diff --git a/.env.example b/.env.example index 5eb8312..b5411e4 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,9 @@ DEEZER_QUALITY= # - ExplicitOnly: Exclude clean/edited versions, keep original explicit content # - CleanOnly: Only show clean content (naturally clean or edited versions) EXPLICIT_FILTER=All + +# Download mode (optional, default: Track) +# - Track: Download only the played track +# - Album: When playing a track, download the entire album in background +# The played track is downloaded first, remaining tracks are queued +DOWNLOAD_MODE=Track diff --git a/docker-compose.yml b/docker-compose.yml index 2750629..b063edf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: - Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533} # Explicit content filter: All, ExplicitOnly, CleanOnly (default: ExplicitOnly) - Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly} + # Download mode: Track (only requested track), Album (full album when playing a track) + - Subsonic__DownloadMode=${DOWNLOAD_MODE:-Track} # Download path inside container - Library__DownloadPath=/app/downloads # Deezer ARL token (required) diff --git a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs index 03a3b27..9cdc687 100644 --- a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs +++ b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs @@ -53,7 +53,7 @@ public class DeezerDownloadServiceTests : IDisposable } } - private DeezerDownloadService CreateService(string? arl = null) + private DeezerDownloadService CreateService(string? arl = null, DownloadMode downloadMode = DownloadMode.Track) { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -64,11 +64,17 @@ public class DeezerDownloadServiceTests : IDisposable }) .Build(); + var subsonicSettings = Options.Create(new SubsonicSettings + { + DownloadMode = downloadMode + }); + return new DeezerDownloadService( _httpClientFactoryMock.Object, config, _localLibraryServiceMock.Object, _metadataServiceMock.Object, + subsonicSettings, _loggerMock.Object); } @@ -162,6 +168,42 @@ public class DeezerDownloadServiceTests : IDisposable Assert.Equal("Song not found", exception.Message); } + + [Fact] + public void DownloadRemainingAlbumTracksInBackground_WithUnsupportedProvider_DoesNotThrow() + { + // Arrange + var service = CreateService(arl: "test-arl", downloadMode: DownloadMode.Album); + + // Act & Assert - Should not throw, just log warning + service.DownloadRemainingAlbumTracksInBackground("spotify", "123456", "789"); + } + + [Fact] + public void DownloadRemainingAlbumTracksInBackground_WithDeezerProvider_StartsBackgroundTask() + { + // Arrange + _metadataServiceMock + .Setup(s => s.GetAlbumAsync("deezer", "123456")) + .ReturnsAsync(new Album + { + Id = "ext-deezer-album-123456", + Title = "Test Album", + Songs = new List + { + new Song { ExternalId = "111", Title = "Track 1" }, + new Song { ExternalId = "222", Title = "Track 2" } + } + }); + + var service = CreateService(arl: "test-arl", downloadMode: DownloadMode.Album); + + // Act - Should not throw (fire-and-forget) + service.DownloadRemainingAlbumTracksInBackground("deezer", "123456", "111"); + + // Assert - Just verify it doesn't throw, actual download is async + Assert.True(true); + } } /// diff --git a/octo-fiesta/Models/SubsonicSettings.cs b/octo-fiesta/Models/SubsonicSettings.cs index 8e3e495..66eea3f 100644 --- a/octo-fiesta/Models/SubsonicSettings.cs +++ b/octo-fiesta/Models/SubsonicSettings.cs @@ -1,5 +1,22 @@ namespace octo_fiesta.Models; +/// +/// Download mode for tracks +/// +public enum DownloadMode +{ + /// + /// Download only the requested track (default behavior) + /// + Track, + + /// + /// When a track is played, download the entire album in background + /// The requested track is downloaded first, then remaining tracks are queued + /// + Album +} + /// /// Explicit content filter mode for Deezer tracks /// @@ -33,4 +50,11 @@ public class SubsonicSettings /// Values: "All", "ExplicitOnly", "CleanOnly" /// public ExplicitFilter ExplicitFilter { get; set; } = ExplicitFilter.All; + + /// + /// Download mode for tracks (default: Track) + /// Environment variable: DOWNLOAD_MODE + /// Values: "Track" (download only played track), "Album" (download full album when playing a track) + /// + public DownloadMode DownloadMode { get; set; } = DownloadMode.Track; } \ No newline at end of file diff --git a/octo-fiesta/Services/DeezerDownloadService.cs b/octo-fiesta/Services/DeezerDownloadService.cs index efdcce8..a40923b 100644 --- a/octo-fiesta/Services/DeezerDownloadService.cs +++ b/octo-fiesta/Services/DeezerDownloadService.cs @@ -5,6 +5,7 @@ using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Parameters; using octo_fiesta.Models; +using Microsoft.Extensions.Options; using TagLib; using IOFile = System.IO.File; @@ -35,6 +36,7 @@ public class DeezerDownloadService : IDownloadService private readonly IConfiguration _configuration; private readonly ILocalLibraryService _localLibraryService; private readonly IMusicMetadataService _metadataService; + private readonly SubsonicSettings _subsonicSettings; private readonly ILogger _logger; private readonly string _downloadPath; @@ -63,12 +65,14 @@ public class DeezerDownloadService : IDownloadService IConfiguration configuration, ILocalLibraryService localLibraryService, IMusicMetadataService metadataService, + IOptions subsonicSettings, ILogger logger) { _httpClient = httpClientFactory.CreateClient(); _configuration = configuration; _localLibraryService = localLibraryService; _metadataService = metadataService; + _subsonicSettings = subsonicSettings.Value; _logger = logger; _downloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; @@ -85,6 +89,15 @@ public class DeezerDownloadService : IDownloadService #region IDownloadService Implementation public async Task DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) + { + return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); + } + + /// + /// Internal method for downloading a song with control over album download triggering + /// + /// If true and DownloadMode is Album, triggers background download of remaining album tracks + private async Task DownloadSongInternalAsync(string externalProvider, string externalId, bool triggerAlbumDownload, CancellationToken cancellationToken = default) { if (externalProvider != "deezer") { @@ -163,6 +176,18 @@ public class DeezerDownloadService : IDownloadService } }); + // If download mode is Album and triggering is enabled, start background download of remaining tracks + if (triggerAlbumDownload && _subsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId)) + { + // Extract album external ID from AlbumId (format: "ext-deezer-album-{id}") + var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId); + if (!string.IsNullOrEmpty(albumExternalId)) + { + _logger.LogInformation("Download mode is Album, triggering background download for album {AlbumId}", albumExternalId); + DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId); + } + } + _logger.LogInformation("Download completed: {Path}", localPath); return localPath; } @@ -212,6 +237,73 @@ public class DeezerDownloadService : IDownloadService } } + public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId) + { + if (externalProvider != "deezer") + { + _logger.LogWarning("Provider '{Provider}' is not supported for album download", externalProvider); + return; + } + + // Fire-and-forget with error handling + _ = Task.Run(async () => + { + try + { + await DownloadRemainingAlbumTracksAsync(albumExternalId, excludeTrackExternalId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to download remaining album tracks for album {AlbumId}", albumExternalId); + } + }); + } + + private async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId) + { + _logger.LogInformation("Starting background download for album {AlbumId} (excluding track {TrackId})", + albumExternalId, excludeTrackExternalId); + + // Get album with tracks + var album = await _metadataService.GetAlbumAsync("deezer", albumExternalId); + if (album == null) + { + _logger.LogWarning("Album {AlbumId} not found, cannot download remaining tracks", albumExternalId); + return; + } + + var tracksToDownload = album.Songs + .Where(s => s.ExternalId != excludeTrackExternalId && !string.IsNullOrEmpty(s.ExternalId)) + .ToList(); + + _logger.LogInformation("Found {Count} additional tracks to download for album '{AlbumTitle}'", + tracksToDownload.Count, album.Title); + + foreach (var track in tracksToDownload) + { + try + { + // Check if already downloaded + var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync("deezer", track.ExternalId!); + if (existingPath != null && IOFile.Exists(existingPath)) + { + _logger.LogDebug("Track {TrackId} already downloaded, skipping", track.ExternalId); + continue; + } + + _logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title); + await DownloadSongInternalAsync("deezer", track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to download track {TrackId} '{Title}'", track.ExternalId, track.Title); + // Continue with other tracks + } + } + + _logger.LogInformation("Completed background download for album '{AlbumTitle}'", album.Title); + } + #endregion #region Deezer API Methods @@ -677,6 +769,20 @@ public class DeezerDownloadService : IDownloadService #region Utility Methods + /// + /// Extracts the external album ID from the internal album ID format + /// Example: "ext-deezer-album-123456" -> "123456" + /// + private static string? ExtractExternalIdFromAlbumId(string albumId) + { + const string prefix = "ext-deezer-album-"; + if (albumId.StartsWith(prefix)) + { + return albumId[prefix.Length..]; + } + return null; + } + /// /// Builds the list of formats to request from Deezer based on preferred quality. /// If a specific quality is preferred, only request that quality and lower. diff --git a/octo-fiesta/Services/IDownloadService.cs b/octo-fiesta/Services/IDownloadService.cs index e1095ff..10de0f5 100644 --- a/octo-fiesta/Services/IDownloadService.cs +++ b/octo-fiesta/Services/IDownloadService.cs @@ -25,6 +25,14 @@ public interface IDownloadService /// A stream of the audio file Task DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); + /// + /// Downloads remaining tracks from an album in background (excluding the specified track) + /// + /// The provider (deezer, spotify) + /// The album ID on the external provider + /// The track ID to exclude (already downloaded) + void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId); + /// /// Checks if a song is currently being downloaded /// diff --git a/octo-fiesta/appsettings.json b/octo-fiesta/appsettings.json index 8b92d28..15b05ce 100644 --- a/octo-fiesta/appsettings.json +++ b/octo-fiesta/appsettings.json @@ -8,7 +8,8 @@ "AllowedHosts": "*", "Subsonic": { "Url": "http://localhost:4533", - "ExplicitFilter": "All" + "ExplicitFilter": "All", + "DownloadMode": "Track" }, "Library": { "DownloadPath": "./downloads"