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: