using Microsoft.Extensions.Options; using octo_fiesta.Models.Settings; namespace octo_fiesta.Services.Common; /// /// Background service that periodically cleans up old cached files /// Only runs when StorageMode is set to Cache /// public class CacheCleanupService : BackgroundService { private readonly IConfiguration _configuration; private readonly SubsonicSettings _subsonicSettings; private readonly ILogger _logger; private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1); public CacheCleanupService( IConfiguration configuration, IOptions subsonicSettings, ILogger logger) { _configuration = configuration; _subsonicSettings = subsonicSettings.Value; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Only run if storage mode is Cache if (_subsonicSettings.StorageMode != StorageMode.Cache) { _logger.LogInformation("CacheCleanupService disabled: StorageMode is not Cache"); return; } _logger.LogInformation("CacheCleanupService started with cleanup interval of {Interval} and retention of {Hours} hours", _cleanupInterval, _subsonicSettings.CacheDurationHours); while (!stoppingToken.IsCancellationRequested) { try { await CleanupOldCachedFilesAsync(stoppingToken); await Task.Delay(_cleanupInterval, stoppingToken); } catch (OperationCanceledException) { // Service is stopping, exit gracefully break; } catch (Exception ex) { _logger.LogError(ex, "Error during cache cleanup"); // Continue running even if cleanup fails await Task.Delay(_cleanupInterval, stoppingToken); } } _logger.LogInformation("CacheCleanupService stopped"); } private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken) { var cachePath = PathHelper.GetCachePath(); if (!Directory.Exists(cachePath)) { _logger.LogDebug("Cache directory does not exist: {Path}", cachePath); return; } var cutoffTime = DateTime.UtcNow.AddHours(-_subsonicSettings.CacheDurationHours); var deletedCount = 0; var totalSize = 0L; _logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime); try { // Get all files in cache directory and subdirectories var files = Directory.GetFiles(cachePath, "*.*", SearchOption.AllDirectories); foreach (var filePath in files) { if (cancellationToken.IsCancellationRequested) break; try { var fileInfo = new FileInfo(filePath); // Use last access time to determine if file should be deleted // This gets updated when a cached file is streamed if (fileInfo.LastAccessTimeUtc < cutoffTime) { var size = fileInfo.Length; File.Delete(filePath); deletedCount++; totalSize += size; _logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})", filePath, fileInfo.LastAccessTimeUtc); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete cached file: {Path}", filePath); } } // Clean up empty directories await CleanupEmptyDirectoriesAsync(cachePath, cancellationToken); if (deletedCount > 0) { var sizeMB = totalSize / (1024.0 * 1024.0); _logger.LogInformation("Cache cleanup completed: deleted {Count} files, freed {Size:F2} MB", deletedCount, sizeMB); } else { _logger.LogDebug("Cache cleanup completed: no files to delete"); } } catch (Exception ex) { _logger.LogError(ex, "Error during cache cleanup"); } } private async Task CleanupEmptyDirectoriesAsync(string rootPath, CancellationToken cancellationToken) { try { var directories = Directory.GetDirectories(rootPath, "*", SearchOption.AllDirectories) .OrderByDescending(d => d.Length); // Process deepest directories first foreach (var directory in directories) { if (cancellationToken.IsCancellationRequested) break; try { if (!Directory.EnumerateFileSystemEntries(directory).Any()) { Directory.Delete(directory); _logger.LogDebug("Deleted empty directory: {Path}", directory); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete empty directory: {Path}", directory); } } } catch (Exception ex) { _logger.LogWarning(ex, "Error cleaning up empty directories"); } await Task.CompletedTask; } }