feat: add download mode option (Track/Album) for Deezer downloads

Closes #10
This commit is contained in:
V1ck3s
2026-01-06 22:50:30 +01:00
committed by Vickes
parent 3fd98ea3de
commit 5d03f86872
7 changed files with 191 additions and 2 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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<string, string?>
@@ -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<Song>
{
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);
}
}
/// <summary>

View File

@@ -1,5 +1,22 @@
namespace octo_fiesta.Models;
/// <summary>
/// Download mode for tracks
/// </summary>
public enum DownloadMode
{
/// <summary>
/// Download only the requested track (default behavior)
/// </summary>
Track,
/// <summary>
/// When a track is played, download the entire album in background
/// The requested track is downloaded first, then remaining tracks are queued
/// </summary>
Album
}
/// <summary>
/// Explicit content filter mode for Deezer tracks
/// </summary>
@@ -33,4 +50,11 @@ public class SubsonicSettings
/// Values: "All", "ExplicitOnly", "CleanOnly"
/// </summary>
public ExplicitFilter ExplicitFilter { get; set; } = ExplicitFilter.All;
/// <summary>
/// Download mode for tracks (default: Track)
/// Environment variable: DOWNLOAD_MODE
/// Values: "Track" (download only played track), "Album" (download full album when playing a track)
/// </summary>
public DownloadMode DownloadMode { get; set; } = DownloadMode.Track;
}

View File

@@ -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<DeezerDownloadService> _logger;
private readonly string _downloadPath;
@@ -63,12 +65,14 @@ public class DeezerDownloadService : IDownloadService
IConfiguration configuration,
ILocalLibraryService localLibraryService,
IMusicMetadataService metadataService,
IOptions<SubsonicSettings> subsonicSettings,
ILogger<DeezerDownloadService> 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<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
}
/// <summary>
/// Internal method for downloading a song with control over album download triggering
/// </summary>
/// <param name="triggerAlbumDownload">If true and DownloadMode is Album, triggers background download of remaining album tracks</param>
private async Task<string> 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
/// <summary>
/// Extracts the external album ID from the internal album ID format
/// Example: "ext-deezer-album-123456" -> "123456"
/// </summary>
private static string? ExtractExternalIdFromAlbumId(string albumId)
{
const string prefix = "ext-deezer-album-";
if (albumId.StartsWith(prefix))
{
return albumId[prefix.Length..];
}
return null;
}
/// <summary>
/// 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.

View File

@@ -25,6 +25,14 @@ public interface IDownloadService
/// <returns>A stream of the audio file</returns>
Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Downloads remaining tracks from an album in background (excluding the specified track)
/// </summary>
/// <param name="externalProvider">The provider (deezer, spotify)</param>
/// <param name="albumExternalId">The album ID on the external provider</param>
/// <param name="excludeTrackExternalId">The track ID to exclude (already downloaded)</param>
void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId);
/// <summary>
/// Checks if a song is currently being downloaded
/// </summary>

View File

@@ -8,7 +8,8 @@
"AllowedHosts": "*",
"Subsonic": {
"Url": "http://localhost:4533",
"ExplicitFilter": "All"
"ExplicitFilter": "All",
"DownloadMode": "Track"
},
"Library": {
"DownloadPath": "./downloads"