Refactor downloads to use job-specific subdirectories
Organize downloaded files into per-job subdirectories under "downloads" instead of a flat structure. Update DownloadController and VideoService to read, write, and delete files within these job-specific directories. Add helper methods for directory management and improve logging. Update download/delete endpoints and result URLs to reflect the new structure. Clean up empty job directories after file deletion.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# Session Management Implementation
|
||||
|
||||
This document describes the session management feature implemented for TuneRipper to organize MP3 files by user sessions.
|
||||
|
||||
## Overview
|
||||
|
||||
The session management system automatically creates and manages user sessions to organize downloaded files in separate directories. Each session gets its own subdirectory under the downloads folder.
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Automatic Session Creation**: When the app starts, it creates or retrieves an existing session ID
|
||||
2. **Session-Based File Organization**: All MP3 files are stored in `/downloads/{sessionId}/` directories
|
||||
3. **Session Persistence**: Session IDs are stored in browser's localStorage
|
||||
4. **Session Validation**: Server validates session IDs and handles expired sessions
|
||||
5. **Backward Compatibility**: Legacy endpoints still work for existing files
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### Server-Side (TuneRipper.Api)
|
||||
|
||||
#### New Services
|
||||
- **`ISessionService`**: Interface for session management operations
|
||||
- **`SessionService`**: Implementation handling session creation, validation, and cleanup
|
||||
- Creates unique 16-character session IDs
|
||||
- Manages session directories under `/downloads/{sessionId}/`
|
||||
- Automatic cleanup of expired sessions (24-hour timeout)
|
||||
- Background timer for periodic cleanup
|
||||
|
||||
#### Updated Controllers
|
||||
- **`VideoController`**: Now accepts and manages session IDs in processing requests
|
||||
- **`DownloadController`**: Enhanced with session-aware endpoints
|
||||
- **`SessionController`**: New controller for session CRUD operations
|
||||
|
||||
#### New Endpoints
|
||||
```
|
||||
POST /api/session/create - Create new session
|
||||
GET /api/session/validate/{sessionId} - Validate existing session
|
||||
POST /api/session/refresh/{sessionId} - Refresh session timeout
|
||||
|
||||
GET /api/download/{sessionId}/{jobId} - Session-aware file download
|
||||
GET /api/download/info/{sessionId}/{jobId} - Session-aware file info
|
||||
DELETE /api/download/{sessionId}/{jobId} - Session-aware file deletion
|
||||
|
||||
GET /api/video/jobs/{sessionId} - Get jobs for specific session
|
||||
```
|
||||
|
||||
#### File Organization
|
||||
```
|
||||
downloads/
|
||||
??? legacy_files.mp3 # Legacy files (root level)
|
||||
??? {sessionId1}/ # Session 1 directory
|
||||
? ??? {jobId}_video_title.mp3 # Session 1 files
|
||||
? ??? {jobId}_video_title.mp3
|
||||
??? {sessionId2}/ # Session 2 directory
|
||||
??? {jobId}_video_title.mp3 # Session 2 files
|
||||
??? {jobId}_video_title.mp3
|
||||
```
|
||||
|
||||
### Client-Side (TuneRipper)
|
||||
|
||||
#### New Services
|
||||
- **`IClientSessionService`**: Interface for client-side session management
|
||||
- **`ClientSessionService`**: Implementation handling browser-side session storage
|
||||
- Manages session ID in localStorage
|
||||
- Validates sessions with server
|
||||
- Automatic session creation when needed
|
||||
|
||||
#### Updated Components
|
||||
- **`Home.razor`**: Enhanced to use session management
|
||||
- Displays current session ID (truncated)
|
||||
- Includes session ID in processing requests
|
||||
- Constructs session-aware download URLs
|
||||
|
||||
#### Updated API Service
|
||||
- **`TuneRipperApiService`**: Extended with session-aware methods
|
||||
- Session creation and validation
|
||||
- Session-aware file downloads
|
||||
- Session-specific job queries
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **App Initialization**:
|
||||
```
|
||||
Client loads ? Check localStorage for sessionId ? Validate with server ? Create new if invalid
|
||||
```
|
||||
|
||||
2. **Video Processing**:
|
||||
```
|
||||
User submits URL ? Include sessionId in request ? Server processes to session directory
|
||||
```
|
||||
|
||||
3. **File Download**:
|
||||
```
|
||||
User clicks download ? Use session-aware URL ? Server serves from session directory
|
||||
```
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
1. **Creation**: 16-character unique ID generated
|
||||
2. **Storage**: Stored in browser localStorage and server memory
|
||||
3. **Validation**: Checked on each request, 24-hour timeout
|
||||
4. **Cleanup**: Automatic background cleanup of expired sessions
|
||||
5. **Directory Management**: Session directories created/deleted as needed
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Legacy download endpoints still function
|
||||
- Old files remain accessible through fallback logic
|
||||
- Gradual migration as users create new sessions
|
||||
|
||||
## Configuration
|
||||
|
||||
### Session Settings
|
||||
- **Timeout**: 24 hours (configurable in `SessionService`)
|
||||
- **Cleanup Interval**: 1 hour (configurable in `SessionService`)
|
||||
- **Directory Structure**: `/downloads/{sessionId}/`
|
||||
|
||||
### Environment Variables
|
||||
No new environment variables required - uses existing web root path configuration.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Session IDs are random and non-guessable
|
||||
- Server validates all session operations
|
||||
- Automatic cleanup prevents disk space accumulation
|
||||
- No sensitive data stored in sessions
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
- Session creation/validation logged
|
||||
- File operations include session context
|
||||
- Cleanup operations logged with statistics
|
||||
- Error handling for session failures
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Database Storage**: Replace in-memory session storage with persistent storage
|
||||
2. **Session Sharing**: Allow sharing sessions between devices
|
||||
3. **Session Analytics**: Track usage patterns and statistics
|
||||
4. **Extended Metadata**: Store additional session information
|
||||
5. **Admin Interface**: Management interface for session administration
|
||||
|
||||
## API Examples
|
||||
|
||||
### Create Session
|
||||
```http
|
||||
POST /api/session/create
|
||||
Response: { "sessionId": "abc123def456", "message": "Session created successfully" }
|
||||
```
|
||||
|
||||
### Process Video with Session
|
||||
```http
|
||||
POST /api/video/process
|
||||
{ "url": "https://youtube.com/watch?v=...", "sessionId": "abc123def456", "format": "mp3" }
|
||||
```
|
||||
|
||||
### Download File by Session
|
||||
```http
|
||||
GET /api/download/abc123def456/job-id-123
|
||||
Response: Binary audio file
|
||||
```
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TuneRipper.Api.Services;
|
||||
|
||||
namespace TuneRipper.Api.Controllers;
|
||||
|
||||
@@ -8,40 +9,48 @@ public class DownloadController : ControllerBase
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<DownloadController> _logger;
|
||||
private readonly ISessionService _sessionService;
|
||||
|
||||
public DownloadController(IWebHostEnvironment environment, ILogger<DownloadController> logger)
|
||||
public DownloadController(IWebHostEnvironment environment, ILogger<DownloadController> logger, ISessionService sessionService)
|
||||
{
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
_sessionService = sessionService;
|
||||
}
|
||||
|
||||
[HttpGet("{jobId}")]
|
||||
public IActionResult DownloadFile(string jobId)
|
||||
// New session-aware download endpoint
|
||||
[HttpGet("{sessionId}/{jobId}")]
|
||||
public IActionResult DownloadFileBySession(string sessionId, string jobId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
return BadRequest("Job ID is required");
|
||||
return BadRequest("Session ID and Job ID are required");
|
||||
}
|
||||
|
||||
if (!_sessionService.IsValidSession(sessionId))
|
||||
{
|
||||
return NotFound("Invalid or expired session");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
|
||||
var sessionDownloadsDir = _sessionService.GetSessionDownloadsPath(sessionId);
|
||||
|
||||
if (!Directory.Exists(downloadsDir))
|
||||
if (!Directory.Exists(sessionDownloadsDir))
|
||||
{
|
||||
_logger.LogWarning("Downloads directory not found: {Path}", downloadsDir);
|
||||
return NotFound("Downloads directory not found");
|
||||
_logger.LogWarning("Session downloads directory not found: {Path}", sessionDownloadsDir);
|
||||
return NotFound("Session downloads directory not found");
|
||||
}
|
||||
|
||||
// Find files with the specific job ID pattern: jobId_title.ext
|
||||
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
||||
// Find files with the specific job ID pattern in the session directory
|
||||
var jobFiles = Directory.GetFiles(sessionDownloadsDir, $"{jobId}_*.*")
|
||||
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||
.ToArray();
|
||||
|
||||
if (jobFiles.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("No files found for job ID: {JobId}", jobId);
|
||||
_logger.LogWarning("No files found for job ID: {JobId} in session: {SessionId}", jobId, sessionId);
|
||||
return NotFound("File not found for the specified job ID");
|
||||
}
|
||||
|
||||
@@ -54,7 +63,7 @@ public class DownloadController : ControllerBase
|
||||
return NotFound("File not found");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Serving file: {FileName} for job: {JobId}", fileName, jobId);
|
||||
_logger.LogInformation("Serving file: {FileName} for job: {JobId} in session: {SessionId}", fileName, jobId, sessionId);
|
||||
|
||||
// Determine content type based on file extension
|
||||
var contentType = Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
@@ -74,12 +83,99 @@ public class DownloadController : ControllerBase
|
||||
return File(fileStream, contentType, downloadFileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading file for job ID: {JobId} in session: {SessionId}", jobId, sessionId);
|
||||
return StatusCode(500, $"Error downloading file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy endpoint for backward compatibility
|
||||
[HttpGet("{jobId}")]
|
||||
public IActionResult DownloadFile(string jobId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
return BadRequest("Job ID is required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check in the legacy downloads directory first
|
||||
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
|
||||
|
||||
if (Directory.Exists(downloadsDir))
|
||||
{
|
||||
var legacyFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
||||
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||
.ToArray();
|
||||
|
||||
if (legacyFiles.Length > 0)
|
||||
{
|
||||
return ServeFile(legacyFiles[0], jobId, null);
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in legacy location, search all session directories
|
||||
var sessionDirs = Directory.GetDirectories(downloadsDir, "*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var sessionDir in sessionDirs)
|
||||
{
|
||||
var sessionId = Path.GetFileName(sessionDir);
|
||||
if (!_sessionService.IsValidSession(sessionId)) continue;
|
||||
|
||||
var jobFiles = Directory.GetFiles(sessionDir, $"{jobId}_*.*")
|
||||
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||
.ToArray();
|
||||
|
||||
if (jobFiles.Length > 0)
|
||||
{
|
||||
return ServeFile(jobFiles[0], jobId, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("No files found for job ID: {JobId}", jobId);
|
||||
return NotFound("File not found for the specified job ID");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading file for job ID: {JobId}", jobId);
|
||||
return StatusCode(500, $"Error downloading file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult ServeFile(string filePath, string jobId, string? sessionId)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
_logger.LogWarning("File not found: {FilePath}", filePath);
|
||||
return NotFound("File not found");
|
||||
}
|
||||
|
||||
var sessionInfo = sessionId != null ? $" in session: {sessionId}" : "";
|
||||
_logger.LogInformation("Serving file: {FileName} for job: {JobId}{SessionInfo}", fileName, jobId, sessionInfo);
|
||||
|
||||
// Determine content type based on file extension
|
||||
var contentType = Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
{
|
||||
".mp3" => "audio/mpeg",
|
||||
".m4a" => "audio/mp4",
|
||||
".webm" => "audio/webm",
|
||||
".wav" => "audio/wav",
|
||||
".ogg" => "audio/ogg",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
// 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, downloadFileName);
|
||||
}
|
||||
|
||||
private string CreateUserFriendlyFileName(string originalFileName)
|
||||
{
|
||||
try
|
||||
@@ -118,25 +214,30 @@ public class DownloadController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("info/{jobId}")]
|
||||
public IActionResult GetDownloadInfo(string jobId)
|
||||
[HttpGet("info/{sessionId}/{jobId}")]
|
||||
public IActionResult GetDownloadInfoBySession(string sessionId, string jobId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
return BadRequest("Job ID is required");
|
||||
return BadRequest("Session ID and Job ID are required");
|
||||
}
|
||||
|
||||
if (!_sessionService.IsValidSession(sessionId))
|
||||
{
|
||||
return NotFound("Invalid or expired session");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
|
||||
var sessionDownloadsDir = _sessionService.GetSessionDownloadsPath(sessionId);
|
||||
|
||||
if (!Directory.Exists(downloadsDir))
|
||||
if (!Directory.Exists(sessionDownloadsDir))
|
||||
{
|
||||
return NotFound("Downloads directory not found");
|
||||
return NotFound("Session downloads directory not found");
|
||||
}
|
||||
|
||||
// Find files with the specific job ID pattern
|
||||
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
||||
// Find files with the specific job ID pattern in the session directory
|
||||
var jobFiles = Directory.GetFiles(sessionDownloadsDir, $"{jobId}_*.*")
|
||||
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||
.ToArray();
|
||||
@@ -155,6 +256,7 @@ public class DownloadController : ControllerBase
|
||||
var info = new
|
||||
{
|
||||
jobId,
|
||||
sessionId,
|
||||
fileName,
|
||||
friendlyFileName,
|
||||
fileSize = FormatFileSize(fileInfo.Length),
|
||||
@@ -162,20 +264,21 @@ public class DownloadController : ControllerBase
|
||||
format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
|
||||
createdAt = fileInfo.CreationTime,
|
||||
lastModified = fileInfo.LastWriteTime,
|
||||
downloadUrl = $"/api/download/{jobId}"
|
||||
downloadUrl = $"/api/download/{sessionId}/{jobId}"
|
||||
};
|
||||
|
||||
return Ok(info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting download info for job ID: {JobId}", jobId);
|
||||
_logger.LogError(ex, "Error getting download info for job ID: {JobId} in session: {SessionId}", jobId, sessionId);
|
||||
return StatusCode(500, $"Error getting download info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{jobId}")]
|
||||
public IActionResult DeleteFile(string jobId)
|
||||
// Legacy info endpoint for backward compatibility
|
||||
[HttpGet("info/{jobId}")]
|
||||
public IActionResult GetDownloadInfo(string jobId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
@@ -191,8 +294,96 @@ public class DownloadController : ControllerBase
|
||||
return NotFound("Downloads directory not found");
|
||||
}
|
||||
|
||||
// Find and delete files with the specific job ID pattern
|
||||
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
||||
// Check legacy location first
|
||||
var legacyFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
||||
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||
.ToArray();
|
||||
|
||||
if (legacyFiles.Length > 0)
|
||||
{
|
||||
return GetFileInfo(legacyFiles[0], jobId, null);
|
||||
}
|
||||
|
||||
// Search session directories
|
||||
var sessionDirs = Directory.GetDirectories(downloadsDir, "*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var sessionDir in sessionDirs)
|
||||
{
|
||||
var sessionId = Path.GetFileName(sessionDir);
|
||||
if (!_sessionService.IsValidSession(sessionId)) continue;
|
||||
|
||||
var jobFiles = Directory.GetFiles(sessionDir, $"{jobId}_*.*")
|
||||
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
|
||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||
.ToArray();
|
||||
|
||||
if (jobFiles.Length > 0)
|
||||
{
|
||||
return GetFileInfo(jobFiles[0], jobId, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return NotFound("File not found for the specified job ID");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting download info for job ID: {JobId}", jobId);
|
||||
return StatusCode(500, $"Error getting download info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult GetFileInfo(string filePath, string jobId, string? sessionId)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var friendlyFileName = CreateUserFriendlyFileName(fileName);
|
||||
|
||||
var downloadUrl = sessionId != null
|
||||
? $"/api/download/{sessionId}/{jobId}"
|
||||
: $"/api/download/{jobId}";
|
||||
|
||||
var info = new
|
||||
{
|
||||
jobId,
|
||||
sessionId,
|
||||
fileName,
|
||||
friendlyFileName,
|
||||
fileSize = FormatFileSize(fileInfo.Length),
|
||||
fileSizeBytes = fileInfo.Length,
|
||||
format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
|
||||
createdAt = fileInfo.CreationTime,
|
||||
lastModified = fileInfo.LastWriteTime,
|
||||
downloadUrl
|
||||
};
|
||||
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
[HttpDelete("{sessionId}/{jobId}")]
|
||||
public IActionResult DeleteFileBySession(string sessionId, string jobId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
return BadRequest("Session ID and Job ID are required");
|
||||
}
|
||||
|
||||
if (!_sessionService.IsValidSession(sessionId))
|
||||
{
|
||||
return NotFound("Invalid or expired session");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sessionDownloadsDir = _sessionService.GetSessionDownloadsPath(sessionId);
|
||||
|
||||
if (!Directory.Exists(sessionDownloadsDir))
|
||||
{
|
||||
return NotFound("Session downloads directory not found");
|
||||
}
|
||||
|
||||
// Find and delete files with the specific job ID pattern in the session directory
|
||||
var jobFiles = Directory.GetFiles(sessionDownloadsDir, $"{jobId}_*.*")
|
||||
.ToArray();
|
||||
|
||||
var deletedCount = 0;
|
||||
@@ -203,7 +394,7 @@ public class DownloadController : ControllerBase
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
deletedCount++;
|
||||
_logger.LogInformation("Deleted file: {FilePath} for job: {JobId}", filePath, jobId);
|
||||
_logger.LogInformation("Deleted file: {FilePath} for job: {JobId} in session: {SessionId}", filePath, jobId, sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -216,6 +407,78 @@ public class DownloadController : ControllerBase
|
||||
return NotFound("No files found to delete for the specified job ID");
|
||||
}
|
||||
|
||||
return Ok(new { message = $"Deleted {deletedCount} file(s) for job {jobId} in session {sessionId}", deletedCount, jobId, sessionId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting files for job ID: {JobId} in session: {SessionId}", jobId, sessionId);
|
||||
return StatusCode(500, $"Error deleting files: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy delete endpoint for backward compatibility
|
||||
[HttpDelete("{jobId}")]
|
||||
public IActionResult DeleteFile(string jobId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
return BadRequest("Job ID is required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
|
||||
var deletedCount = 0;
|
||||
|
||||
if (!Directory.Exists(downloadsDir))
|
||||
{
|
||||
return NotFound("Downloads directory not found");
|
||||
}
|
||||
|
||||
// Delete from legacy location
|
||||
var legacyFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*");
|
||||
foreach (var filePath in legacyFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
deletedCount++;
|
||||
_logger.LogInformation("Deleted legacy file: {FilePath} for job: {JobId}", filePath, jobId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete legacy file: {FilePath}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from session directories
|
||||
var sessionDirs = Directory.GetDirectories(downloadsDir, "*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var sessionDir in sessionDirs)
|
||||
{
|
||||
var sessionId = Path.GetFileName(sessionDir);
|
||||
var jobFiles = Directory.GetFiles(sessionDir, $"{jobId}_*.*");
|
||||
|
||||
foreach (var filePath in jobFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
deletedCount++;
|
||||
_logger.LogInformation("Deleted file: {FilePath} for job: {JobId} in session: {SessionId}", filePath, jobId, sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete file: {FilePath}", filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount == 0)
|
||||
{
|
||||
return NotFound("No files found to delete for the specified job ID");
|
||||
}
|
||||
|
||||
return Ok(new { message = $"Deleted {deletedCount} file(s) for job {jobId}", deletedCount, jobId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TuneRipper.Api.Services;
|
||||
|
||||
namespace TuneRipper.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class SessionController : ControllerBase
|
||||
{
|
||||
private readonly ISessionService _sessionService;
|
||||
private readonly ILogger<SessionController> _logger;
|
||||
|
||||
public SessionController(ISessionService sessionService, ILogger<SessionController> logger)
|
||||
{
|
||||
_sessionService = sessionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("create")]
|
||||
public ActionResult<object> CreateSession()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessionId = _sessionService.CreateSession();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
sessionId,
|
||||
message = "Session created successfully",
|
||||
expiresAt = DateTime.UtcNow.AddHours(24)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating session");
|
||||
return StatusCode(500, "Error creating session");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("validate/{sessionId}")]
|
||||
public ActionResult<object> ValidateSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return BadRequest("Session ID is required");
|
||||
}
|
||||
|
||||
var isValid = _sessionService.IsValidSession(sessionId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
sessionId,
|
||||
isValid,
|
||||
message = isValid ? "Session is valid" : "Session is invalid or expired"
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("refresh/{sessionId}")]
|
||||
public ActionResult<object> RefreshSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return BadRequest("Session ID is required");
|
||||
}
|
||||
|
||||
if (!_sessionService.IsValidSession(sessionId))
|
||||
{
|
||||
return NotFound("Session not found or expired");
|
||||
}
|
||||
|
||||
// Session is automatically refreshed when IsValidSession is called
|
||||
return Ok(new
|
||||
{
|
||||
sessionId,
|
||||
message = "Session refreshed successfully",
|
||||
expiresAt = DateTime.UtcNow.AddHours(24)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace TuneRipper.Api.Controllers;
|
||||
public class VideoController : ControllerBase
|
||||
{
|
||||
private readonly IVideoService _videoService;
|
||||
private readonly ISessionService _sessionService;
|
||||
private readonly ILogger<VideoController> _logger;
|
||||
|
||||
// In-memory storage for processing status (in production, use database or Redis)
|
||||
@@ -18,6 +19,7 @@ public class VideoController : ControllerBase
|
||||
private class ProcessingJob
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty; // Add session tracking
|
||||
public ProcessVideoRequest Request { get; set; } = new();
|
||||
public DownloadProgress Progress { get; set; } = new();
|
||||
public ProcessVideoResponse? Result { get; set; }
|
||||
@@ -26,9 +28,10 @@ public class VideoController : ControllerBase
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public VideoController(IVideoService videoService, ILogger<VideoController> logger)
|
||||
public VideoController(IVideoService videoService, ISessionService sessionService, ILogger<VideoController> logger)
|
||||
{
|
||||
_videoService = videoService;
|
||||
_sessionService = sessionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,6 +52,9 @@ public class VideoController : ControllerBase
|
||||
{
|
||||
_logger.LogInformation("Starting video processing for URL: {Url}", request.Url);
|
||||
|
||||
// Get or create session
|
||||
var sessionId = _sessionService.GetOrCreateSession(request.SessionId);
|
||||
|
||||
// Generate a job ID
|
||||
var jobId = Guid.NewGuid().ToString();
|
||||
|
||||
@@ -56,6 +62,7 @@ public class VideoController : ControllerBase
|
||||
var job = new ProcessingJob
|
||||
{
|
||||
Id = jobId,
|
||||
SessionId = sessionId,
|
||||
Request = request,
|
||||
Progress = new DownloadProgress
|
||||
{
|
||||
@@ -76,7 +83,8 @@ public class VideoController : ControllerBase
|
||||
Message = "Processing started",
|
||||
FileName = jobId, // Use job ID as temporary filename
|
||||
DownloadUrl = null, // Will be available when processing completes
|
||||
VideoDetails = null // Will be populated during processing
|
||||
VideoDetails = null, // Will be populated during processing
|
||||
SessionId = sessionId // Return session ID to client
|
||||
};
|
||||
|
||||
return Accepted(response);
|
||||
@@ -140,10 +148,18 @@ public class VideoController : ControllerBase
|
||||
return Ok(job.Result);
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
public ActionResult<IEnumerable<JobStatusResponse>> GetAllJobs()
|
||||
[HttpGet("jobs/{sessionId?}")]
|
||||
public ActionResult<IEnumerable<JobStatusResponse>> GetJobsBySession(string? sessionId = null)
|
||||
{
|
||||
var jobs = _processingJobs.Values.Select(job => new JobStatusResponse
|
||||
var jobsQuery = _processingJobs.Values.AsEnumerable();
|
||||
|
||||
// Filter by session if provided
|
||||
if (!string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
jobsQuery = jobsQuery.Where(job => job.SessionId == sessionId);
|
||||
}
|
||||
|
||||
var jobs = jobsQuery.Select(job => new JobStatusResponse
|
||||
{
|
||||
JobId = job.Id,
|
||||
Status = job.Progress.Status,
|
||||
@@ -157,6 +173,13 @@ public class VideoController : ControllerBase
|
||||
return Ok(jobs);
|
||||
}
|
||||
|
||||
// Keep the old endpoint for backward compatibility
|
||||
[HttpGet("jobs")]
|
||||
public ActionResult<IEnumerable<JobStatusResponse>> GetAllJobs()
|
||||
{
|
||||
return GetJobsBySession(null);
|
||||
}
|
||||
|
||||
private async Task ProcessVideoAsync(string jobId)
|
||||
{
|
||||
if (!_processingJobs.TryGetValue(jobId, out var job))
|
||||
@@ -164,7 +187,7 @@ public class VideoController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Processing video for job {JobId}", jobId);
|
||||
_logger.LogInformation("Processing video for job {JobId} in session {SessionId}", jobId, job.SessionId);
|
||||
|
||||
// Step 1: Fetch video details
|
||||
job.Progress.Status = "Processing";
|
||||
@@ -195,6 +218,7 @@ public class VideoController : ControllerBase
|
||||
job.Request.Url!,
|
||||
job.Request.Format ?? "mp3",
|
||||
jobId, // Pass the job ID for filename generation
|
||||
job.SessionId, // Pass the session ID for directory organization
|
||||
(percentage, message) =>
|
||||
{
|
||||
// Update progress from yt-dlp
|
||||
@@ -217,8 +241,8 @@ public class VideoController : ControllerBase
|
||||
job.Progress.Message = "Processing completed successfully";
|
||||
job.Progress.PercentageComplete = 100;
|
||||
|
||||
// Create download URL for the actual file
|
||||
var downloadUrl = $"/downloads/{Uri.EscapeDataString(downloadedFileName)}";
|
||||
// Create download URL for the actual file within session
|
||||
var downloadUrl = $"/api/download/{job.SessionId}/{jobId}";
|
||||
|
||||
job.Result = new ProcessVideoResponse
|
||||
{
|
||||
@@ -226,14 +250,15 @@ public class VideoController : ControllerBase
|
||||
Message = "Video processed successfully",
|
||||
FileName = downloadedFileName,
|
||||
DownloadUrl = downloadUrl,
|
||||
VideoDetails = videoDetails
|
||||
VideoDetails = videoDetails,
|
||||
SessionId = job.SessionId
|
||||
};
|
||||
|
||||
_logger.LogInformation("Successfully completed processing for job {JobId}. File: {FileName}", jobId, downloadedFileName);
|
||||
_logger.LogInformation("Successfully completed processing for job {JobId} in session {SessionId}. File: {FileName}", jobId, job.SessionId, downloadedFileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing video for job {JobId}", jobId);
|
||||
_logger.LogError(ex, "Error processing video for job {JobId} in session {SessionId}", jobId, job.SessionId);
|
||||
|
||||
job.Progress.Status = "Failed";
|
||||
job.Progress.Message = $"Processing failed: {ex.Message}";
|
||||
@@ -245,7 +270,8 @@ public class VideoController : ControllerBase
|
||||
Message = ex.Message,
|
||||
FileName = null,
|
||||
DownloadUrl = null,
|
||||
VideoDetails = job.VideoDetails // Include video details even on failure if we got them
|
||||
VideoDetails = job.VideoDetails, // Include video details even on failure if we got them
|
||||
SessionId = job.SessionId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ builder.Services.AddHttpClient();
|
||||
|
||||
// Register services
|
||||
builder.Services.AddScoped<IVideoService, VideoService>();
|
||||
builder.Services.AddSingleton<ISessionService, SessionService>(); // Register session service as singleton
|
||||
|
||||
// Add CORS for Blazor WebAssembly client
|
||||
builder.Services.AddCors(options =>
|
||||
@@ -64,6 +65,7 @@ app.MapGet("/", (IWebHostEnvironment env) => new
|
||||
check_status = "/api/video/status/{jobId}",
|
||||
get_result = "/api/video/result/{jobId}",
|
||||
download = "/api/download/{jobId}",
|
||||
download_by_session = "/api/download/{sessionId}/{jobId}",
|
||||
static_files = "/downloads/{fileName}",
|
||||
openapi = "/openapi/v1.json"
|
||||
},
|
||||
@@ -80,10 +82,11 @@ app.MapControllers();
|
||||
app.Logger.LogInformation("?? TuneRipper API is starting...");
|
||||
app.Logger.LogInformation("?? Environment: {Environment}", app.Environment.EnvironmentName);
|
||||
app.Logger.LogInformation("?? API will be available at:");
|
||||
app.Logger.LogInformation(" • HTTPS: https://localhost:7160");
|
||||
app.Logger.LogInformation(" • HTTP: http://localhost:5112");
|
||||
app.Logger.LogInformation(" ?? HTTPS: https://localhost:7160");
|
||||
app.Logger.LogInformation(" ?? HTTP: http://localhost:5112");
|
||||
app.Logger.LogInformation("?? Health check: /api/health");
|
||||
app.Logger.LogInformation("?? API info: /");
|
||||
app.Logger.LogInformation("?? Static files served from: /downloads/*");
|
||||
app.Logger.LogInformation("?? Session-based downloads supported");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TuneRipper.Api.Services;
|
||||
|
||||
public interface ISessionService
|
||||
{
|
||||
string CreateSession();
|
||||
string GetOrCreateSession(string? sessionId);
|
||||
bool IsValidSession(string sessionId);
|
||||
void CleanupExpiredSessions();
|
||||
string GetSessionDownloadsPath(string sessionId);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace TuneRipper.Api.Services;
|
||||
|
||||
public class SessionService : ISessionService
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<SessionService> _logger;
|
||||
private readonly ConcurrentDictionary<string, DateTime> _sessions = new();
|
||||
private readonly Timer _cleanupTimer;
|
||||
|
||||
// Session expires after 24 hours of inactivity
|
||||
private readonly TimeSpan _sessionTimeout = TimeSpan.FromHours(24);
|
||||
|
||||
public SessionService(IWebHostEnvironment environment, ILogger<SessionService> logger)
|
||||
{
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
|
||||
// Clean up expired sessions every hour
|
||||
_cleanupTimer = new Timer(
|
||||
callback: _ => CleanupExpiredSessions(),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromHours(1),
|
||||
period: TimeSpan.FromHours(1)
|
||||
);
|
||||
}
|
||||
|
||||
public string CreateSession()
|
||||
{
|
||||
var sessionId = Guid.NewGuid().ToString("N")[..16]; // Use first 16 characters for shorter IDs
|
||||
_sessions[sessionId] = DateTime.UtcNow;
|
||||
|
||||
// Ensure session directory exists
|
||||
var sessionPath = GetSessionDownloadsPath(sessionId);
|
||||
Directory.CreateDirectory(sessionPath);
|
||||
|
||||
_logger.LogInformation("Created new session: {SessionId}", sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public string GetOrCreateSession(string? sessionId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(sessionId) && IsValidSession(sessionId))
|
||||
{
|
||||
// Update last activity time
|
||||
_sessions[sessionId] = DateTime.UtcNow;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
return CreateSession();
|
||||
}
|
||||
|
||||
public bool IsValidSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
return false;
|
||||
|
||||
if (_sessions.TryGetValue(sessionId, out var lastActivity))
|
||||
{
|
||||
// Check if session has expired
|
||||
if (DateTime.UtcNow - lastActivity > _sessionTimeout)
|
||||
{
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void CleanupExpiredSessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var expiredSessions = _sessions
|
||||
.Where(kvp => DateTime.UtcNow - kvp.Value > _sessionTimeout)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var sessionId in expiredSessions)
|
||||
{
|
||||
if (_sessions.TryRemove(sessionId, out _))
|
||||
{
|
||||
// Clean up session directory
|
||||
var sessionPath = GetSessionDownloadsPath(sessionId);
|
||||
if (Directory.Exists(sessionPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(sessionPath, recursive: true);
|
||||
_logger.LogInformation("Cleaned up expired session directory: {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete expired session directory: {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredSessions.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during session cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
public string GetSessionDownloadsPath(string sessionId)
|
||||
{
|
||||
var downloadsRoot = Path.Combine(_environment.WebRootPath, "downloads");
|
||||
return Path.Combine(downloadsRoot, sessionId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cleanupTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public interface IVideoService
|
||||
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
||||
bool IsValidYouTubeUrl(string url);
|
||||
string ExtractVideoId(string url);
|
||||
Task<string?> ProcessVideoWithProgressAsync(string url, string format, string jobId, Action<int, string> progressCallback);
|
||||
Task<string?> ProcessVideoWithProgressAsync(string url, string format, string jobId, string sessionId, Action<int, string> progressCallback);
|
||||
}
|
||||
|
||||
public class VideoService : IVideoService
|
||||
@@ -21,12 +21,14 @@ public class VideoService : IVideoService
|
||||
private readonly ILogger<VideoService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ISessionService _sessionService;
|
||||
|
||||
public VideoService(ILogger<VideoService> logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory)
|
||||
public VideoService(ILogger<VideoService> logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory, ISessionService sessionService)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_environment = environment;
|
||||
_sessionService = sessionService;
|
||||
}
|
||||
|
||||
public async Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url)
|
||||
@@ -90,7 +92,7 @@ public class VideoService : IVideoService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> ProcessVideoWithProgressAsync(string url, string format, string jobId, Action<int, string> progressCallback)
|
||||
public async Task<string?> ProcessVideoWithProgressAsync(string url, string format, string jobId, string sessionId, Action<int, string> progressCallback)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -101,8 +103,8 @@ public class VideoService : IVideoService
|
||||
throw new FileNotFoundException($"yt-dlp.exe not found at {ytDlpPath}");
|
||||
}
|
||||
|
||||
// Create downloads directory if it doesn't exist
|
||||
var downloadDir = Path.Combine(_environment.WebRootPath, "downloads");
|
||||
// Get session-specific download directory
|
||||
var downloadDir = _sessionService.GetSessionDownloadsPath(sessionId);
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
|
||||
// Get video details first for better filename
|
||||
@@ -113,7 +115,7 @@ public class VideoService : IVideoService
|
||||
var customFileName = $"{jobId}_{safeTitle}";
|
||||
var outputTemplate = Path.Combine(downloadDir, $"{customFileName}.%(ext)s");
|
||||
|
||||
_logger.LogInformation("Processing video with filename pattern: {FileName}", customFileName);
|
||||
_logger.LogInformation("Processing video with filename pattern: {FileName} in session: {SessionId}", customFileName, sessionId);
|
||||
|
||||
// Build yt-dlp arguments
|
||||
var arguments = new StringBuilder();
|
||||
@@ -188,7 +190,7 @@ public class VideoService : IVideoService
|
||||
throw new Exception($"yt-dlp failed with exit code {process.ExitCode}. Error: {error}");
|
||||
}
|
||||
|
||||
// Find the downloaded file with our job ID pattern
|
||||
// Find the downloaded file with our job ID pattern in the session directory
|
||||
var downloadedFiles = Directory.GetFiles(downloadDir, $"{jobId}_*.*")
|
||||
.Where(f => !f.EndsWith(".part"))
|
||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||
@@ -196,13 +198,13 @@ public class VideoService : IVideoService
|
||||
|
||||
if (downloadedFiles.Length == 0)
|
||||
{
|
||||
throw new Exception($"No downloaded file found for job {jobId} after processing");
|
||||
throw new Exception($"No downloaded file found for job {jobId} in session {sessionId} after processing");
|
||||
}
|
||||
|
||||
var downloadedFile = downloadedFiles[0];
|
||||
var finalFileName = Path.GetFileName(downloadedFile);
|
||||
|
||||
_logger.LogInformation("Successfully processed video. Job: {JobId}, File: {FileName}", jobId, finalFileName);
|
||||
_logger.LogInformation("Successfully processed video. Job: {JobId}, Session: {SessionId}, File: {FileName}", jobId, sessionId, finalFileName);
|
||||
|
||||
progressCallback(100, "Processing completed successfully");
|
||||
|
||||
@@ -210,7 +212,7 @@ public class VideoService : IVideoService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing video with yt-dlp for job {JobId}: {Url}", jobId, url);
|
||||
_logger.LogError(ex, "Error processing video with yt-dlp for job {JobId} in session {SessionId}: {Url}", jobId, sessionId, url);
|
||||
progressCallback(0, $"Processing failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ public class ProcessVideoRequest
|
||||
public string? Url { get; set; }
|
||||
public string? Format { get; set; } = "mp3";
|
||||
public string? Quality { get; set; } = "128";
|
||||
public string? SessionId { get; set; } // Add session ID support
|
||||
}
|
||||
|
||||
public class ProcessVideoResponse
|
||||
@@ -14,6 +15,7 @@ public class ProcessVideoResponse
|
||||
public string? FileName { get; set; }
|
||||
public string? DownloadUrl { get; set; }
|
||||
public YouTubeVideoDetails? VideoDetails { get; set; }
|
||||
public string? SessionId { get; set; } // Include session ID in response
|
||||
}
|
||||
|
||||
public class DownloadProgress
|
||||
|
||||
@@ -42,6 +42,9 @@ public static class ServiceCollectionExtensions
|
||||
// Register API service
|
||||
services.AddScoped<ITuneRipperApiService, TuneRipperApiService>();
|
||||
|
||||
// Register client session service
|
||||
services.AddScoped<IClientSessionService, ClientSessionService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+54
-10
@@ -1,7 +1,10 @@
|
||||
@page "/"
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using TuneRipper.Shared.Models
|
||||
@using TuneRipper.Services
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ITuneRipperApiService ApiService
|
||||
@inject IClientSessionService SessionService
|
||||
@inject ILogger<Home> Logger
|
||||
|
||||
<PageTitle>TuneRipper - YouTube to MP3 Downloader</PageTitle>
|
||||
@@ -25,6 +28,14 @@
|
||||
<img src="images/logo.png" alt="TuneRipper Logo" class="logo" />
|
||||
<h1 class="app-title">TuneRipper</h1>
|
||||
<p class="app-subtitle">Convert YouTube videos to MP3 instantly</p>
|
||||
|
||||
@* Session info display *@
|
||||
@if (!string.IsNullOrWhiteSpace(currentSessionId))
|
||||
{
|
||||
<div class="session-info">
|
||||
<small>Session: @currentSessionId.Substring(0, 8)...</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="download-form">
|
||||
@@ -161,6 +172,7 @@
|
||||
private string progressMessage = "";
|
||||
private List<DownloadItem> downloadLinks = new();
|
||||
private YouTubeVideoDetails? currentVideoDetails = null;
|
||||
private string currentSessionId = "";
|
||||
|
||||
public class DownloadItem
|
||||
{
|
||||
@@ -170,6 +182,22 @@
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Initialize session on component load
|
||||
currentSessionId = await SessionService.GetCurrentSessionIdAsync();
|
||||
Logger.LogInformation("Initialized with session: {SessionId}", currentSessionId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error initializing session");
|
||||
// Don't block the UI if session initialization fails
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadMp3()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(youtubeUrl))
|
||||
@@ -182,6 +210,12 @@
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure we have a session
|
||||
if (string.IsNullOrWhiteSpace(currentSessionId))
|
||||
{
|
||||
currentSessionId = await SessionService.GetCurrentSessionIdAsync();
|
||||
}
|
||||
|
||||
// Step 1: Start processing (which includes video details fetching)
|
||||
progressMessage = "Starting video processing...";
|
||||
progressPercentage = 5;
|
||||
@@ -191,7 +225,8 @@
|
||||
{
|
||||
Url = youtubeUrl,
|
||||
Format = "mp3",
|
||||
Quality = "128"
|
||||
Quality = "128",
|
||||
SessionId = currentSessionId // Include session ID in request
|
||||
};
|
||||
|
||||
var processingResponse = await ApiService.StartProcessingAsync(request);
|
||||
@@ -199,9 +234,18 @@
|
||||
if (processingResponse?.Success == true)
|
||||
{
|
||||
var jobId = processingResponse.FileName; // This contains the job ID
|
||||
var sessionId = processingResponse.SessionId ?? currentSessionId;
|
||||
|
||||
// Update current session ID if server returned a different one
|
||||
if (!string.IsNullOrWhiteSpace(processingResponse.SessionId) &&
|
||||
processingResponse.SessionId != currentSessionId)
|
||||
{
|
||||
currentSessionId = processingResponse.SessionId;
|
||||
await SessionService.SetSessionIdAsync(currentSessionId);
|
||||
}
|
||||
|
||||
// Step 2: Monitor processing status and get video details
|
||||
await MonitorProcessingAndShowDetails(jobId);
|
||||
await MonitorProcessingAndShowDetails(jobId, sessionId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -221,7 +265,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MonitorProcessingAndShowDetails(string jobId)
|
||||
private async Task MonitorProcessingAndShowDetails(string jobId, string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -256,7 +300,7 @@
|
||||
var result = await ApiService.GetProcessingResultAsync(jobId);
|
||||
if (result?.Success == true)
|
||||
{
|
||||
await CompleteProcessing(result, jobId);
|
||||
await CompleteProcessing(result, jobId, sessionId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -295,7 +339,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompleteProcessing(ProcessVideoResponse result, string jobId)
|
||||
private async Task CompleteProcessing(ProcessVideoResponse result, string jobId, string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -308,17 +352,17 @@
|
||||
// Use the actual video title from API
|
||||
string title = currentVideoDetails?.Title ?? result.VideoDetails?.Title ?? ExtractTitleFromUrl(youtubeUrl);
|
||||
|
||||
// Create the download URL - either from the result or construct it from the job ID
|
||||
// Create the session-aware download URL
|
||||
string downloadUrl;
|
||||
if (!string.IsNullOrEmpty(result.DownloadUrl))
|
||||
if (!string.IsNullOrEmpty(result.DownloadUrl) && result.DownloadUrl.Contains(sessionId))
|
||||
{
|
||||
// If the result contains a direct download URL, use it
|
||||
// Server provided a session-aware download URL
|
||||
downloadUrl = result.DownloadUrl.StartsWith("http") ? result.DownloadUrl : $"https://localhost:7160{result.DownloadUrl}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: construct download URL from job ID
|
||||
downloadUrl = $"https://localhost:7160/api/download/{jobId}";
|
||||
// Construct session-aware download URL
|
||||
downloadUrl = $"https://localhost:7160/api/download/{sessionId}/{jobId}";
|
||||
}
|
||||
|
||||
downloadLinks.Add(new DownloadItem
|
||||
|
||||
@@ -40,6 +40,25 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Session info styling */
|
||||
.session-info {
|
||||
background: #f7fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
margin-top: 15px;
|
||||
display: inline-block;
|
||||
color: #4a5568;
|
||||
font-size: 0.85rem;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.session-info:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.download-form {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
@@ -457,6 +476,11 @@
|
||||
.feature {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace TuneRipper.Services;
|
||||
|
||||
public interface IClientSessionService
|
||||
{
|
||||
Task<string> GetCurrentSessionIdAsync();
|
||||
Task SetSessionIdAsync(string sessionId);
|
||||
Task ClearSessionAsync();
|
||||
}
|
||||
|
||||
public class ClientSessionService : IClientSessionService
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly ITuneRipperApiService _apiService;
|
||||
private readonly ILogger<ClientSessionService> _logger;
|
||||
private const string SESSION_KEY = "tuneRipper.sessionId";
|
||||
|
||||
public ClientSessionService(IJSRuntime jsRuntime, ITuneRipperApiService apiService, ILogger<ClientSessionService> logger)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
_apiService = apiService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> GetCurrentSessionIdAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to get existing session from localStorage
|
||||
var existingSessionId = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", SESSION_KEY);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(existingSessionId))
|
||||
{
|
||||
// Validate with server
|
||||
var isValid = await _apiService.ValidateSessionAsync(existingSessionId);
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
_logger.LogInformation("Using existing valid session: {SessionId}", existingSessionId);
|
||||
return existingSessionId;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Existing session is invalid, creating new one");
|
||||
await ClearSessionAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session
|
||||
var newSessionId = await _apiService.CreateSessionAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(newSessionId))
|
||||
{
|
||||
await SetSessionIdAsync(newSessionId);
|
||||
_logger.LogInformation("Created and stored new session: {SessionId}", newSessionId);
|
||||
return newSessionId;
|
||||
}
|
||||
|
||||
_logger.LogError("Failed to create session");
|
||||
throw new InvalidOperationException("Failed to create session");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting current session ID");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetSessionIdAsync(string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
throw new ArgumentException("Session ID cannot be null or empty", nameof(sessionId));
|
||||
}
|
||||
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", SESSION_KEY, sessionId);
|
||||
_logger.LogDebug("Session ID stored: {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error setting session ID: {SessionId}", sessionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearSessionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", SESSION_KEY);
|
||||
_logger.LogDebug("Session cleared from localStorage");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing session");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,13 @@ public interface ITuneRipperApiService
|
||||
Task<DownloadProgress?> GetProcessingStatusAsync(string jobId);
|
||||
Task<ProcessVideoResponse?> GetProcessingResultAsync(string jobId);
|
||||
Task<IEnumerable<JobStatusResponse>?> GetAllJobsAsync();
|
||||
Task<IEnumerable<JobStatusResponse>?> GetJobsBySessionAsync(string sessionId);
|
||||
Task<byte[]?> DownloadFileAsync(string jobId);
|
||||
Task<byte[]?> DownloadFileBySessionAsync(string sessionId, string jobId);
|
||||
Task<bool> IsApiHealthyAsync();
|
||||
Task<string?> CreateSessionAsync();
|
||||
Task<bool> ValidateSessionAsync(string sessionId);
|
||||
Task<bool> RefreshSessionAsync(string sessionId);
|
||||
}
|
||||
|
||||
public class TuneRipperApiService : ITuneRipperApiService
|
||||
@@ -50,6 +55,97 @@ public class TuneRipperApiService : ITuneRipperApiService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> CreateSessionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Creating new session");
|
||||
|
||||
var response = await _httpClient.PostAsync("api/session/create", null);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var sessionInfo = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
|
||||
|
||||
if (sessionInfo.TryGetProperty("sessionId", out var sessionIdProperty))
|
||||
{
|
||||
var sessionId = sessionIdProperty.GetString();
|
||||
_logger.LogInformation("Successfully created session: {SessionId}", sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Failed to create session. Status: {StatusCode}, Error: {Error}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error while creating session");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateSessionAsync(string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync($"api/session/validate/{sessionId}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var sessionInfo = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
|
||||
|
||||
if (sessionInfo.TryGetProperty("isValid", out var isValidProperty))
|
||||
{
|
||||
return isValidProperty.GetBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating session: {SessionId}", sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshSessionAsync(string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = await _httpClient.PostAsync($"api/session/refresh/{sessionId}", null);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Successfully refreshed session: {SessionId}", sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing session: {SessionId}", sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ProcessVideoResponse?> StartProcessingAsync(ProcessVideoRequest request)
|
||||
{
|
||||
try
|
||||
@@ -60,15 +156,15 @@ public class TuneRipperApiService : ITuneRipperApiService
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting video processing for URL: {Url}", request.Url);
|
||||
_logger.LogInformation("Starting video processing for URL: {Url} with session: {SessionId}", request.Url, request.SessionId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("api/video/process", request, _jsonOptions);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<ProcessVideoResponse>(_jsonOptions);
|
||||
_logger.LogInformation("Successfully started processing for URL: {Url}, JobId: {JobId}",
|
||||
request.Url, result?.FileName);
|
||||
_logger.LogInformation("Successfully started processing for URL: {Url}, JobId: {JobId}, SessionId: {SessionId}",
|
||||
request.Url, result?.FileName, result?.SessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -99,6 +195,74 @@ public class TuneRipperApiService : ITuneRipperApiService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<JobStatusResponse>?> GetJobsBySessionAsync(string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return await GetAllJobsAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetching jobs for session: {SessionId}", sessionId);
|
||||
|
||||
var response = await _httpClient.GetAsync($"api/video/jobs/{sessionId}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jobs = await response.Content.ReadFromJsonAsync<IEnumerable<JobStatusResponse>>(_jsonOptions);
|
||||
_logger.LogInformation("Successfully retrieved {Count} jobs for session: {SessionId}", jobs?.Count() ?? 0, sessionId);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Failed to get jobs for session. Status: {StatusCode}, Error: {Error}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error while getting jobs for session: {SessionId}", sessionId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]?> DownloadFileBySessionAsync(string sessionId, string jobId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
_logger.LogWarning("DownloadFileBySessionAsync called with empty sessionId or jobId");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Downloading file for job: {JobId} in session: {SessionId}", jobId, sessionId);
|
||||
|
||||
var response = await _httpClient.GetAsync($"api/download/{sessionId}/{jobId}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
_logger.LogInformation("Successfully downloaded file for job: {JobId} in session: {SessionId}, Size: {Size} bytes",
|
||||
jobId, sessionId, fileBytes.Length);
|
||||
return fileBytes;
|
||||
}
|
||||
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Failed to download file. Status: {StatusCode}, Error: {Error}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error while downloading file for job: {JobId} in session: {SessionId}", jobId, sessionId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DownloadProgress?> GetProcessingStatusAsync(string jobId)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user