using System.Collections.Concurrent; using Microsoft.Extensions.Options; using TuneRipper.Api.Configuration; namespace TuneRipper.Api.Services; public class SessionService : ISessionService, IDisposable { private readonly ILogger _logger; private readonly string _downloadsRoot; 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, IOptions options, ILogger logger) { _logger = logger; _downloadsRoot = Path.GetFullPath( string.IsNullOrWhiteSpace(options.Value.DownloadsPath) ? Path.Combine(environment.WebRootPath, "downloads") : options.Value.DownloadsPath); Directory.CreateDirectory(_downloadsRoot); RestoreSessionsFromDisk(); // Clean up expired sessions every hour _cleanupTimer = new Timer( callback: _ => CleanupExpiredSessions(), state: null, dueTime: TimeSpan.FromHours(1), period: TimeSpan.FromHours(1) ); } /// /// Creates a new session with a unique ID. /// Note: The session directory is NOT created at this point - it will be created /// lazily when the first MP3 file is saved using EnsureSessionDirectoryExists(). /// /// The unique session ID public string CreateSession() { var sessionId = Guid.NewGuid().ToString("N")[..16]; // Use first 16 characters for shorter IDs _sessions[sessionId] = DateTime.UtcNow; // Don't create directory here - it will be created when first MP3 is saved _logger.LogInformation("Created new session: {SessionId}", sessionId); return sessionId; } public string GetOrCreateSession(string? sessionId) { if (!string.IsNullOrWhiteSpace(sessionId) && IsValidSession(sessionId)) { TouchSession(sessionId); return sessionId; } return CreateSession(); } public bool IsValidSession(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) return false; if (!_sessions.TryGetValue(sessionId, out var lastActivity)) { var sessionPath = GetSessionDownloadsPath(sessionId); if (Directory.Exists(sessionPath)) { lastActivity = Directory.GetLastWriteTimeUtc(sessionPath); _sessions.TryAdd(sessionId, lastActivity); } } if (_sessions.TryGetValue(sessionId, out 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 if it exists 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); } } else { _logger.LogDebug("No directory to clean up for expired session: {SessionId} (no MP3 files were downloaded)", 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 GetDownloadsRootPath() => _downloadsRoot; public string GetSessionDownloadsPath(string sessionId) { return Path.Combine(_downloadsRoot, sessionId); } /// /// Ensures that the session directory exists, creating it if necessary. /// This method should be called just before saving the first MP3 file in a session. /// /// The session ID /// Thrown when sessionId is null or empty /// Thrown when session is invalid or expired public void EnsureSessionDirectoryExists(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) { throw new ArgumentException("Session ID cannot be null or empty", nameof(sessionId)); } if (!IsValidSession(sessionId)) { throw new InvalidOperationException($"Invalid session ID: {sessionId}"); } var sessionPath = GetSessionDownloadsPath(sessionId); if (!Directory.Exists(sessionPath)) { Directory.CreateDirectory(sessionPath); _logger.LogInformation("Created session directory for session: {SessionId} at {Path}", sessionId, sessionPath); } TouchSession(sessionId); } private void RestoreSessionsFromDisk() { foreach (var sessionPath in Directory.EnumerateDirectories(_downloadsRoot)) { var sessionId = Path.GetFileName(sessionPath); var lastActivity = Directory.GetLastWriteTimeUtc(sessionPath); if (DateTime.UtcNow - lastActivity <= _sessionTimeout) { _sessions[sessionId] = lastActivity; } } _logger.LogInformation( "Restored {Count} active sessions from {DownloadsRoot}", _sessions.Count, _downloadsRoot); } private void TouchSession(string sessionId) { var now = DateTime.UtcNow; _sessions[sessionId] = now; var sessionPath = GetSessionDownloadsPath(sessionId); if (Directory.Exists(sessionPath)) { Directory.SetLastWriteTimeUtc(sessionPath, now); } } public void Dispose() { _cleanupTimer?.Dispose(); } }