mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: add cache-only storage mode for streaming without library persistence
This commit is contained in:
14
.env.example
14
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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,14 +137,30 @@ 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)
|
||||||
|
if (!isCache)
|
||||||
|
{
|
||||||
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||||
if (existingPath != null && IOFile.Exists(existingPath))
|
if (existingPath != null && IOFile.Exists(existingPath))
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||||
return 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
|
||||||
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||||
@@ -185,6 +208,10 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
downloadInfo.CompletedAt = DateTime.UtcNow;
|
downloadInfo.CompletedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
song.LocalPath = localPath;
|
song.LocalPath = localPath;
|
||||||
|
|
||||||
|
// Only register and scan if NOT in cache mode
|
||||||
|
if (!isCache)
|
||||||
|
{
|
||||||
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
||||||
|
|
||||||
// Trigger a Subsonic library rescan (with debounce)
|
// Trigger a Subsonic library rescan (with debounce)
|
||||||
@@ -210,6 +237,11 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId);
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
163
octo-fiesta/Services/Common/CacheCleanupService.cs
Normal file
163
octo-fiesta/Services/Common/CacheCleanupService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)!;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user