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"