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.
///