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.
This commit is contained in:
@@ -33,33 +33,19 @@ public class DownloadController : ControllerBase
|
|||||||
return NotFound("Downloads directory not found");
|
return NotFound("Downloads directory not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find files related to this job ID
|
// Find files with the specific job ID pattern: jobId_title.ext
|
||||||
var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*")
|
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
||||||
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
||||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
if (jobFiles.Length == 0)
|
if (jobFiles.Length == 0)
|
||||||
{
|
{
|
||||||
// Fallback: look for any audio files that might match the pattern
|
_logger.LogWarning("No files found for job ID: {JobId}", jobId);
|
||||||
var audioFiles = Directory.GetFiles(downloadsDir, "*.*")
|
return NotFound("File not found for the specified job ID");
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var filePath = jobFiles[0];
|
var filePath = jobFiles[0]; // Take the first (most recent) file
|
||||||
var fileName = Path.GetFileName(filePath);
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
|
||||||
if (!System.IO.File.Exists(filePath))
|
if (!System.IO.File.Exists(filePath))
|
||||||
@@ -81,21 +67,11 @@ public class DownloadController : ControllerBase
|
|||||||
_ => "application/octet-stream"
|
_ => "application/octet-stream"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a safe download filename
|
// Create a user-friendly download filename from our pattern
|
||||||
var safeFileName = fileName;
|
var downloadFileName = CreateUserFriendlyFileName(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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||||
return File(fileStream, contentType, safeFileName);
|
return File(fileStream, contentType, downloadFileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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}")]
|
[HttpGet("info/{jobId}")]
|
||||||
public IActionResult GetDownloadInfo(string jobId)
|
public IActionResult GetDownloadInfo(string jobId)
|
||||||
{
|
{
|
||||||
@@ -121,26 +135,28 @@ public class DownloadController : ControllerBase
|
|||||||
return NotFound("Downloads directory not found");
|
return NotFound("Downloads directory not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find files related to this job ID
|
// Find files with the specific job ID pattern
|
||||||
var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*")
|
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
||||||
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
||||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
if (jobFiles.Length == 0)
|
if (jobFiles.Length == 0)
|
||||||
{
|
{
|
||||||
return NotFound("File not found");
|
return NotFound("File not found for the specified job ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
var filePath = jobFiles[0];
|
var filePath = jobFiles[0];
|
||||||
var fileName = Path.GetFileName(filePath);
|
var fileName = Path.GetFileName(filePath);
|
||||||
var fileInfo = new FileInfo(filePath);
|
var fileInfo = new FileInfo(filePath);
|
||||||
|
var friendlyFileName = CreateUserFriendlyFileName(fileName);
|
||||||
|
|
||||||
// Return download metadata
|
// Return download metadata
|
||||||
var info = new
|
var info = new
|
||||||
{
|
{
|
||||||
jobId,
|
jobId,
|
||||||
fileName,
|
fileName,
|
||||||
|
friendlyFileName,
|
||||||
fileSize = FormatFileSize(fileInfo.Length),
|
fileSize = FormatFileSize(fileInfo.Length),
|
||||||
fileSizeBytes = fileInfo.Length,
|
fileSizeBytes = fileInfo.Length,
|
||||||
format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
|
format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
|
||||||
@@ -175,8 +191,10 @@ public class DownloadController : ControllerBase
|
|||||||
return NotFound("Downloads directory not found");
|
return NotFound("Downloads directory not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and delete files related to this job ID
|
// Find and delete files with the specific job ID pattern
|
||||||
var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*");
|
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var deletedCount = 0;
|
var deletedCount = 0;
|
||||||
|
|
||||||
foreach (var filePath in jobFiles)
|
foreach (var filePath in jobFiles)
|
||||||
@@ -185,7 +203,7 @@ public class DownloadController : ControllerBase
|
|||||||
{
|
{
|
||||||
System.IO.File.Delete(filePath);
|
System.IO.File.Delete(filePath);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
_logger.LogInformation("Deleted file: {FilePath}", filePath);
|
_logger.LogInformation("Deleted file: {FilePath} for job: {JobId}", filePath, jobId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -195,10 +213,10 @@ public class DownloadController : ControllerBase
|
|||||||
|
|
||||||
if (deletedCount == 0)
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ public class VideoController : ControllerBase
|
|||||||
var downloadedFileName = await _videoService.ProcessVideoWithProgressAsync(
|
var downloadedFileName = await _videoService.ProcessVideoWithProgressAsync(
|
||||||
job.Request.Url!,
|
job.Request.Url!,
|
||||||
job.Request.Format ?? "mp3",
|
job.Request.Format ?? "mp3",
|
||||||
|
jobId, // Pass the job ID for filename generation
|
||||||
(percentage, message) =>
|
(percentage, message) =>
|
||||||
{
|
{
|
||||||
// Update progress from yt-dlp
|
// Update progress from yt-dlp
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## 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
|
## Setup
|
||||||
|
|
||||||
@@ -22,6 +22,33 @@ TuneRipper.Api/
|
|||||||
??? Program.cs
|
??? 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
|
## Key Changes
|
||||||
|
|
||||||
### 1. Program.cs Updates
|
### 1. Program.cs Updates
|
||||||
@@ -30,21 +57,26 @@ TuneRipper.Api/
|
|||||||
- Updated logging and endpoint information
|
- Updated logging and endpoint information
|
||||||
|
|
||||||
### 2. VideoService Updates
|
### 2. VideoService Updates
|
||||||
- Integrated real `yt-dlp.exe` processing with progress monitoring
|
- **GUID-Based Filenames**: Uses job ID as primary identifier in filename
|
||||||
- Real-time progress parsing from yt-dlp output
|
- **Title Sanitization**: Converts video titles to safe filenames
|
||||||
- File storage in `wwwroot/downloads/` directory
|
- **Direct Job Mapping**: Files can be found using `{jobId}_*.*` pattern
|
||||||
- Progress callbacks for UI updates
|
- **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
|
### 3. VideoController Updates
|
||||||
|
- Passes job ID to VideoService for filename generation
|
||||||
- Uses actual yt-dlp processing instead of simulation
|
- 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
|
- Returns real download URLs for completed files
|
||||||
|
|
||||||
### 4. DownloadController Updates
|
### 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/`
|
- Serves actual MP3 files from `wwwroot/downloads/`
|
||||||
- Proper content type detection
|
- Proper MIME type detection for audio files
|
||||||
- File cleanup capabilities
|
|
||||||
- Download metadata endpoints
|
|
||||||
|
|
||||||
## API Workflow
|
## API Workflow
|
||||||
|
|
||||||
@@ -65,7 +97,7 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Processing started",
|
"message": "Processing started",
|
||||||
"fileName": "job-id-guid",
|
"fileName": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"downloadUrl": null,
|
"downloadUrl": null,
|
||||||
"videoDetails": null
|
"videoDetails": null
|
||||||
}
|
}
|
||||||
@@ -73,21 +105,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
### 2. Monitor Progress
|
### 2. Monitor Progress
|
||||||
```http
|
```http
|
||||||
GET /api/video/status/{jobId}
|
GET /api/video/status/550e8400-e29b-41d4-a716-446655440000
|
||||||
```
|
|
||||||
|
|
||||||
**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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response (Processing):**
|
**Response (Processing):**
|
||||||
@@ -96,13 +114,17 @@ GET /api/video/status/{jobId}
|
|||||||
"percentageComplete": 45,
|
"percentageComplete": 45,
|
||||||
"status": "Processing",
|
"status": "Processing",
|
||||||
"message": "Downloading audio...",
|
"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
|
### 3. Get Final Result
|
||||||
```http
|
```http
|
||||||
GET /api/video/result/{jobId}
|
GET /api/video/result/550e8400-e29b-41d4-a716-446655440000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -110,23 +132,48 @@ GET /api/video/result/{jobId}
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Video processed successfully",
|
"message": "Video processed successfully",
|
||||||
"fileName": "dQw4w9WgXcQ_20241212_093022.mp3",
|
"fileName": "550e8400-e29b-41d4-a716-446655440000_Rick_Astley_Never_Gonna_Give_You_Up.mp3",
|
||||||
"downloadUrl": "/downloads/dQw4w9WgXcQ_20241212_093022.mp3",
|
"downloadUrl": "/downloads/550e8400-e29b-41d4-a716-446655440000_Rick_Astley_Never_Gonna_Give_You_Up.mp3",
|
||||||
"videoDetails": { ... }
|
"videoDetails": { ... }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Download File
|
### 4. Download File
|
||||||
```http
|
```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:**
|
**Alternative Static File Access:**
|
||||||
```http
|
```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
|
## Progress Monitoring
|
||||||
|
|
||||||
The VideoService monitors yt-dlp output and translates it to progress updates:
|
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
|
2. **ffmpeg not available**: Install ffmpeg and add to PATH
|
||||||
3. **Invalid YouTube URL**: URL format validation
|
3. **Invalid YouTube URL**: URL format validation
|
||||||
4. **Download timeout**: 5-minute timeout for processing
|
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:
|
### Error Response Format:
|
||||||
```json
|
```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
|
## Configuration
|
||||||
|
|
||||||
### appsettings.json
|
### appsettings.json
|
||||||
@@ -189,6 +224,7 @@ The VideoService monitors yt-dlp output and translates it to progress updates:
|
|||||||
- `--no-playlist`: Single video only
|
- `--no-playlist`: Single video only
|
||||||
- `--no-warnings`: Clean output
|
- `--no-warnings`: Clean output
|
||||||
- `--newline`: Line-by-line progress
|
- `--newline`: Line-by-line progress
|
||||||
|
- `--output "{jobId}_{title}.%(ext)s"`: Uses job ID-based filename
|
||||||
|
|
||||||
## Testing
|
## 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`
|
1. Start the API: `dotnet run --project TuneRipper.Api`
|
||||||
2. Use API testing tools or the Blazor client
|
2. Use API testing tools or the Blazor client
|
||||||
3. Check logs for yt-dlp progress
|
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:
|
### Health Check:
|
||||||
```http
|
```http
|
||||||
@@ -211,11 +255,12 @@ GET /api/video/jobs
|
|||||||
## Production Considerations
|
## Production Considerations
|
||||||
|
|
||||||
1. **File Storage**: Consider cloud storage for production
|
1. **File Storage**: Consider cloud storage for production
|
||||||
2. **Cleanup**: Implement automatic file cleanup/retention
|
2. **Cleanup Strategy**: Implement automatic file cleanup/retention using job completion timestamps
|
||||||
3. **Security**: Validate URLs and sanitize filenames
|
3. **Security**: Job IDs provide secure file access (no directory traversal)
|
||||||
4. **Performance**: Consider queue-based processing for high load
|
4. **Performance**: Consider queue-based processing for high load
|
||||||
5. **Monitoring**: Add detailed logging and metrics
|
5. **Monitoring**: Add detailed logging and metrics
|
||||||
6. **Updates**: Keep yt-dlp.exe updated for compatibility
|
6. **Updates**: Keep yt-dlp.exe updated for compatibility
|
||||||
|
7. **Scalability**: GUID-based naming prevents conflicts in distributed systems
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -224,6 +269,7 @@ GET /api/video/jobs
|
|||||||
2. **Antivirus**: yt-dlp.exe may be flagged by antivirus
|
2. **Antivirus**: yt-dlp.exe may be flagged by antivirus
|
||||||
3. **Network**: YouTube may block requests from some IPs
|
3. **Network**: YouTube may block requests from some IPs
|
||||||
4. **Format Support**: Some videos may not support audio extraction
|
4. **Format Support**: Some videos may not support audio extraction
|
||||||
|
5. **Long Titles**: Titles are truncated to 50 characters for filesystem compatibility
|
||||||
|
|
||||||
### Debug Logging:
|
### Debug Logging:
|
||||||
Enable debug logging for VideoService to see detailed yt-dlp output:
|
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"
|
"TuneRipper.Api.Services.VideoService": "Debug"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
@@ -13,7 +13,7 @@ public interface IVideoService
|
|||||||
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
||||||
bool IsValidYouTubeUrl(string url);
|
bool IsValidYouTubeUrl(string url);
|
||||||
string ExtractVideoId(string url);
|
string ExtractVideoId(string url);
|
||||||
Task<string?> ProcessVideoWithProgressAsync(string url, string format, Action<int, string> progressCallback);
|
Task<string?> ProcessVideoWithProgressAsync(string url, string format, string jobId, Action<int, string> progressCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class VideoService : IVideoService
|
public class VideoService : IVideoService
|
||||||
@@ -90,7 +90,7 @@ public class VideoService : IVideoService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> ProcessVideoWithProgressAsync(string url, string format, Action<int, string> progressCallback)
|
public async Task<string?> ProcessVideoWithProgressAsync(string url, string format, string jobId, Action<int, string> progressCallback)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -105,10 +105,15 @@ public class VideoService : IVideoService
|
|||||||
var downloadDir = Path.Combine(_environment.WebRootPath, "downloads");
|
var downloadDir = Path.Combine(_environment.WebRootPath, "downloads");
|
||||||
Directory.CreateDirectory(downloadDir);
|
Directory.CreateDirectory(downloadDir);
|
||||||
|
|
||||||
// Generate safe filename
|
// Get video details first for better filename
|
||||||
var videoId = ExtractVideoId(url);
|
var videoDetails = await GetVideoDetailsAsync(url);
|
||||||
var fileName = $"{videoId}_{DateTime.Now:yyyyMMdd_HHmmss}.%(ext)s";
|
|
||||||
var outputTemplate = Path.Combine(downloadDir, fileName);
|
// 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
|
// Build yt-dlp arguments
|
||||||
var arguments = new StringBuilder();
|
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}");
|
throw new Exception($"yt-dlp failed with exit code {process.ExitCode}. Error: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the downloaded file
|
// Find the downloaded file with our job ID pattern
|
||||||
var downloadedFiles = Directory.GetFiles(downloadDir, $"{videoId}_*.*")
|
var downloadedFiles = Directory.GetFiles(downloadDir, $"{jobId}_*.*")
|
||||||
.Where(f => !f.EndsWith(".part"))
|
.Where(f => !f.EndsWith(".part"))
|
||||||
.OrderByDescending(File.GetCreationTime)
|
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
if (downloadedFiles.Length == 0)
|
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 downloadedFile = downloadedFiles[0];
|
||||||
var finalFileName = Path.GetFileName(downloadedFile);
|
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");
|
progressCallback(100, "Processing completed successfully");
|
||||||
|
|
||||||
@@ -205,12 +210,75 @@ public class VideoService : IVideoService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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}");
|
progressCallback(0, $"Processing failed: {ex.Message}");
|
||||||
throw;
|
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<int, string> progressCallback)
|
private void ParseProgressFromOutput(string output, Action<int, string> progressCallback)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in New Issue
Block a user