feat: add cache-only storage mode for streaming without library persistence

This commit is contained in:
V1ck3s
2026-01-10 00:46:18 +01:00
committed by Vickes
parent 318ce96cbc
commit dbb1964f46
8 changed files with 301 additions and 28 deletions

View File

@@ -1,7 +1,7 @@
# Navidrome/Subsonic server URL # Navidrome/Subsonic server URL
SUBSONIC_URL=http://localhost:4533 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 DOWNLOAD_PATH=./downloads
# Music service to use: Deezer or Qobuz (default: Deezer) # 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 # - Album: When playing a track, download the entire album in background
# The played track is downloaded first, remaining tracks are queued # The played track is downloaded first, remaining tracks are queued
DOWNLOAD_MODE=Track 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

View File

@@ -40,6 +40,23 @@ public enum ExplicitFilter
CleanOnly CleanOnly
} }
/// <summary>
/// Storage mode for downloaded tracks
/// </summary>
public enum StorageMode
{
/// <summary>
/// Files are permanently stored in the library and registered in the database
/// </summary>
Permanent,
/// <summary>
/// Files are stored in a temporary cache and automatically cleaned up
/// Not registered in the database, no Navidrome scan triggered
/// </summary>
Cache
}
/// <summary> /// <summary>
/// Music service provider /// Music service provider
/// </summary> /// </summary>
@@ -81,4 +98,19 @@ public class SubsonicSettings
/// Values: "Deezer", "Qobuz" /// Values: "Deezer", "Qobuz"
/// </summary> /// </summary>
public MusicService MusicService { get; set; } = MusicService.Deezer; public MusicService MusicService { get; set; } = MusicService.Deezer;
/// <summary>
/// Storage mode for downloaded files (default: Permanent)
/// Environment variable: STORAGE_MODE
/// Values: "Permanent" (files saved to library), "Cache" (temporary files, auto-cleanup)
/// </summary>
public StorageMode StorageMode { get; set; } = StorageMode.Permanent;
/// <summary>
/// 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
/// </summary>
public int CacheDurationHours { get; set; } = 1;
} }

View File

@@ -5,6 +5,7 @@ using octo_fiesta.Services.Qobuz;
using octo_fiesta.Services.Local; using octo_fiesta.Services.Local;
using octo_fiesta.Services.Validation; using octo_fiesta.Services.Validation;
using octo_fiesta.Services.Subsonic; using octo_fiesta.Services.Subsonic;
using octo_fiesta.Services.Common;
using octo_fiesta.Middleware; using octo_fiesta.Middleware;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -64,6 +65,9 @@ builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
// Register orchestrator as hosted service // Register orchestrator as hosted service
builder.Services.AddHostedService<StartupValidationOrchestrator>(); builder.Services.AddHostedService<StartupValidationOrchestrator>();
// Register cache cleanup service (only runs when StorageMode is Cache)
builder.Services.AddHostedService<CacheCleanupService>();
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>

View File

@@ -23,6 +23,7 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly ILogger Logger; protected readonly ILogger Logger;
protected readonly string DownloadPath; protected readonly string DownloadPath;
protected readonly string CachePath;
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new(); protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
protected readonly SemaphoreSlim DownloadLock = new(1, 1); protected readonly SemaphoreSlim DownloadLock = new(1, 1);
@@ -46,11 +47,17 @@ public abstract class BaseDownloadService : IDownloadService
Logger = logger; Logger = logger;
DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
CachePath = Path.Combine(Path.GetTempPath(), "octo-fiesta-cache");
if (!Directory.Exists(DownloadPath)) if (!Directory.Exists(DownloadPath))
{ {
Directory.CreateDirectory(DownloadPath); Directory.CreateDirectory(DownloadPath);
} }
if (!Directory.Exists(CachePath))
{
Directory.CreateDirectory(CachePath);
}
} }
#region IDownloadService Implementation #region IDownloadService Implementation
@@ -62,7 +69,7 @@ public abstract class BaseDownloadService : IDownloadService
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) public async Task<Stream> 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); return IOFile.OpenRead(localPath);
} }
@@ -130,13 +137,29 @@ public abstract class BaseDownloadService : IDownloadService
} }
var songId = $"ext-{externalProvider}-{externalId}"; var songId = $"ext-{externalProvider}-{externalId}";
var isCache = SubsonicSettings.StorageMode == StorageMode.Cache;
// Check if already downloaded // Check if already downloaded (skip for cache mode as we want to check cache folder)
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); if (!isCache)
if (existingPath != null && IOFile.Exists(existingPath))
{ {
Logger.LogInformation("Song already downloaded: {Path}", existingPath); var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
return existingPath; 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 // Check if download in progress
@@ -185,31 +208,40 @@ public abstract class BaseDownloadService : IDownloadService
downloadInfo.CompletedAt = DateTime.UtcNow; downloadInfo.CompletedAt = DateTime.UtcNow;
song.LocalPath = localPath; song.LocalPath = localPath;
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
// Trigger a Subsonic library rescan (with debounce) // Only register and scan if NOT in cache mode
_ = Task.Run(async () => if (!isCache)
{ {
try await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
{
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 // Trigger a Subsonic library rescan (with debounce)
if (triggerAlbumDownload && SubsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId)) _ = Task.Run(async () =>
{
var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId);
if (!string.IsNullOrEmpty(albumExternalId))
{ {
Logger.LogInformation("Download mode is Album, triggering background download for album {AlbumId}", albumExternalId); try
DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId); {
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))
{
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);
}
} }
} }
else
{
Logger.LogInformation("Cache mode: skipping library registration and scan");
}
Logger.LogInformation("Download completed: {Path}", localPath); Logger.LogInformation("Download completed: {Path}", localPath);
return localPath; return localPath;
@@ -401,5 +433,31 @@ public abstract class BaseDownloadService : IDownloadService
} }
} }
/// <summary>
/// Gets the cached file path for a given provider and external ID
/// Returns null if no cached file exists
/// </summary>
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 #endregion
} }

View File

@@ -0,0 +1,163 @@
using Microsoft.Extensions.Options;
using octo_fiesta.Models.Settings;
namespace octo_fiesta.Services.Common;
/// <summary>
/// Background service that periodically cleans up old cached files
/// Only runs when StorageMode is set to Cache
/// </summary>
public class CacheCleanupService : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly SubsonicSettings _subsonicSettings;
private readonly ILogger<CacheCleanupService> _logger;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
public CacheCleanupService(
IConfiguration configuration,
IOptions<SubsonicSettings> subsonicSettings,
ILogger<CacheCleanupService> 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;
}
}

View File

@@ -109,7 +109,8 @@ public class DeezerDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; 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 // Create directories if they don't exist
var albumFolder = Path.GetDirectoryName(outputPath)!; var albumFolder = Path.GetDirectoryName(outputPath)!;

View File

@@ -108,7 +108,8 @@ public class QobuzDownloadService : BaseDownloadService
// Build organized folder structure using AlbumArtist (fallback to Artist for singles) // Build organized folder structure using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; 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)!; var albumFolder = Path.GetDirectoryName(outputPath)!;
EnsureDirectoryExists(albumFolder); EnsureDirectoryExists(albumFolder);

View File

@@ -9,7 +9,9 @@
"Subsonic": { "Subsonic": {
"Url": "http://localhost:4533", "Url": "http://localhost:4533",
"ExplicitFilter": "All", "ExplicitFilter": "All",
"DownloadMode": "Track" "DownloadMode": "Track",
"StorageMode": "Permanent",
"CacheDurationHours": 1
}, },
"Library": { "Library": {
"DownloadPath": "./downloads" "DownloadPath": "./downloads"