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 Microsoft.AspNetCore.Mvc;
|
||||||
|
using TuneRipper.Api.Services;
|
||||||
|
|
||||||
namespace TuneRipper.Api.Controllers;
|
namespace TuneRipper.Api.Controllers;
|
||||||
|
|
||||||
@@ -8,40 +9,48 @@ public class DownloadController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IWebHostEnvironment _environment;
|
private readonly IWebHostEnvironment _environment;
|
||||||
private readonly ILogger<DownloadController> _logger;
|
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;
|
_environment = environment;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_sessionService = sessionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{jobId}")]
|
// New session-aware download endpoint
|
||||||
public IActionResult DownloadFile(string jobId)
|
[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
|
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);
|
_logger.LogWarning("Session downloads directory not found: {Path}", sessionDownloadsDir);
|
||||||
return NotFound("Downloads directory not found");
|
return NotFound("Session downloads directory not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find files with the specific job ID pattern: jobId_title.ext
|
// Find files with the specific job ID pattern in the session directory
|
||||||
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
var jobFiles = Directory.GetFiles(sessionDownloadsDir, $"{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)
|
||||||
{
|
{
|
||||||
_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");
|
return NotFound("File not found for the specified job ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +63,7 @@ public class DownloadController : ControllerBase
|
|||||||
return NotFound("File not found");
|
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
|
// Determine content type based on file extension
|
||||||
var contentType = Path.GetExtension(fileName).ToLowerInvariant() switch
|
var contentType = Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||||
@@ -74,12 +83,99 @@ public class DownloadController : ControllerBase
|
|||||||
return File(fileStream, contentType, downloadFileName);
|
return File(fileStream, contentType, downloadFileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
_logger.LogError(ex, "Error downloading file for job ID: {JobId}", jobId);
|
||||||
return StatusCode(500, $"Error downloading file: {ex.Message}");
|
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)
|
private string CreateUserFriendlyFileName(string originalFileName)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -118,25 +214,30 @@ public class DownloadController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("info/{jobId}")]
|
[HttpGet("info/{sessionId}/{jobId}")]
|
||||||
public IActionResult GetDownloadInfo(string 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
|
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
|
// Find files with the specific job ID pattern in the session directory
|
||||||
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
var jobFiles = Directory.GetFiles(sessionDownloadsDir, $"{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();
|
||||||
@@ -155,6 +256,7 @@ public class DownloadController : ControllerBase
|
|||||||
var info = new
|
var info = new
|
||||||
{
|
{
|
||||||
jobId,
|
jobId,
|
||||||
|
sessionId,
|
||||||
fileName,
|
fileName,
|
||||||
friendlyFileName,
|
friendlyFileName,
|
||||||
fileSize = FormatFileSize(fileInfo.Length),
|
fileSize = FormatFileSize(fileInfo.Length),
|
||||||
@@ -162,20 +264,21 @@ public class DownloadController : ControllerBase
|
|||||||
format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
|
format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
|
||||||
createdAt = fileInfo.CreationTime,
|
createdAt = fileInfo.CreationTime,
|
||||||
lastModified = fileInfo.LastWriteTime,
|
lastModified = fileInfo.LastWriteTime,
|
||||||
downloadUrl = $"/api/download/{jobId}"
|
downloadUrl = $"/api/download/{sessionId}/{jobId}"
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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}");
|
return StatusCode(500, $"Error getting download info: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{jobId}")]
|
// Legacy info endpoint for backward compatibility
|
||||||
public IActionResult DeleteFile(string jobId)
|
[HttpGet("info/{jobId}")]
|
||||||
|
public IActionResult GetDownloadInfo(string jobId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(jobId))
|
if (string.IsNullOrWhiteSpace(jobId))
|
||||||
{
|
{
|
||||||
@@ -191,8 +294,96 @@ public class DownloadController : ControllerBase
|
|||||||
return NotFound("Downloads directory not found");
|
return NotFound("Downloads directory not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and delete files with the specific job ID pattern
|
// Check legacy location first
|
||||||
var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*")
|
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();
|
.ToArray();
|
||||||
|
|
||||||
var deletedCount = 0;
|
var deletedCount = 0;
|
||||||
@@ -203,7 +394,7 @@ public class DownloadController : ControllerBase
|
|||||||
{
|
{
|
||||||
System.IO.File.Delete(filePath);
|
System.IO.File.Delete(filePath);
|
||||||
deletedCount++;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -216,6 +407,78 @@ public class DownloadController : ControllerBase
|
|||||||
return NotFound("No files found to delete for the specified job ID");
|
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 });
|
return Ok(new { message = $"Deleted {deletedCount} file(s) for job {jobId}", deletedCount, jobId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
public class VideoController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IVideoService _videoService;
|
private readonly IVideoService _videoService;
|
||||||
|
private readonly ISessionService _sessionService;
|
||||||
private readonly ILogger<VideoController> _logger;
|
private readonly ILogger<VideoController> _logger;
|
||||||
|
|
||||||
// In-memory storage for processing status (in production, use database or Redis)
|
// In-memory storage for processing status (in production, use database or Redis)
|
||||||
@@ -18,6 +19,7 @@ public class VideoController : ControllerBase
|
|||||||
private class ProcessingJob
|
private class ProcessingJob
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = string.Empty;
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string SessionId { get; set; } = string.Empty; // Add session tracking
|
||||||
public ProcessVideoRequest Request { get; set; } = new();
|
public ProcessVideoRequest Request { get; set; } = new();
|
||||||
public DownloadProgress Progress { get; set; } = new();
|
public DownloadProgress Progress { get; set; } = new();
|
||||||
public ProcessVideoResponse? Result { get; set; }
|
public ProcessVideoResponse? Result { get; set; }
|
||||||
@@ -26,9 +28,10 @@ public class VideoController : ControllerBase
|
|||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
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;
|
_videoService = videoService;
|
||||||
|
_sessionService = sessionService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +52,9 @@ public class VideoController : ControllerBase
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Starting video processing for URL: {Url}", request.Url);
|
_logger.LogInformation("Starting video processing for URL: {Url}", request.Url);
|
||||||
|
|
||||||
|
// Get or create session
|
||||||
|
var sessionId = _sessionService.GetOrCreateSession(request.SessionId);
|
||||||
|
|
||||||
// Generate a job ID
|
// Generate a job ID
|
||||||
var jobId = Guid.NewGuid().ToString();
|
var jobId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
@@ -56,6 +62,7 @@ public class VideoController : ControllerBase
|
|||||||
var job = new ProcessingJob
|
var job = new ProcessingJob
|
||||||
{
|
{
|
||||||
Id = jobId,
|
Id = jobId,
|
||||||
|
SessionId = sessionId,
|
||||||
Request = request,
|
Request = request,
|
||||||
Progress = new DownloadProgress
|
Progress = new DownloadProgress
|
||||||
{
|
{
|
||||||
@@ -76,7 +83,8 @@ public class VideoController : ControllerBase
|
|||||||
Message = "Processing started",
|
Message = "Processing started",
|
||||||
FileName = jobId, // Use job ID as temporary filename
|
FileName = jobId, // Use job ID as temporary filename
|
||||||
DownloadUrl = null, // Will be available when processing completes
|
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);
|
return Accepted(response);
|
||||||
@@ -140,10 +148,18 @@ public class VideoController : ControllerBase
|
|||||||
return Ok(job.Result);
|
return Ok(job.Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("jobs")]
|
[HttpGet("jobs/{sessionId?}")]
|
||||||
public ActionResult<IEnumerable<JobStatusResponse>> GetAllJobs()
|
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,
|
JobId = job.Id,
|
||||||
Status = job.Progress.Status,
|
Status = job.Progress.Status,
|
||||||
@@ -157,6 +173,13 @@ public class VideoController : ControllerBase
|
|||||||
return Ok(jobs);
|
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)
|
private async Task ProcessVideoAsync(string jobId)
|
||||||
{
|
{
|
||||||
if (!_processingJobs.TryGetValue(jobId, out var job))
|
if (!_processingJobs.TryGetValue(jobId, out var job))
|
||||||
@@ -164,7 +187,7 @@ public class VideoController : ControllerBase
|
|||||||
|
|
||||||
try
|
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
|
// Step 1: Fetch video details
|
||||||
job.Progress.Status = "Processing";
|
job.Progress.Status = "Processing";
|
||||||
@@ -195,6 +218,7 @@ public class VideoController : ControllerBase
|
|||||||
job.Request.Url!,
|
job.Request.Url!,
|
||||||
job.Request.Format ?? "mp3",
|
job.Request.Format ?? "mp3",
|
||||||
jobId, // Pass the job ID for filename generation
|
jobId, // Pass the job ID for filename generation
|
||||||
|
job.SessionId, // Pass the session ID for directory organization
|
||||||
(percentage, message) =>
|
(percentage, message) =>
|
||||||
{
|
{
|
||||||
// Update progress from yt-dlp
|
// Update progress from yt-dlp
|
||||||
@@ -217,8 +241,8 @@ public class VideoController : ControllerBase
|
|||||||
job.Progress.Message = "Processing completed successfully";
|
job.Progress.Message = "Processing completed successfully";
|
||||||
job.Progress.PercentageComplete = 100;
|
job.Progress.PercentageComplete = 100;
|
||||||
|
|
||||||
// Create download URL for the actual file
|
// Create download URL for the actual file within session
|
||||||
var downloadUrl = $"/downloads/{Uri.EscapeDataString(downloadedFileName)}";
|
var downloadUrl = $"/api/download/{job.SessionId}/{jobId}";
|
||||||
|
|
||||||
job.Result = new ProcessVideoResponse
|
job.Result = new ProcessVideoResponse
|
||||||
{
|
{
|
||||||
@@ -226,14 +250,15 @@ public class VideoController : ControllerBase
|
|||||||
Message = "Video processed successfully",
|
Message = "Video processed successfully",
|
||||||
FileName = downloadedFileName,
|
FileName = downloadedFileName,
|
||||||
DownloadUrl = downloadUrl,
|
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)
|
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.Status = "Failed";
|
||||||
job.Progress.Message = $"Processing failed: {ex.Message}";
|
job.Progress.Message = $"Processing failed: {ex.Message}";
|
||||||
@@ -245,7 +270,8 @@ public class VideoController : ControllerBase
|
|||||||
Message = ex.Message,
|
Message = ex.Message,
|
||||||
FileName = null,
|
FileName = null,
|
||||||
DownloadUrl = 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
|
// Register services
|
||||||
builder.Services.AddScoped<IVideoService, VideoService>();
|
builder.Services.AddScoped<IVideoService, VideoService>();
|
||||||
|
builder.Services.AddSingleton<ISessionService, SessionService>(); // Register session service as singleton
|
||||||
|
|
||||||
// Add CORS for Blazor WebAssembly client
|
// Add CORS for Blazor WebAssembly client
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
@@ -64,6 +65,7 @@ app.MapGet("/", (IWebHostEnvironment env) => new
|
|||||||
check_status = "/api/video/status/{jobId}",
|
check_status = "/api/video/status/{jobId}",
|
||||||
get_result = "/api/video/result/{jobId}",
|
get_result = "/api/video/result/{jobId}",
|
||||||
download = "/api/download/{jobId}",
|
download = "/api/download/{jobId}",
|
||||||
|
download_by_session = "/api/download/{sessionId}/{jobId}",
|
||||||
static_files = "/downloads/{fileName}",
|
static_files = "/downloads/{fileName}",
|
||||||
openapi = "/openapi/v1.json"
|
openapi = "/openapi/v1.json"
|
||||||
},
|
},
|
||||||
@@ -80,10 +82,11 @@ app.MapControllers();
|
|||||||
app.Logger.LogInformation("?? TuneRipper API is starting...");
|
app.Logger.LogInformation("?? TuneRipper API is starting...");
|
||||||
app.Logger.LogInformation("?? Environment: {Environment}", app.Environment.EnvironmentName);
|
app.Logger.LogInformation("?? Environment: {Environment}", app.Environment.EnvironmentName);
|
||||||
app.Logger.LogInformation("?? API will be available at:");
|
app.Logger.LogInformation("?? API will be available at:");
|
||||||
app.Logger.LogInformation(" • HTTPS: https://localhost:7160");
|
app.Logger.LogInformation(" ?? HTTPS: https://localhost:7160");
|
||||||
app.Logger.LogInformation(" • HTTP: http://localhost:5112");
|
app.Logger.LogInformation(" ?? HTTP: http://localhost:5112");
|
||||||
app.Logger.LogInformation("?? Health check: /api/health");
|
app.Logger.LogInformation("?? Health check: /api/health");
|
||||||
app.Logger.LogInformation("?? API info: /");
|
app.Logger.LogInformation("?? API info: /");
|
||||||
app.Logger.LogInformation("?? Static files served from: /downloads/*");
|
app.Logger.LogInformation("?? Static files served from: /downloads/*");
|
||||||
|
app.Logger.LogInformation("?? Session-based downloads supported");
|
||||||
|
|
||||||
app.Run();
|
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);
|
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, 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
|
public class VideoService : IVideoService
|
||||||
@@ -21,12 +21,14 @@ public class VideoService : IVideoService
|
|||||||
private readonly ILogger<VideoService> _logger;
|
private readonly ILogger<VideoService> _logger;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly IWebHostEnvironment _environment;
|
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;
|
_logger = logger;
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
|
_sessionService = sessionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url)
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -101,8 +103,8 @@ public class VideoService : IVideoService
|
|||||||
throw new FileNotFoundException($"yt-dlp.exe not found at {ytDlpPath}");
|
throw new FileNotFoundException($"yt-dlp.exe not found at {ytDlpPath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create downloads directory if it doesn't exist
|
// Get session-specific download directory
|
||||||
var downloadDir = Path.Combine(_environment.WebRootPath, "downloads");
|
var downloadDir = _sessionService.GetSessionDownloadsPath(sessionId);
|
||||||
Directory.CreateDirectory(downloadDir);
|
Directory.CreateDirectory(downloadDir);
|
||||||
|
|
||||||
// Get video details first for better filename
|
// Get video details first for better filename
|
||||||
@@ -113,7 +115,7 @@ public class VideoService : IVideoService
|
|||||||
var customFileName = $"{jobId}_{safeTitle}";
|
var customFileName = $"{jobId}_{safeTitle}";
|
||||||
var outputTemplate = Path.Combine(downloadDir, $"{customFileName}.%(ext)s");
|
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
|
// Build yt-dlp arguments
|
||||||
var arguments = new StringBuilder();
|
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}");
|
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}_*.*")
|
var downloadedFiles = Directory.GetFiles(downloadDir, $"{jobId}_*.*")
|
||||||
.Where(f => !f.EndsWith(".part"))
|
.Where(f => !f.EndsWith(".part"))
|
||||||
.OrderByDescending(System.IO.File.GetCreationTime)
|
.OrderByDescending(System.IO.File.GetCreationTime)
|
||||||
@@ -196,13 +198,13 @@ public class VideoService : IVideoService
|
|||||||
|
|
||||||
if (downloadedFiles.Length == 0)
|
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 downloadedFile = downloadedFiles[0];
|
||||||
var finalFileName = Path.GetFileName(downloadedFile);
|
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");
|
progressCallback(100, "Processing completed successfully");
|
||||||
|
|
||||||
@@ -210,7 +212,7 @@ public class VideoService : IVideoService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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}");
|
progressCallback(0, $"Processing failed: {ex.Message}");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public class ProcessVideoRequest
|
|||||||
public string? Url { get; set; }
|
public string? Url { get; set; }
|
||||||
public string? Format { get; set; } = "mp3";
|
public string? Format { get; set; } = "mp3";
|
||||||
public string? Quality { get; set; } = "128";
|
public string? Quality { get; set; } = "128";
|
||||||
|
public string? SessionId { get; set; } // Add session ID support
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ProcessVideoResponse
|
public class ProcessVideoResponse
|
||||||
@@ -14,6 +15,7 @@ public class ProcessVideoResponse
|
|||||||
public string? FileName { get; set; }
|
public string? FileName { get; set; }
|
||||||
public string? DownloadUrl { get; set; }
|
public string? DownloadUrl { get; set; }
|
||||||
public YouTubeVideoDetails? VideoDetails { get; set; }
|
public YouTubeVideoDetails? VideoDetails { get; set; }
|
||||||
|
public string? SessionId { get; set; } // Include session ID in response
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DownloadProgress
|
public class DownloadProgress
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ public static class ServiceCollectionExtensions
|
|||||||
// Register API service
|
// Register API service
|
||||||
services.AddScoped<ITuneRipperApiService, TuneRipperApiService>();
|
services.AddScoped<ITuneRipperApiService, TuneRipperApiService>();
|
||||||
|
|
||||||
|
// Register client session service
|
||||||
|
services.AddScoped<IClientSessionService, ClientSessionService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+54
-10
@@ -1,7 +1,10 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using TuneRipper.Shared.Models
|
||||||
|
@using TuneRipper.Services
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject ITuneRipperApiService ApiService
|
@inject ITuneRipperApiService ApiService
|
||||||
|
@inject IClientSessionService SessionService
|
||||||
@inject ILogger<Home> Logger
|
@inject ILogger<Home> Logger
|
||||||
|
|
||||||
<PageTitle>TuneRipper - YouTube to MP3 Downloader</PageTitle>
|
<PageTitle>TuneRipper - YouTube to MP3 Downloader</PageTitle>
|
||||||
@@ -25,6 +28,14 @@
|
|||||||
<img src="images/logo.png" alt="TuneRipper Logo" class="logo" />
|
<img src="images/logo.png" alt="TuneRipper Logo" class="logo" />
|
||||||
<h1 class="app-title">TuneRipper</h1>
|
<h1 class="app-title">TuneRipper</h1>
|
||||||
<p class="app-subtitle">Convert YouTube videos to MP3 instantly</p>
|
<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>
|
||||||
|
|
||||||
<div class="download-form">
|
<div class="download-form">
|
||||||
@@ -161,6 +172,7 @@
|
|||||||
private string progressMessage = "";
|
private string progressMessage = "";
|
||||||
private List<DownloadItem> downloadLinks = new();
|
private List<DownloadItem> downloadLinks = new();
|
||||||
private YouTubeVideoDetails? currentVideoDetails = null;
|
private YouTubeVideoDetails? currentVideoDetails = null;
|
||||||
|
private string currentSessionId = "";
|
||||||
|
|
||||||
public class DownloadItem
|
public class DownloadItem
|
||||||
{
|
{
|
||||||
@@ -170,6 +182,22 @@
|
|||||||
public DateTime CreatedAt { get; set; }
|
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()
|
private async Task DownloadMp3()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(youtubeUrl))
|
if (!string.IsNullOrWhiteSpace(youtubeUrl))
|
||||||
@@ -182,6 +210,12 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Ensure we have a session
|
||||||
|
if (string.IsNullOrWhiteSpace(currentSessionId))
|
||||||
|
{
|
||||||
|
currentSessionId = await SessionService.GetCurrentSessionIdAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Start processing (which includes video details fetching)
|
// Step 1: Start processing (which includes video details fetching)
|
||||||
progressMessage = "Starting video processing...";
|
progressMessage = "Starting video processing...";
|
||||||
progressPercentage = 5;
|
progressPercentage = 5;
|
||||||
@@ -191,7 +225,8 @@
|
|||||||
{
|
{
|
||||||
Url = youtubeUrl,
|
Url = youtubeUrl,
|
||||||
Format = "mp3",
|
Format = "mp3",
|
||||||
Quality = "128"
|
Quality = "128",
|
||||||
|
SessionId = currentSessionId // Include session ID in request
|
||||||
};
|
};
|
||||||
|
|
||||||
var processingResponse = await ApiService.StartProcessingAsync(request);
|
var processingResponse = await ApiService.StartProcessingAsync(request);
|
||||||
@@ -199,9 +234,18 @@
|
|||||||
if (processingResponse?.Success == true)
|
if (processingResponse?.Success == true)
|
||||||
{
|
{
|
||||||
var jobId = processingResponse.FileName; // This contains the job ID
|
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
|
// Step 2: Monitor processing status and get video details
|
||||||
await MonitorProcessingAndShowDetails(jobId);
|
await MonitorProcessingAndShowDetails(jobId, sessionId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -221,7 +265,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MonitorProcessingAndShowDetails(string jobId)
|
private async Task MonitorProcessingAndShowDetails(string jobId, string sessionId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -256,7 +300,7 @@
|
|||||||
var result = await ApiService.GetProcessingResultAsync(jobId);
|
var result = await ApiService.GetProcessingResultAsync(jobId);
|
||||||
if (result?.Success == true)
|
if (result?.Success == true)
|
||||||
{
|
{
|
||||||
await CompleteProcessing(result, jobId);
|
await CompleteProcessing(result, jobId, sessionId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -295,7 +339,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CompleteProcessing(ProcessVideoResponse result, string jobId)
|
private async Task CompleteProcessing(ProcessVideoResponse result, string jobId, string sessionId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -308,17 +352,17 @@
|
|||||||
// Use the actual video title from API
|
// Use the actual video title from API
|
||||||
string title = currentVideoDetails?.Title ?? result.VideoDetails?.Title ?? ExtractTitleFromUrl(youtubeUrl);
|
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;
|
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}";
|
downloadUrl = result.DownloadUrl.StartsWith("http") ? result.DownloadUrl : $"https://localhost:7160{result.DownloadUrl}";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fallback: construct download URL from job ID
|
// Construct session-aware download URL
|
||||||
downloadUrl = $"https://localhost:7160/api/download/{jobId}";
|
downloadUrl = $"https://localhost:7160/api/download/{sessionId}/{jobId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadLinks.Add(new DownloadItem
|
downloadLinks.Add(new DownloadItem
|
||||||
|
|||||||
@@ -40,6 +40,25 @@
|
|||||||
font-weight: 400;
|
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 {
|
.download-form {
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
@@ -457,6 +476,11 @@
|
|||||||
.feature {
|
.feature {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* 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<DownloadProgress?> GetProcessingStatusAsync(string jobId);
|
||||||
Task<ProcessVideoResponse?> GetProcessingResultAsync(string jobId);
|
Task<ProcessVideoResponse?> GetProcessingResultAsync(string jobId);
|
||||||
Task<IEnumerable<JobStatusResponse>?> GetAllJobsAsync();
|
Task<IEnumerable<JobStatusResponse>?> GetAllJobsAsync();
|
||||||
|
Task<IEnumerable<JobStatusResponse>?> GetJobsBySessionAsync(string sessionId);
|
||||||
Task<byte[]?> DownloadFileAsync(string jobId);
|
Task<byte[]?> DownloadFileAsync(string jobId);
|
||||||
|
Task<byte[]?> DownloadFileBySessionAsync(string sessionId, string jobId);
|
||||||
Task<bool> IsApiHealthyAsync();
|
Task<bool> IsApiHealthyAsync();
|
||||||
|
Task<string?> CreateSessionAsync();
|
||||||
|
Task<bool> ValidateSessionAsync(string sessionId);
|
||||||
|
Task<bool> RefreshSessionAsync(string sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TuneRipperApiService : ITuneRipperApiService
|
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)
|
public async Task<ProcessVideoResponse?> StartProcessingAsync(ProcessVideoRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -60,15 +156,15 @@ public class TuneRipperApiService : ITuneRipperApiService
|
|||||||
return null;
|
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);
|
var response = await _httpClient.PostAsJsonAsync("api/video/process", request, _jsonOptions);
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var result = await response.Content.ReadFromJsonAsync<ProcessVideoResponse>(_jsonOptions);
|
var result = await response.Content.ReadFromJsonAsync<ProcessVideoResponse>(_jsonOptions);
|
||||||
_logger.LogInformation("Successfully started processing for URL: {Url}, JobId: {JobId}",
|
_logger.LogInformation("Successfully started processing for URL: {Url}, JobId: {JobId}, SessionId: {SessionId}",
|
||||||
request.Url, result?.FileName);
|
request.Url, result?.FileName, result?.SessionId);
|
||||||
return result;
|
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)
|
public async Task<DownloadProgress?> GetProcessingStatusAsync(string jobId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in New Issue
Block a user