From f44d8652b4a7276d66d2f0daac4bdaaed6fd5554 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Wed, 4 Feb 2026 22:34:11 -0500 Subject: [PATCH] Improve favorite/unfavorite logic - copy from cache, avoid re-downloads --- allstarr/Controllers/JellyfinController.cs | 138 +++++++++++++++++---- 1 file changed, 117 insertions(+), 21 deletions(-) diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index b8a63fb..b102518 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -1455,11 +1455,25 @@ public class JellyfinController : ControllerBase _logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}", userId, itemId, Request.Path); - // External items can't be unfavorited (they're not really favorited in Jellyfin) - var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId); + // External items - remove from kept folder if it exists + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId)) { - _logger.LogInformation("Unfavoriting external item {ItemId} - returning success", itemId); + _logger.LogInformation("Unfavoriting external item {ItemId} - removing from kept folder", itemId); + + // Remove from kept folder in background + _ = Task.Run(async () => + { + try + { + await RemoveExternalTrackFromKeptAsync(itemId, provider!, externalId!); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove external track {ItemId} from kept folder", itemId); + } + }); + return Ok(new { IsFavorite = false, @@ -3446,7 +3460,7 @@ public class JellyfinController : ControllerBase { try { - // Get the song metadata first to check if already in kept folder + // Get the song metadata first to build paths var song = await _metadataService.GetSongAsync(provider, externalId); if (song == null) { @@ -3459,12 +3473,11 @@ public class JellyfinController : ControllerBase var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist)); var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album)); - // Check if track already exists in kept folder BEFORE downloading - // Look for any file matching the song title pattern (any extension) + // Check if track already exists in kept folder if (Directory.Exists(keptAlbumPath)) { var sanitizedTitle = PathHelper.SanitizeFileName(song.Title); - var existingFiles = Directory.GetFiles(keptAlbumPath, $"{sanitizedTitle}.*"); + var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*"); if (existingFiles.Length > 0) { _logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]); @@ -3472,25 +3485,44 @@ public class JellyfinController : ControllerBase } } - // Track not in kept folder - download it - _logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId); - string downloadPath; + // Look for the track in cache folder first + var cacheBasePath = "/tmp/allstarr-cache"; + var cacheArtistPath = Path.Combine(cacheBasePath, PathHelper.SanitizeFileName(song.Artist)); + var cacheAlbumPath = Path.Combine(cacheArtistPath, PathHelper.SanitizeFileName(song.Album)); - try + string? sourceFilePath = null; + + if (Directory.Exists(cacheAlbumPath)) { - downloadPath = await _downloadService.DownloadSongAsync(provider, externalId); + var sanitizedTitle = PathHelper.SanitizeFileName(song.Title); + var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*"); + if (cacheFiles.Length > 0) + { + sourceFilePath = cacheFiles[0]; + _logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath); + } } - catch (Exception ex) + + // If not in cache, download it first + if (sourceFilePath == null) { - _logger.LogWarning(ex, "Failed to download track {ItemId}", itemId); - return; + _logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId); + try + { + sourceFilePath = await _downloadService.DownloadSongAsync(provider, externalId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to download track {ItemId}", itemId); + return; + } } // Create the kept folder structure Directory.CreateDirectory(keptAlbumPath); // Copy file to kept folder - var fileName = Path.GetFileName(downloadPath); + var fileName = Path.GetFileName(sourceFilePath); var keptFilePath = Path.Combine(keptAlbumPath, fileName); // Double-check in case of race condition (multiple favorite clicks) @@ -3500,17 +3532,17 @@ public class JellyfinController : ControllerBase return; } - System.IO.File.Copy(downloadPath, keptFilePath, overwrite: false); - _logger.LogInformation("✓ Copied favorited track to kept folder: {Path}", keptFilePath); + System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false); + _logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath); // Also copy cover art if it exists - var coverPath = Path.Combine(Path.GetDirectoryName(downloadPath)!, "cover.jpg"); - if (System.IO.File.Exists(coverPath)) + var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg"); + if (System.IO.File.Exists(sourceCoverPath)) { var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg"); if (!System.IO.File.Exists(keptCoverPath)) { - System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false); + System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false); _logger.LogDebug("Copied cover art to kept folder"); } } @@ -3521,6 +3553,70 @@ public class JellyfinController : ControllerBase } } + /// + /// Removes an external track from the kept folder when unfavorited. + /// + private async Task RemoveExternalTrackFromKeptAsync(string itemId, string provider, string externalId) + { + try + { + // Get the song metadata to build paths + var song = await _metadataService.GetSongAsync(provider, externalId); + if (song == null) + { + _logger.LogWarning("Could not find song metadata for {ItemId}", itemId); + return; + } + + // Build kept folder path: /app/kept/Artist/Album/ + 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; + } + + // 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); + } + + // 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); + } + } + /// /// Loads missing tracks from file cache as fallback when Redis is empty. ///