diff --git a/.env.example b/.env.example
index f1292e9..5824c1d 100644
--- a/.env.example
+++ b/.env.example
@@ -30,6 +30,12 @@ MUSIC_SERVICE=SquidWTF
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
DOWNLOAD_PATH=./downloads
+# Path where favorited external tracks are permanently kept
+KEPT_PATH=./kept
+
+# Path for cache files (Spotify missing tracks, etc.)
+CACHE_PATH=./cache
+
# ===== SQUIDWTF CONFIGURATION =====
# Different quality options for SquidWTF. Only FLAC supported right now
SQUIDWTF_QUALITY=FLAC
diff --git a/README.md b/README.md
index b3432b7..08b19d3 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,7 @@ This project brings together all the music streaming providers into one unified
- **Transparent Proxy**: Sits between your music clients and media server
- **Automatic Search**: Searches streaming providers when songs aren't local
- **On-the-Fly Downloads**: Songs download and cache for future use
+- **Favorite to Keep**: When you favorite an external track, it's automatically copied to a permanent `/kept` folder separate from the cache
- **External Playlist Support**: Search and download playlists from Deezer, Qobuz, and SquidWTF with M3U generation
- **Hi-Res Audio**: SquidWTF supports up to 24-bit/192kHz FLAC
- **Full Metadata**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and cover art
diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs
index faba802..5b339c5 100644
--- a/allstarr/Controllers/JellyfinController.cs
+++ b/allstarr/Controllers/JellyfinController.cs
@@ -1118,12 +1118,24 @@ public class JellyfinController : ControllerBase
}
// Check if this is an external song/album
- var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
+ var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
- // External items don't exist in Jellyfin, so we can't favorite them there
- // Just return success - the client will show it as favorited
- _logger.LogDebug("Favoriting external item {ItemId} (not synced to Jellyfin)", itemId);
+ _logger.LogInformation("Favoriting external item {ItemId}, copying to kept folder", itemId);
+
+ // Copy the track to kept folder in background
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId);
+ }
+ });
+
return Ok(new { IsFavorite = true });
}
@@ -2083,6 +2095,76 @@ public class JellyfinController : ControllerBase
}
}
+ ///
+ /// Copies an external track to the kept folder when favorited.
+ ///
+ private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId)
+ {
+ try
+ {
+ // Get the song metadata
+ var song = await _metadataService.GetSongAsync(provider, externalId);
+ if (song == null)
+ {
+ _logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
+ return;
+ }
+
+ // Check if file exists in downloads folder
+ var downloadPath = PathHelper.GetSongPath(song.Artist, song.Album, song.Title);
+ if (!System.IO.File.Exists(downloadPath))
+ {
+ _logger.LogInformation("Track not yet downloaded, triggering download for {ItemId}", itemId);
+
+ // Download the track first
+ var downloadResult = await _downloadService.DownloadSongAsync(provider, externalId);
+ if (!downloadResult.IsSuccess)
+ {
+ _logger.LogWarning("Failed to download track {ItemId}: {Error}", itemId, downloadResult.Error);
+ return;
+ }
+
+ downloadPath = downloadResult.Value!.FilePath;
+ }
+
+ // Create kept folder structure: /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));
+
+ Directory.CreateDirectory(keptAlbumPath);
+
+ // Copy file to kept folder
+ var fileName = Path.GetFileName(downloadPath);
+ var keptFilePath = Path.Combine(keptAlbumPath, fileName);
+
+ if (System.IO.File.Exists(keptFilePath))
+ {
+ _logger.LogInformation("Track already exists in kept folder: {Path}", keptFilePath);
+ return;
+ }
+
+ System.IO.File.Copy(downloadPath, keptFilePath, overwrite: false);
+ _logger.LogInformation("✓ Copied favorited 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 keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
+ if (!System.IO.File.Exists(keptCoverPath))
+ {
+ System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false);
+ _logger.LogDebug("Copied cover art to kept folder");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId);
+ }
+ }
+
///
/// Loads missing tracks from file cache as fallback when Redis is empty.
///
diff --git a/docker-compose.yml b/docker-compose.yml
index 73f572e..ec79566 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -93,6 +93,8 @@ services:
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
volumes:
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
+ - ${KEPT_PATH:-./kept}:/app/kept
+ - ${CACHE_PATH:-./cache}:/app/cache
networks:
allstarr-network: