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:
Michal Vrba
2025-12-14 11:04:46 +01:00
parent 7c99721174
commit 2d2e855202
14 changed files with 1073 additions and 66 deletions
+161
View File
@@ -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
```
+292 -29
View 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)
});
}
}
+38 -12
View File
@@ -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
}; };
} }
} }
+5 -2
View File
@@ -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);
}
+124
View File
@@ -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();
}
}
+12 -10
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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 */
+102
View File
@@ -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;
}
}
}
+167 -3
View File
@@ -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