diff --git a/.env.example b/.env.example index cde2e68..ffd4b06 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Navidrome/Subsonic server URL SUBSONIC_URL=http://localhost:4533 -# Path where downloaded songs will be stored on the host +# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent) DOWNLOAD_PATH=./downloads # Music service to use: Deezer or Qobuz (default: Deezer) @@ -46,3 +46,15 @@ EXPLICIT_FILTER=All # - Album: When playing a track, download the entire album in background # The played track is downloaded first, remaining tracks are queued DOWNLOAD_MODE=Track + +# Storage mode (optional, default: Permanent) +# - Permanent: Files are saved to the library permanently and registered in Navidrome +# - Cache: Files are stored in /tmp and automatically cleaned up after CACHE_DURATION_HOURS +# Not registered in Navidrome, ideal for streaming without library bloat +STORAGE_MODE=Permanent + +# Cache duration in hours (optional, default: 1) +# Files older than this duration will be automatically deleted when STORAGE_MODE=Cache +# Based on last access time (updated each time the file is streamed) +# Cache location: /tmp/octo-fiesta-cache (automatic, no configuration needed) +CACHE_DURATION_HOURS=1 diff --git a/octo-fiesta/Models/Settings/SubsonicSettings.cs b/octo-fiesta/Models/Settings/SubsonicSettings.cs index 6a8cb0d..7377924 100644 --- a/octo-fiesta/Models/Settings/SubsonicSettings.cs +++ b/octo-fiesta/Models/Settings/SubsonicSettings.cs @@ -40,6 +40,23 @@ public enum ExplicitFilter CleanOnly } +/// +/// Storage mode for downloaded tracks +/// +public enum StorageMode +{ + /// + /// Files are permanently stored in the library and registered in the database + /// + Permanent, + + /// + /// Files are stored in a temporary cache and automatically cleaned up + /// Not registered in the database, no Navidrome scan triggered + /// + Cache +} + /// /// Music service provider /// @@ -81,4 +98,19 @@ public class SubsonicSettings /// Values: "Deezer", "Qobuz" /// public MusicService MusicService { get; set; } = MusicService.Deezer; + + /// + /// Storage mode for downloaded files (default: Permanent) + /// Environment variable: STORAGE_MODE + /// Values: "Permanent" (files saved to library), "Cache" (temporary files, auto-cleanup) + /// + public StorageMode StorageMode { get; set; } = StorageMode.Permanent; + + /// + /// Cache duration in hours for Cache storage mode (default: 1) + /// Environment variable: CACHE_DURATION_HOURS + /// Files older than this duration will be automatically deleted + /// Only applies when StorageMode is Cache + /// + public int CacheDurationHours { get; set; } = 1; } \ No newline at end of file diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index ddd47ff..6eaddcf 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -5,6 +5,7 @@ using octo_fiesta.Services.Qobuz; using octo_fiesta.Services.Local; using octo_fiesta.Services.Validation; using octo_fiesta.Services.Subsonic; +using octo_fiesta.Services.Common; using octo_fiesta.Middleware; var builder = WebApplication.CreateBuilder(args); @@ -64,6 +65,9 @@ builder.Services.AddSingleton(); // Register orchestrator as hosted service builder.Services.AddHostedService(); +// Register cache cleanup service (only runs when StorageMode is Cache) +builder.Services.AddHostedService(); + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs index 050d651..5709505 100644 --- a/octo-fiesta/Services/Common/BaseDownloadService.cs +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -23,6 +23,7 @@ public abstract class BaseDownloadService : IDownloadService protected readonly ILogger Logger; protected readonly string DownloadPath; + protected readonly string CachePath; protected readonly Dictionary ActiveDownloads = new(); protected readonly SemaphoreSlim DownloadLock = new(1, 1); @@ -46,11 +47,17 @@ public abstract class BaseDownloadService : IDownloadService Logger = logger; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; + CachePath = Path.Combine(Path.GetTempPath(), "octo-fiesta-cache"); if (!Directory.Exists(DownloadPath)) { Directory.CreateDirectory(DownloadPath); } + + if (!Directory.Exists(CachePath)) + { + Directory.CreateDirectory(CachePath); + } } #region IDownloadService Implementation @@ -62,7 +69,7 @@ public abstract class BaseDownloadService : IDownloadService public async Task DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - var localPath = await DownloadSongAsync(externalProvider, externalId, cancellationToken); + var localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); return IOFile.OpenRead(localPath); } @@ -130,13 +137,29 @@ public abstract class BaseDownloadService : IDownloadService } var songId = $"ext-{externalProvider}-{externalId}"; + var isCache = SubsonicSettings.StorageMode == StorageMode.Cache; - // Check if already downloaded - var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); - if (existingPath != null && IOFile.Exists(existingPath)) + // Check if already downloaded (skip for cache mode as we want to check cache folder) + if (!isCache) { - Logger.LogInformation("Song already downloaded: {Path}", existingPath); - return existingPath; + var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); + if (existingPath != null && IOFile.Exists(existingPath)) + { + Logger.LogInformation("Song already downloaded: {Path}", existingPath); + return existingPath; + } + } + else + { + // For cache mode, check if file exists in cache directory + var cachedPath = GetCachedFilePath(externalProvider, externalId); + if (cachedPath != null && IOFile.Exists(cachedPath)) + { + Logger.LogInformation("Song found in cache: {Path}", cachedPath); + // Update file access time for cache cleanup logic + IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow); + return cachedPath; + } } // Check if download in progress @@ -185,30 +208,39 @@ public abstract class BaseDownloadService : IDownloadService downloadInfo.CompletedAt = DateTime.UtcNow; song.LocalPath = localPath; - await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath); - // Trigger a Subsonic library rescan (with debounce) - _ = Task.Run(async () => + // Only register and scan if NOT in cache mode + if (!isCache) { - try + await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath); + + // Trigger a Subsonic library rescan (with debounce) + _ = Task.Run(async () => { - await LocalLibraryService.TriggerLibraryScanAsync(); - } - catch (Exception ex) + try + { + await LocalLibraryService.TriggerLibraryScanAsync(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to trigger library scan after download"); + } + }); + + // If download mode is Album and triggering is enabled, start background download of remaining tracks + if (triggerAlbumDownload && SubsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId)) { - Logger.LogWarning(ex, "Failed to trigger library scan after download"); + var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId); + if (!string.IsNullOrEmpty(albumExternalId)) + { + Logger.LogInformation("Download mode is Album, triggering background download for album {AlbumId}", albumExternalId); + DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId); + } } - }); - - // If download mode is Album and triggering is enabled, start background download of remaining tracks - if (triggerAlbumDownload && SubsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId)) + } + else { - var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId); - if (!string.IsNullOrEmpty(albumExternalId)) - { - Logger.LogInformation("Download mode is Album, triggering background download for album {AlbumId}", albumExternalId); - DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId); - } + Logger.LogInformation("Cache mode: skipping library registration and scan"); } Logger.LogInformation("Download completed: {Path}", localPath); @@ -401,5 +433,31 @@ public abstract class BaseDownloadService : IDownloadService } } + /// + /// Gets the cached file path for a given provider and external ID + /// Returns null if no cached file exists + /// + protected string? GetCachedFilePath(string provider, string externalId) + { + try + { + // Search for cached files matching the pattern: {provider}_{externalId}.* + var pattern = $"{provider}_{externalId}.*"; + var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories); + + if (files.Length > 0) + { + return files[0]; // Return first match + } + + return null; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId); + return null; + } + } + #endregion } diff --git a/octo-fiesta/Services/Common/CacheCleanupService.cs b/octo-fiesta/Services/Common/CacheCleanupService.cs new file mode 100644 index 0000000..13d7f0a --- /dev/null +++ b/octo-fiesta/Services/Common/CacheCleanupService.cs @@ -0,0 +1,163 @@ +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 = Path.Combine(Path.GetTempPath(), "octo-fiesta-cache"); + + 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; + } +} diff --git a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs index 0bc843d..e813da5 100644 --- a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs +++ b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs @@ -109,7 +109,8 @@ public class DeezerDownloadService : BaseDownloadService // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) var artistForPath = song.AlbumArtist ?? song.Artist; - var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension); + var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath; + var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); // Create directories if they don't exist var albumFolder = Path.GetDirectoryName(outputPath)!; diff --git a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs index 5abddfa..3de4ea2 100644 --- a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs @@ -108,7 +108,8 @@ public class QobuzDownloadService : BaseDownloadService // Build organized folder structure using AlbumArtist (fallback to Artist for singles) var artistForPath = song.AlbumArtist ?? song.Artist; - var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension); + var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath; + var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); var albumFolder = Path.GetDirectoryName(outputPath)!; EnsureDirectoryExists(albumFolder); diff --git a/octo-fiesta/appsettings.json b/octo-fiesta/appsettings.json index 15b05ce..e8509c1 100644 --- a/octo-fiesta/appsettings.json +++ b/octo-fiesta/appsettings.json @@ -9,7 +9,9 @@ "Subsonic": { "Url": "http://localhost:4533", "ExplicitFilter": "All", - "DownloadMode": "Track" + "DownloadMode": "Track", + "StorageMode": "Permanent", + "CacheDurationHours": 1 }, "Library": { "DownloadPath": "./downloads"