Complete mark-for-deletion system and memory optimization

This commit is contained in:
2026-02-04 22:41:08 -05:00
parent f44d8652b4
commit 7db66067f4
4 changed files with 439 additions and 23 deletions

View File

@@ -3460,6 +3460,13 @@ public class JellyfinController : ControllerBase
{
try
{
// Check if already favorited (persistent tracking)
if (await IsTrackFavoritedAsync(itemId))
{
_logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId);
return;
}
// Get the song metadata first to build paths
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null)
@@ -3481,6 +3488,8 @@ public class JellyfinController : ControllerBase
if (existingFiles.Length > 0)
{
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
// Mark as favorited even if we didn't download it
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
}
@@ -3529,6 +3538,7 @@ public class JellyfinController : ControllerBase
if (System.IO.File.Exists(keptFilePath))
{
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
@@ -3546,6 +3556,9 @@ public class JellyfinController : ControllerBase
_logger.LogDebug("Copied cover art to kept folder");
}
}
// Mark as favorited in persistent storage
await MarkTrackAsFavoritedAsync(itemId, song);
}
catch (Exception ex)
{
@@ -3560,63 +3573,241 @@ public class JellyfinController : ControllerBase
{
try
{
// Get the song metadata to build paths
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null)
// Mark for deletion instead of immediate deletion
await MarkTrackForDeletionAsync(itemId);
_logger.LogInformation("✓ Marked track for deletion: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking external track {ItemId} for deletion", itemId);
}
}
#region Persistent Favorites Tracking
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
/// <summary>
/// Checks if a track is already favorited (persistent across restarts).
/// </summary>
private async Task<bool> IsTrackFavoritedAsync(string itemId)
{
try
{
if (!System.IO.File.Exists(_favoritesFilePath))
return false;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
return favorites.ContainsKey(itemId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId);
return false;
}
}
/// <summary>
/// Marks a track as favorited in persistent storage.
/// </summary>
private async Task MarkTrackAsFavoritedAsync(string itemId, Song song)
{
try
{
var favorites = new Dictionary<string, FavoriteTrackInfo>();
if (System.IO.File.Exists(_favoritesFilePath))
{
_logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
return;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
}
// Build kept folder path: /app/kept/Artist/Album/
favorites[itemId] = new FavoriteTrackInfo
{
ItemId = itemId,
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
FavoritedAt = DateTime.UtcNow
};
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark track as favorited: {ItemId}", itemId);
}
}
/// <summary>
/// Removes a track from persistent favorites storage.
/// </summary>
private async Task UnmarkTrackAsFavoritedAsync(string itemId)
{
try
{
if (!System.IO.File.Exists(_favoritesFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
if (favorites.Remove(itemId))
{
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove track from favorites: {ItemId}", itemId);
}
}
/// <summary>
/// Marks a track for deletion (delayed deletion for safety).
/// </summary>
private async Task MarkTrackForDeletionAsync(string itemId)
{
try
{
var deletionFilePath = "/app/cache/pending_deletions.json";
var pendingDeletions = new Dictionary<string, DateTime>();
if (System.IO.File.Exists(deletionFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
}
// Mark for deletion 24 hours from now
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
var updatedJson = JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
// Also remove from favorites immediately
await UnmarkTrackAsFavoritedAsync(itemId);
_logger.LogDebug("Marked track for deletion in 24 hours: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark track for deletion: {ItemId}", itemId);
}
}
/// <summary>
/// Information about a favorited track for persistent storage.
/// </summary>
private class FavoriteTrackInfo
{
public string ItemId { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Album { get; set; } = "";
public DateTime FavoritedAt { get; set; }
}
/// <summary>
/// Processes pending deletions (called by cleanup service).
/// </summary>
public async Task ProcessPendingDeletionsAsync()
{
try
{
var deletionFilePath = "/app/cache/pending_deletions.json";
if (!System.IO.File.Exists(deletionFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
var now = DateTime.UtcNow;
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
var remaining = pendingDeletions.Where(kvp => kvp.Value > now).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
foreach (var (itemId, _) in toDelete)
{
await ActuallyDeleteTrackAsync(itemId);
}
if (toDelete.Count > 0)
{
// Update pending deletions file
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
_logger.LogInformation("Processed {Count} pending deletions", toDelete.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending deletions");
}
}
/// <summary>
/// Actually deletes a track from the kept folder.
/// </summary>
private async Task ActuallyDeleteTrackAsync(string itemId)
{
try
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal) return;
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null) return;
var keptBasePath = "/app/kept";
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
if (!Directory.Exists(keptAlbumPath))
{
_logger.LogInformation("Track not in kept folder (album folder doesn't exist): {ItemId}", itemId);
return;
}
if (!Directory.Exists(keptAlbumPath)) return;
// Find and remove the track file
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
if (trackFiles.Length == 0)
{
_logger.LogInformation("Track not found in kept folder: {ItemId}", itemId);
return;
}
foreach (var trackFile in trackFiles)
{
System.IO.File.Delete(trackFile);
_logger.LogInformation("✓ Removed track from kept folder: {Path}", trackFile);
_logger.LogInformation("✓ Deleted track from kept folder: {Path}", trackFile);
}
// Clean up empty directories
if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0)
{
Directory.Delete(keptAlbumPath);
_logger.LogDebug("Removed empty album folder: {Path}", keptAlbumPath);
// Also remove artist folder if empty
if (Directory.Exists(keptArtistPath) &&
Directory.GetFiles(keptArtistPath).Length == 0 &&
Directory.GetDirectories(keptArtistPath).Length == 0)
{
Directory.Delete(keptArtistPath);
_logger.LogDebug("Removed empty artist folder: {Path}", keptArtistPath);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing external track {ItemId} from kept folder", itemId);
_logger.LogWarning(ex, "Failed to delete track {ItemId}", itemId);
}
}
#endregion
/// <summary>
/// Loads missing tracks from file cache as fallback when Redis is empty.
/// </summary>

View File

@@ -388,6 +388,9 @@ if (backendType == BackendType.Jellyfin)
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>();
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
// Register JellyfinController as a service for dependency injection
builder.Services.AddScoped<allstarr.Controllers.JellyfinController>();
}
else
{

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Controllers;
namespace allstarr.Services.Common;
@@ -11,16 +12,19 @@ public class CacheCleanupService : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly SubsonicSettings _subsonicSettings;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<CacheCleanupService> _logger;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
public CacheCleanupService(
IConfiguration configuration,
IOptions<SubsonicSettings> subsonicSettings,
IServiceProvider serviceProvider,
ILogger<CacheCleanupService> logger)
{
_configuration = configuration;
_subsonicSettings = subsonicSettings.Value;
_serviceProvider = serviceProvider;
_logger = logger;
}
@@ -41,6 +45,7 @@ public class CacheCleanupService : BackgroundService
try
{
await CleanupOldCachedFilesAsync(stoppingToken);
await ProcessPendingDeletionsAsync(stoppingToken);
await Task.Delay(_cleanupInterval, stoppingToken);
}
catch (OperationCanceledException)
@@ -160,4 +165,30 @@ public class CacheCleanupService : BackgroundService
await Task.CompletedTask;
}
/// <summary>
/// Processes pending track deletions from the kept folder.
/// </summary>
private async Task ProcessPendingDeletionsAsync(CancellationToken cancellationToken)
{
try
{
// Create a scope to get the JellyfinController
using var scope = _serviceProvider.CreateScope();
var jellyfinController = scope.ServiceProvider.GetService<JellyfinController>();
if (jellyfinController != null)
{
await jellyfinController.ProcessPendingDeletionsAsync();
}
else
{
_logger.LogWarning("Could not resolve JellyfinController for pending deletions processing");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending deletions");
}
}
}