diff --git a/SESSION_MANAGEMENT.md b/SESSION_MANAGEMENT.md new file mode 100644 index 0000000..5aed590 --- /dev/null +++ b/SESSION_MANAGEMENT.md @@ -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 +``` \ No newline at end of file diff --git a/TuneRipper.Api/Controllers/DownloadController.cs b/TuneRipper.Api/Controllers/DownloadController.cs index e912688..f88e37b 100644 --- a/TuneRipper.Api/Controllers/DownloadController.cs +++ b/TuneRipper.Api/Controllers/DownloadController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using TuneRipper.Api.Services; namespace TuneRipper.Api.Controllers; @@ -8,40 +9,48 @@ public class DownloadController : ControllerBase { private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; + private readonly ISessionService _sessionService; - public DownloadController(IWebHostEnvironment environment, ILogger logger) + public DownloadController(IWebHostEnvironment environment, ILogger logger, ISessionService sessionService) { _environment = environment; _logger = logger; + _sessionService = sessionService; } - [HttpGet("{jobId}")] - public IActionResult DownloadFile(string jobId) + // New session-aware download endpoint + [HttpGet("{sessionId}/{jobId}")] + public IActionResult DownloadFileBySession(string sessionId, string jobId) { - if (string.IsNullOrWhiteSpace(jobId)) + if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(jobId)) { - return BadRequest("Job ID is required"); + return BadRequest("Session ID and Job ID are required"); + } + + if (!_sessionService.IsValidSession(sessionId)) + { + return NotFound("Invalid or expired session"); } try { - var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); + var sessionDownloadsDir = _sessionService.GetSessionDownloadsPath(sessionId); - if (!Directory.Exists(downloadsDir)) + if (!Directory.Exists(sessionDownloadsDir)) { - _logger.LogWarning("Downloads directory not found: {Path}", downloadsDir); - return NotFound("Downloads directory not found"); + _logger.LogWarning("Session downloads directory not found: {Path}", sessionDownloadsDir); + return NotFound("Session downloads directory not found"); } - // Find files with the specific job ID pattern: jobId_title.ext - var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*") + // Find files with the specific job ID pattern in the session directory + var jobFiles = Directory.GetFiles(sessionDownloadsDir, $"{jobId}_*.*") .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) .OrderByDescending(System.IO.File.GetCreationTime) .ToArray(); if (jobFiles.Length == 0) { - _logger.LogWarning("No files found for job ID: {JobId}", jobId); + _logger.LogWarning("No files found for job ID: {JobId} in session: {SessionId}", jobId, sessionId); return NotFound("File not found for the specified job ID"); } @@ -54,7 +63,7 @@ public class DownloadController : ControllerBase return NotFound("File not found"); } - _logger.LogInformation("Serving file: {FileName} for job: {JobId}", fileName, jobId); + _logger.LogInformation("Serving file: {FileName} for job: {JobId} in session: {SessionId}", fileName, jobId, sessionId); // Determine content type based on file extension var contentType = Path.GetExtension(fileName).ToLowerInvariant() switch @@ -74,12 +83,99 @@ public class DownloadController : ControllerBase return File(fileStream, contentType, downloadFileName); } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading file for job ID: {JobId} in session: {SessionId}", jobId, sessionId); + return StatusCode(500, $"Error downloading file: {ex.Message}"); + } + } + + // Legacy endpoint for backward compatibility + [HttpGet("{jobId}")] + public IActionResult DownloadFile(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + return BadRequest("Job ID is required"); + } + + try + { + // Check in the legacy downloads directory first + var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); + + if (Directory.Exists(downloadsDir)) + { + var legacyFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*") + .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) + .OrderByDescending(System.IO.File.GetCreationTime) + .ToArray(); + + if (legacyFiles.Length > 0) + { + return ServeFile(legacyFiles[0], jobId, null); + } + } + + // If not found in legacy location, search all session directories + var sessionDirs = Directory.GetDirectories(downloadsDir, "*", SearchOption.TopDirectoryOnly); + + foreach (var sessionDir in sessionDirs) + { + var sessionId = Path.GetFileName(sessionDir); + if (!_sessionService.IsValidSession(sessionId)) continue; + + var jobFiles = Directory.GetFiles(sessionDir, $"{jobId}_*.*") + .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) + .OrderByDescending(System.IO.File.GetCreationTime) + .ToArray(); + + if (jobFiles.Length > 0) + { + return ServeFile(jobFiles[0], jobId, sessionId); + } + } + + _logger.LogWarning("No files found for job ID: {JobId}", jobId); + return NotFound("File not found for the specified job ID"); + } + catch (Exception ex) { _logger.LogError(ex, "Error downloading file for job ID: {JobId}", jobId); return StatusCode(500, $"Error downloading file: {ex.Message}"); } } + private IActionResult ServeFile(string filePath, string jobId, string? sessionId) + { + var fileName = Path.GetFileName(filePath); + + if (!System.IO.File.Exists(filePath)) + { + _logger.LogWarning("File not found: {FilePath}", filePath); + return NotFound("File not found"); + } + + var sessionInfo = sessionId != null ? $" in session: {sessionId}" : ""; + _logger.LogInformation("Serving file: {FileName} for job: {JobId}{SessionInfo}", fileName, jobId, sessionInfo); + + // Determine content type based on file extension + var contentType = Path.GetExtension(fileName).ToLowerInvariant() switch + { + ".mp3" => "audio/mpeg", + ".m4a" => "audio/mp4", + ".webm" => "audio/webm", + ".wav" => "audio/wav", + ".ogg" => "audio/ogg", + _ => "application/octet-stream" + }; + + // Create a user-friendly download filename from our pattern + var downloadFileName = CreateUserFriendlyFileName(fileName); + + var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + return File(fileStream, contentType, downloadFileName); + } + private string CreateUserFriendlyFileName(string originalFileName) { try @@ -118,25 +214,30 @@ public class DownloadController : ControllerBase } } - [HttpGet("info/{jobId}")] - public IActionResult GetDownloadInfo(string jobId) + [HttpGet("info/{sessionId}/{jobId}")] + public IActionResult GetDownloadInfoBySession(string sessionId, string jobId) { - if (string.IsNullOrWhiteSpace(jobId)) + if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(jobId)) { - return BadRequest("Job ID is required"); + return BadRequest("Session ID and Job ID are required"); + } + + if (!_sessionService.IsValidSession(sessionId)) + { + return NotFound("Invalid or expired session"); } try { - var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); + var sessionDownloadsDir = _sessionService.GetSessionDownloadsPath(sessionId); - if (!Directory.Exists(downloadsDir)) + if (!Directory.Exists(sessionDownloadsDir)) { - return NotFound("Downloads directory not found"); + return NotFound("Session downloads directory not found"); } - // Find files with the specific job ID pattern - var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*") + // Find files with the specific job ID pattern in the session directory + var jobFiles = Directory.GetFiles(sessionDownloadsDir, $"{jobId}_*.*") .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) .OrderByDescending(System.IO.File.GetCreationTime) .ToArray(); @@ -155,6 +256,7 @@ public class DownloadController : ControllerBase var info = new { jobId, + sessionId, fileName, friendlyFileName, fileSize = FormatFileSize(fileInfo.Length), @@ -162,20 +264,21 @@ public class DownloadController : ControllerBase format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(), createdAt = fileInfo.CreationTime, lastModified = fileInfo.LastWriteTime, - downloadUrl = $"/api/download/{jobId}" + downloadUrl = $"/api/download/{sessionId}/{jobId}" }; return Ok(info); } catch (Exception ex) { - _logger.LogError(ex, "Error getting download info for job ID: {JobId}", jobId); + _logger.LogError(ex, "Error getting download info for job ID: {JobId} in session: {SessionId}", jobId, sessionId); return StatusCode(500, $"Error getting download info: {ex.Message}"); } } - [HttpDelete("{jobId}")] - public IActionResult DeleteFile(string jobId) + // Legacy info endpoint for backward compatibility + [HttpGet("info/{jobId}")] + public IActionResult GetDownloadInfo(string jobId) { if (string.IsNullOrWhiteSpace(jobId)) { @@ -191,8 +294,96 @@ public class DownloadController : ControllerBase return NotFound("Downloads directory not found"); } - // Find and delete files with the specific job ID pattern - var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*") + // Check legacy location first + var legacyFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*") + .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) + .OrderByDescending(System.IO.File.GetCreationTime) + .ToArray(); + + if (legacyFiles.Length > 0) + { + return GetFileInfo(legacyFiles[0], jobId, null); + } + + // Search session directories + var sessionDirs = Directory.GetDirectories(downloadsDir, "*", SearchOption.TopDirectoryOnly); + + foreach (var sessionDir in sessionDirs) + { + var sessionId = Path.GetFileName(sessionDir); + if (!_sessionService.IsValidSession(sessionId)) continue; + + var jobFiles = Directory.GetFiles(sessionDir, $"{jobId}_*.*") + .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) + .OrderByDescending(System.IO.File.GetCreationTime) + .ToArray(); + + if (jobFiles.Length > 0) + { + return GetFileInfo(jobFiles[0], jobId, sessionId); + } + } + + return NotFound("File not found for the specified job ID"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting download info for job ID: {JobId}", jobId); + return StatusCode(500, $"Error getting download info: {ex.Message}"); + } + } + + private IActionResult GetFileInfo(string filePath, string jobId, string? sessionId) + { + var fileName = Path.GetFileName(filePath); + var fileInfo = new FileInfo(filePath); + var friendlyFileName = CreateUserFriendlyFileName(fileName); + + var downloadUrl = sessionId != null + ? $"/api/download/{sessionId}/{jobId}" + : $"/api/download/{jobId}"; + + var info = new + { + jobId, + sessionId, + fileName, + friendlyFileName, + fileSize = FormatFileSize(fileInfo.Length), + fileSizeBytes = fileInfo.Length, + format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(), + createdAt = fileInfo.CreationTime, + lastModified = fileInfo.LastWriteTime, + downloadUrl + }; + + return Ok(info); + } + + [HttpDelete("{sessionId}/{jobId}")] + public IActionResult DeleteFileBySession(string sessionId, string jobId) + { + if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(jobId)) + { + return BadRequest("Session ID and Job ID are required"); + } + + if (!_sessionService.IsValidSession(sessionId)) + { + return NotFound("Invalid or expired session"); + } + + try + { + var sessionDownloadsDir = _sessionService.GetSessionDownloadsPath(sessionId); + + if (!Directory.Exists(sessionDownloadsDir)) + { + return NotFound("Session downloads directory not found"); + } + + // Find and delete files with the specific job ID pattern in the session directory + var jobFiles = Directory.GetFiles(sessionDownloadsDir, $"{jobId}_*.*") .ToArray(); var deletedCount = 0; @@ -203,7 +394,7 @@ public class DownloadController : ControllerBase { System.IO.File.Delete(filePath); deletedCount++; - _logger.LogInformation("Deleted file: {FilePath} for job: {JobId}", filePath, jobId); + _logger.LogInformation("Deleted file: {FilePath} for job: {JobId} in session: {SessionId}", filePath, jobId, sessionId); } catch (Exception ex) { @@ -216,6 +407,78 @@ public class DownloadController : ControllerBase return NotFound("No files found to delete for the specified job ID"); } + return Ok(new { message = $"Deleted {deletedCount} file(s) for job {jobId} in session {sessionId}", deletedCount, jobId, sessionId }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting files for job ID: {JobId} in session: {SessionId}", jobId, sessionId); + return StatusCode(500, $"Error deleting files: {ex.Message}"); + } + } + + // Legacy delete endpoint for backward compatibility + [HttpDelete("{jobId}")] + public IActionResult DeleteFile(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + return BadRequest("Job ID is required"); + } + + try + { + var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); + var deletedCount = 0; + + if (!Directory.Exists(downloadsDir)) + { + return NotFound("Downloads directory not found"); + } + + // Delete from legacy location + var legacyFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*"); + foreach (var filePath in legacyFiles) + { + try + { + System.IO.File.Delete(filePath); + deletedCount++; + _logger.LogInformation("Deleted legacy file: {FilePath} for job: {JobId}", filePath, jobId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete legacy file: {FilePath}", filePath); + } + } + + // Delete from session directories + var sessionDirs = Directory.GetDirectories(downloadsDir, "*", SearchOption.TopDirectoryOnly); + + foreach (var sessionDir in sessionDirs) + { + var sessionId = Path.GetFileName(sessionDir); + var jobFiles = Directory.GetFiles(sessionDir, $"{jobId}_*.*"); + + foreach (var filePath in jobFiles) + { + try + { + System.IO.File.Delete(filePath); + deletedCount++; + _logger.LogInformation("Deleted file: {FilePath} for job: {JobId} in session: {SessionId}", filePath, jobId, sessionId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete file: {FilePath}", filePath); + } + } + } + + if (deletedCount == 0) + { + return NotFound("No files found to delete for the specified job ID"); + } + return Ok(new { message = $"Deleted {deletedCount} file(s) for job {jobId}", deletedCount, jobId }); } catch (Exception ex) diff --git a/TuneRipper.Api/Controllers/SessionController.cs b/TuneRipper.Api/Controllers/SessionController.cs new file mode 100644 index 0000000..c9c7192 --- /dev/null +++ b/TuneRipper.Api/Controllers/SessionController.cs @@ -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 _logger; + + public SessionController(ISessionService sessionService, ILogger logger) + { + _sessionService = sessionService; + _logger = logger; + } + + [HttpPost("create")] + public ActionResult 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 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 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) + }); + } +} \ No newline at end of file diff --git a/TuneRipper.Api/Controllers/VideoController.cs b/TuneRipper.Api/Controllers/VideoController.cs index d45d48e..11b785d 100644 --- a/TuneRipper.Api/Controllers/VideoController.cs +++ b/TuneRipper.Api/Controllers/VideoController.cs @@ -10,6 +10,7 @@ namespace TuneRipper.Api.Controllers; public class VideoController : ControllerBase { private readonly IVideoService _videoService; + private readonly ISessionService _sessionService; private readonly ILogger _logger; // In-memory storage for processing status (in production, use database or Redis) @@ -18,6 +19,7 @@ public class VideoController : ControllerBase private class ProcessingJob { public string Id { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; // Add session tracking public ProcessVideoRequest Request { get; set; } = new(); public DownloadProgress Progress { get; set; } = new(); public ProcessVideoResponse? Result { get; set; } @@ -26,9 +28,10 @@ public class VideoController : ControllerBase public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } - public VideoController(IVideoService videoService, ILogger logger) + public VideoController(IVideoService videoService, ISessionService sessionService, ILogger logger) { _videoService = videoService; + _sessionService = sessionService; _logger = logger; } @@ -49,6 +52,9 @@ public class VideoController : ControllerBase { _logger.LogInformation("Starting video processing for URL: {Url}", request.Url); + // Get or create session + var sessionId = _sessionService.GetOrCreateSession(request.SessionId); + // Generate a job ID var jobId = Guid.NewGuid().ToString(); @@ -56,6 +62,7 @@ public class VideoController : ControllerBase var job = new ProcessingJob { Id = jobId, + SessionId = sessionId, Request = request, Progress = new DownloadProgress { @@ -76,7 +83,8 @@ public class VideoController : ControllerBase Message = "Processing started", FileName = jobId, // Use job ID as temporary filename DownloadUrl = null, // Will be available when processing completes - VideoDetails = null // Will be populated during processing + VideoDetails = null, // Will be populated during processing + SessionId = sessionId // Return session ID to client }; return Accepted(response); @@ -140,10 +148,18 @@ public class VideoController : ControllerBase return Ok(job.Result); } - [HttpGet("jobs")] - public ActionResult> GetAllJobs() + [HttpGet("jobs/{sessionId?}")] + public ActionResult> GetJobsBySession(string? sessionId = null) { - var jobs = _processingJobs.Values.Select(job => new JobStatusResponse + var jobsQuery = _processingJobs.Values.AsEnumerable(); + + // Filter by session if provided + if (!string.IsNullOrWhiteSpace(sessionId)) + { + jobsQuery = jobsQuery.Where(job => job.SessionId == sessionId); + } + + var jobs = jobsQuery.Select(job => new JobStatusResponse { JobId = job.Id, Status = job.Progress.Status, @@ -157,6 +173,13 @@ public class VideoController : ControllerBase return Ok(jobs); } + // Keep the old endpoint for backward compatibility + [HttpGet("jobs")] + public ActionResult> GetAllJobs() + { + return GetJobsBySession(null); + } + private async Task ProcessVideoAsync(string jobId) { if (!_processingJobs.TryGetValue(jobId, out var job)) @@ -164,7 +187,7 @@ public class VideoController : ControllerBase try { - _logger.LogInformation("Processing video for job {JobId}", jobId); + _logger.LogInformation("Processing video for job {JobId} in session {SessionId}", jobId, job.SessionId); // Step 1: Fetch video details job.Progress.Status = "Processing"; @@ -195,6 +218,7 @@ public class VideoController : ControllerBase job.Request.Url!, job.Request.Format ?? "mp3", jobId, // Pass the job ID for filename generation + job.SessionId, // Pass the session ID for directory organization (percentage, message) => { // Update progress from yt-dlp @@ -217,8 +241,8 @@ public class VideoController : ControllerBase job.Progress.Message = "Processing completed successfully"; job.Progress.PercentageComplete = 100; - // Create download URL for the actual file - var downloadUrl = $"/downloads/{Uri.EscapeDataString(downloadedFileName)}"; + // Create download URL for the actual file within session + var downloadUrl = $"/api/download/{job.SessionId}/{jobId}"; job.Result = new ProcessVideoResponse { @@ -226,14 +250,15 @@ public class VideoController : ControllerBase Message = "Video processed successfully", FileName = downloadedFileName, DownloadUrl = downloadUrl, - VideoDetails = videoDetails + VideoDetails = videoDetails, + SessionId = job.SessionId }; - _logger.LogInformation("Successfully completed processing for job {JobId}. File: {FileName}", jobId, downloadedFileName); + _logger.LogInformation("Successfully completed processing for job {JobId} in session {SessionId}. File: {FileName}", jobId, job.SessionId, downloadedFileName); } catch (Exception ex) { - _logger.LogError(ex, "Error processing video for job {JobId}", jobId); + _logger.LogError(ex, "Error processing video for job {JobId} in session {SessionId}", jobId, job.SessionId); job.Progress.Status = "Failed"; job.Progress.Message = $"Processing failed: {ex.Message}"; @@ -245,7 +270,8 @@ public class VideoController : ControllerBase Message = ex.Message, FileName = null, DownloadUrl = null, - VideoDetails = job.VideoDetails // Include video details even on failure if we got them + VideoDetails = job.VideoDetails, // Include video details even on failure if we got them + SessionId = job.SessionId }; } } diff --git a/TuneRipper.Api/Program.cs b/TuneRipper.Api/Program.cs index 1ff6139..b5435c6 100644 --- a/TuneRipper.Api/Program.cs +++ b/TuneRipper.Api/Program.cs @@ -10,6 +10,7 @@ builder.Services.AddHttpClient(); // Register services builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Register session service as singleton // Add CORS for Blazor WebAssembly client builder.Services.AddCors(options => @@ -64,6 +65,7 @@ app.MapGet("/", (IWebHostEnvironment env) => new check_status = "/api/video/status/{jobId}", get_result = "/api/video/result/{jobId}", download = "/api/download/{jobId}", + download_by_session = "/api/download/{sessionId}/{jobId}", static_files = "/downloads/{fileName}", openapi = "/openapi/v1.json" }, @@ -80,10 +82,11 @@ app.MapControllers(); app.Logger.LogInformation("?? TuneRipper API is starting..."); app.Logger.LogInformation("?? Environment: {Environment}", app.Environment.EnvironmentName); app.Logger.LogInformation("?? API will be available at:"); -app.Logger.LogInformation(" • HTTPS: https://localhost:7160"); -app.Logger.LogInformation(" • HTTP: http://localhost:5112"); +app.Logger.LogInformation(" ?? HTTPS: https://localhost:7160"); +app.Logger.LogInformation(" ?? HTTP: http://localhost:5112"); app.Logger.LogInformation("?? Health check: /api/health"); app.Logger.LogInformation("?? API info: /"); app.Logger.LogInformation("?? Static files served from: /downloads/*"); +app.Logger.LogInformation("?? Session-based downloads supported"); app.Run(); diff --git a/TuneRipper.Api/Services/ISessionService.cs b/TuneRipper.Api/Services/ISessionService.cs new file mode 100644 index 0000000..4f92364 --- /dev/null +++ b/TuneRipper.Api/Services/ISessionService.cs @@ -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); +} \ No newline at end of file diff --git a/TuneRipper.Api/Services/SessionService.cs b/TuneRipper.Api/Services/SessionService.cs new file mode 100644 index 0000000..84257d0 --- /dev/null +++ b/TuneRipper.Api/Services/SessionService.cs @@ -0,0 +1,124 @@ +using System.Collections.Concurrent; + +namespace TuneRipper.Api.Services; + +public class SessionService : ISessionService +{ + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _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 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(); + } +} \ No newline at end of file diff --git a/TuneRipper.Api/Services/VideoService.cs b/TuneRipper.Api/Services/VideoService.cs index 289e00c..5d3c07a 100644 --- a/TuneRipper.Api/Services/VideoService.cs +++ b/TuneRipper.Api/Services/VideoService.cs @@ -13,7 +13,7 @@ public interface IVideoService Task GetVideoDetailsAsync(string url); bool IsValidYouTubeUrl(string url); string ExtractVideoId(string url); - Task ProcessVideoWithProgressAsync(string url, string format, string jobId, Action progressCallback); + Task ProcessVideoWithProgressAsync(string url, string format, string jobId, string sessionId, Action progressCallback); } public class VideoService : IVideoService @@ -21,12 +21,14 @@ public class VideoService : IVideoService private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly IWebHostEnvironment _environment; + private readonly ISessionService _sessionService; - public VideoService(ILogger logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory) + public VideoService(ILogger logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory, ISessionService sessionService) { _logger = logger; _httpClient = httpClientFactory.CreateClient(); _environment = environment; + _sessionService = sessionService; } public async Task GetVideoDetailsAsync(string url) @@ -90,7 +92,7 @@ public class VideoService : IVideoService } } - public async Task ProcessVideoWithProgressAsync(string url, string format, string jobId, Action progressCallback) + public async Task ProcessVideoWithProgressAsync(string url, string format, string jobId, string sessionId, Action progressCallback) { try { @@ -101,8 +103,8 @@ public class VideoService : IVideoService throw new FileNotFoundException($"yt-dlp.exe not found at {ytDlpPath}"); } - // Create downloads directory if it doesn't exist - var downloadDir = Path.Combine(_environment.WebRootPath, "downloads"); + // Get session-specific download directory + var downloadDir = _sessionService.GetSessionDownloadsPath(sessionId); Directory.CreateDirectory(downloadDir); // Get video details first for better filename @@ -113,7 +115,7 @@ public class VideoService : IVideoService var customFileName = $"{jobId}_{safeTitle}"; var outputTemplate = Path.Combine(downloadDir, $"{customFileName}.%(ext)s"); - _logger.LogInformation("Processing video with filename pattern: {FileName}", customFileName); + _logger.LogInformation("Processing video with filename pattern: {FileName} in session: {SessionId}", customFileName, sessionId); // Build yt-dlp arguments var arguments = new StringBuilder(); @@ -188,7 +190,7 @@ public class VideoService : IVideoService throw new Exception($"yt-dlp failed with exit code {process.ExitCode}. Error: {error}"); } - // Find the downloaded file with our job ID pattern + // Find the downloaded file with our job ID pattern in the session directory var downloadedFiles = Directory.GetFiles(downloadDir, $"{jobId}_*.*") .Where(f => !f.EndsWith(".part")) .OrderByDescending(System.IO.File.GetCreationTime) @@ -196,13 +198,13 @@ public class VideoService : IVideoService if (downloadedFiles.Length == 0) { - throw new Exception($"No downloaded file found for job {jobId} after processing"); + throw new Exception($"No downloaded file found for job {jobId} in session {sessionId} after processing"); } var downloadedFile = downloadedFiles[0]; var finalFileName = Path.GetFileName(downloadedFile); - _logger.LogInformation("Successfully processed video. Job: {JobId}, File: {FileName}", jobId, finalFileName); + _logger.LogInformation("Successfully processed video. Job: {JobId}, Session: {SessionId}, File: {FileName}", jobId, sessionId, finalFileName); progressCallback(100, "Processing completed successfully"); @@ -210,7 +212,7 @@ public class VideoService : IVideoService } catch (Exception ex) { - _logger.LogError(ex, "Error processing video with yt-dlp for job {JobId}: {Url}", jobId, url); + _logger.LogError(ex, "Error processing video with yt-dlp for job {JobId} in session {SessionId}: {Url}", jobId, sessionId, url); progressCallback(0, $"Processing failed: {ex.Message}"); throw; } diff --git a/TuneRipper.Shared/Models/ProcessVideoModels.cs b/TuneRipper.Shared/Models/ProcessVideoModels.cs index 2b2ea2d..7d81c6c 100644 --- a/TuneRipper.Shared/Models/ProcessVideoModels.cs +++ b/TuneRipper.Shared/Models/ProcessVideoModels.cs @@ -5,6 +5,7 @@ public class ProcessVideoRequest public string? Url { get; set; } public string? Format { get; set; } = "mp3"; public string? Quality { get; set; } = "128"; + public string? SessionId { get; set; } // Add session ID support } public class ProcessVideoResponse @@ -14,6 +15,7 @@ public class ProcessVideoResponse public string? FileName { get; set; } public string? DownloadUrl { get; set; } public YouTubeVideoDetails? VideoDetails { get; set; } + public string? SessionId { get; set; } // Include session ID in response } public class DownloadProgress diff --git a/TuneRipper/Extensions/ServiceCollectionExtensions.cs b/TuneRipper/Extensions/ServiceCollectionExtensions.cs index 7a3d88e..3039d6c 100644 --- a/TuneRipper/Extensions/ServiceCollectionExtensions.cs +++ b/TuneRipper/Extensions/ServiceCollectionExtensions.cs @@ -42,6 +42,9 @@ public static class ServiceCollectionExtensions // Register API service services.AddScoped(); + // Register client session service + services.AddScoped(); + return services; } } \ No newline at end of file diff --git a/TuneRipper/Pages/Home.razor b/TuneRipper/Pages/Home.razor index 5c4b352..1d6c73e 100644 --- a/TuneRipper/Pages/Home.razor +++ b/TuneRipper/Pages/Home.razor @@ -1,7 +1,10 @@ @page "/" @using Microsoft.AspNetCore.Components.Web +@using TuneRipper.Shared.Models +@using TuneRipper.Services @inject IJSRuntime JSRuntime @inject ITuneRipperApiService ApiService +@inject IClientSessionService SessionService @inject ILogger Logger TuneRipper - YouTube to MP3 Downloader @@ -25,6 +28,14 @@

TuneRipper

Convert YouTube videos to MP3 instantly

+ + @* Session info display *@ + @if (!string.IsNullOrWhiteSpace(currentSessionId)) + { +
+ Session: @currentSessionId.Substring(0, 8)... +
+ }
@@ -161,6 +172,7 @@ private string progressMessage = ""; private List downloadLinks = new(); private YouTubeVideoDetails? currentVideoDetails = null; + private string currentSessionId = ""; public class DownloadItem { @@ -170,6 +182,22 @@ public DateTime CreatedAt { get; set; } } + protected override async Task OnInitializedAsync() + { + try + { + // Initialize session on component load + currentSessionId = await SessionService.GetCurrentSessionIdAsync(); + Logger.LogInformation("Initialized with session: {SessionId}", currentSessionId); + StateHasChanged(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error initializing session"); + // Don't block the UI if session initialization fails + } + } + private async Task DownloadMp3() { if (!string.IsNullOrWhiteSpace(youtubeUrl)) @@ -182,6 +210,12 @@ try { + // Ensure we have a session + if (string.IsNullOrWhiteSpace(currentSessionId)) + { + currentSessionId = await SessionService.GetCurrentSessionIdAsync(); + } + // Step 1: Start processing (which includes video details fetching) progressMessage = "Starting video processing..."; progressPercentage = 5; @@ -191,7 +225,8 @@ { Url = youtubeUrl, Format = "mp3", - Quality = "128" + Quality = "128", + SessionId = currentSessionId // Include session ID in request }; var processingResponse = await ApiService.StartProcessingAsync(request); @@ -199,9 +234,18 @@ if (processingResponse?.Success == true) { var jobId = processingResponse.FileName; // This contains the job ID + var sessionId = processingResponse.SessionId ?? currentSessionId; + + // Update current session ID if server returned a different one + if (!string.IsNullOrWhiteSpace(processingResponse.SessionId) && + processingResponse.SessionId != currentSessionId) + { + currentSessionId = processingResponse.SessionId; + await SessionService.SetSessionIdAsync(currentSessionId); + } // Step 2: Monitor processing status and get video details - await MonitorProcessingAndShowDetails(jobId); + await MonitorProcessingAndShowDetails(jobId, sessionId); } else { @@ -221,7 +265,7 @@ } } - private async Task MonitorProcessingAndShowDetails(string jobId) + private async Task MonitorProcessingAndShowDetails(string jobId, string sessionId) { try { @@ -256,7 +300,7 @@ var result = await ApiService.GetProcessingResultAsync(jobId); if (result?.Success == true) { - await CompleteProcessing(result, jobId); + await CompleteProcessing(result, jobId, sessionId); } else { @@ -295,7 +339,7 @@ } } - private async Task CompleteProcessing(ProcessVideoResponse result, string jobId) + private async Task CompleteProcessing(ProcessVideoResponse result, string jobId, string sessionId) { try { @@ -308,17 +352,17 @@ // Use the actual video title from API string title = currentVideoDetails?.Title ?? result.VideoDetails?.Title ?? ExtractTitleFromUrl(youtubeUrl); - // Create the download URL - either from the result or construct it from the job ID + // Create the session-aware download URL string downloadUrl; - if (!string.IsNullOrEmpty(result.DownloadUrl)) + if (!string.IsNullOrEmpty(result.DownloadUrl) && result.DownloadUrl.Contains(sessionId)) { - // If the result contains a direct download URL, use it + // Server provided a session-aware download URL downloadUrl = result.DownloadUrl.StartsWith("http") ? result.DownloadUrl : $"https://localhost:7160{result.DownloadUrl}"; } else { - // Fallback: construct download URL from job ID - downloadUrl = $"https://localhost:7160/api/download/{jobId}"; + // Construct session-aware download URL + downloadUrl = $"https://localhost:7160/api/download/{sessionId}/{jobId}"; } downloadLinks.Add(new DownloadItem diff --git a/TuneRipper/Pages/Home.razor.css b/TuneRipper/Pages/Home.razor.css index c0e431e..6419ef8 100644 --- a/TuneRipper/Pages/Home.razor.css +++ b/TuneRipper/Pages/Home.razor.css @@ -40,6 +40,25 @@ font-weight: 400; } +/* Session info styling */ +.session-info { + background: #f7fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 8px 16px; + margin-top: 15px; + display: inline-block; + color: #4a5568; + font-size: 0.85rem; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + opacity: 0.8; + transition: opacity 0.2s ease; +} + +.session-info:hover { + opacity: 1; +} + .download-form { margin-bottom: 40px; } @@ -457,6 +476,11 @@ .feature { min-width: 100px; } + + .session-info { + font-size: 0.8rem; + padding: 6px 12px; + } } /* Responsive Design */ diff --git a/TuneRipper/Services/ClientSessionService.cs b/TuneRipper/Services/ClientSessionService.cs new file mode 100644 index 0000000..04ad2b2 --- /dev/null +++ b/TuneRipper/Services/ClientSessionService.cs @@ -0,0 +1,102 @@ +using Microsoft.JSInterop; + +namespace TuneRipper.Services; + +public interface IClientSessionService +{ + Task GetCurrentSessionIdAsync(); + Task SetSessionIdAsync(string sessionId); + Task ClearSessionAsync(); +} + +public class ClientSessionService : IClientSessionService +{ + private readonly IJSRuntime _jsRuntime; + private readonly ITuneRipperApiService _apiService; + private readonly ILogger _logger; + private const string SESSION_KEY = "tuneRipper.sessionId"; + + public ClientSessionService(IJSRuntime jsRuntime, ITuneRipperApiService apiService, ILogger logger) + { + _jsRuntime = jsRuntime; + _apiService = apiService; + _logger = logger; + } + + public async Task GetCurrentSessionIdAsync() + { + try + { + // Try to get existing session from localStorage + var existingSessionId = await _jsRuntime.InvokeAsync("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; + } + } +} \ No newline at end of file diff --git a/TuneRipper/Services/TuneRipperApiService.cs b/TuneRipper/Services/TuneRipperApiService.cs index 8fd7d3c..5cc1a02 100644 --- a/TuneRipper/Services/TuneRipperApiService.cs +++ b/TuneRipper/Services/TuneRipperApiService.cs @@ -13,8 +13,13 @@ public interface ITuneRipperApiService Task GetProcessingStatusAsync(string jobId); Task GetProcessingResultAsync(string jobId); Task?> GetAllJobsAsync(); + Task?> GetJobsBySessionAsync(string sessionId); Task DownloadFileAsync(string jobId); + Task DownloadFileBySessionAsync(string sessionId, string jobId); Task IsApiHealthyAsync(); + Task CreateSessionAsync(); + Task ValidateSessionAsync(string sessionId); + Task RefreshSessionAsync(string sessionId); } public class TuneRipperApiService : ITuneRipperApiService @@ -50,6 +55,97 @@ public class TuneRipperApiService : ITuneRipperApiService } } + public async Task 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(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 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(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 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 StartProcessingAsync(ProcessVideoRequest request) { try @@ -60,15 +156,15 @@ public class TuneRipperApiService : ITuneRipperApiService return null; } - _logger.LogInformation("Starting video processing for URL: {Url}", request.Url); + _logger.LogInformation("Starting video processing for URL: {Url} with session: {SessionId}", request.Url, request.SessionId); var response = await _httpClient.PostAsJsonAsync("api/video/process", request, _jsonOptions); if (response.IsSuccessStatusCode) { var result = await response.Content.ReadFromJsonAsync(_jsonOptions); - _logger.LogInformation("Successfully started processing for URL: {Url}, JobId: {JobId}", - request.Url, result?.FileName); + _logger.LogInformation("Successfully started processing for URL: {Url}, JobId: {JobId}, SessionId: {SessionId}", + request.Url, result?.FileName, result?.SessionId); return result; } @@ -99,6 +195,74 @@ public class TuneRipperApiService : ITuneRipperApiService } } + public async Task?> 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>(_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 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 GetProcessingStatusAsync(string jobId) { try