mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: add download mode option (Track/Album) for Deezer downloads
Closes #10
This commit is contained in:
@@ -20,3 +20,9 @@ DEEZER_QUALITY=
|
|||||||
# - ExplicitOnly: Exclude clean/edited versions, keep original explicit content
|
# - ExplicitOnly: Exclude clean/edited versions, keep original explicit content
|
||||||
# - CleanOnly: Only show clean content (naturally clean or edited versions)
|
# - CleanOnly: Only show clean content (naturally clean or edited versions)
|
||||||
EXPLICIT_FILTER=All
|
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
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ services:
|
|||||||
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
|
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
|
||||||
# Explicit content filter: All, ExplicitOnly, CleanOnly (default: ExplicitOnly)
|
# Explicit content filter: All, ExplicitOnly, CleanOnly (default: ExplicitOnly)
|
||||||
- Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-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
|
# Download path inside container
|
||||||
- Library__DownloadPath=/app/downloads
|
- Library__DownloadPath=/app/downloads
|
||||||
# Deezer ARL token (required)
|
# Deezer ARL token (required)
|
||||||
|
|||||||
@@ -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()
|
var config = new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
@@ -64,11 +64,17 @@ public class DeezerDownloadServiceTests : IDisposable
|
|||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
var subsonicSettings = Options.Create(new SubsonicSettings
|
||||||
|
{
|
||||||
|
DownloadMode = downloadMode
|
||||||
|
});
|
||||||
|
|
||||||
return new DeezerDownloadService(
|
return new DeezerDownloadService(
|
||||||
_httpClientFactoryMock.Object,
|
_httpClientFactoryMock.Object,
|
||||||
config,
|
config,
|
||||||
_localLibraryServiceMock.Object,
|
_localLibraryServiceMock.Object,
|
||||||
_metadataServiceMock.Object,
|
_metadataServiceMock.Object,
|
||||||
|
subsonicSettings,
|
||||||
_loggerMock.Object);
|
_loggerMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +168,42 @@ public class DeezerDownloadServiceTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal("Song not found", exception.Message);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
namespace octo_fiesta.Models;
|
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>
|
/// <summary>
|
||||||
/// Explicit content filter mode for Deezer tracks
|
/// Explicit content filter mode for Deezer tracks
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -33,4 +50,11 @@ public class SubsonicSettings
|
|||||||
/// Values: "All", "ExplicitOnly", "CleanOnly"
|
/// Values: "All", "ExplicitOnly", "CleanOnly"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ExplicitFilter ExplicitFilter { get; set; } = ExplicitFilter.All;
|
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;
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ using Org.BouncyCastle.Crypto.Engines;
|
|||||||
using Org.BouncyCastle.Crypto.Modes;
|
using Org.BouncyCastle.Crypto.Modes;
|
||||||
using Org.BouncyCastle.Crypto.Parameters;
|
using Org.BouncyCastle.Crypto.Parameters;
|
||||||
using octo_fiesta.Models;
|
using octo_fiesta.Models;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using TagLib;
|
using TagLib;
|
||||||
using IOFile = System.IO.File;
|
using IOFile = System.IO.File;
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
private readonly ILogger<DeezerDownloadService> _logger;
|
private readonly ILogger<DeezerDownloadService> _logger;
|
||||||
|
|
||||||
private readonly string _downloadPath;
|
private readonly string _downloadPath;
|
||||||
@@ -63,12 +65,14 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILocalLibraryService localLibraryService,
|
ILocalLibraryService localLibraryService,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
ILogger<DeezerDownloadService> logger)
|
ILogger<DeezerDownloadService> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
_downloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
|
_downloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
|
||||||
@@ -85,6 +89,15 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
#region IDownloadService Implementation
|
#region IDownloadService Implementation
|
||||||
|
|
||||||
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
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")
|
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);
|
_logger.LogInformation("Download completed: {Path}", localPath);
|
||||||
return 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
|
#endregion
|
||||||
|
|
||||||
#region Deezer API Methods
|
#region Deezer API Methods
|
||||||
@@ -677,6 +769,20 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
|
|
||||||
#region Utility Methods
|
#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>
|
/// <summary>
|
||||||
/// Builds the list of formats to request from Deezer based on preferred quality.
|
/// 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.
|
/// If a specific quality is preferred, only request that quality and lower.
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ public interface IDownloadService
|
|||||||
/// <returns>A stream of the audio file</returns>
|
/// <returns>A stream of the audio file</returns>
|
||||||
Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
|
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>
|
/// <summary>
|
||||||
/// Checks if a song is currently being downloaded
|
/// Checks if a song is currently being downloaded
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Subsonic": {
|
"Subsonic": {
|
||||||
"Url": "http://localhost:4533",
|
"Url": "http://localhost:4533",
|
||||||
"ExplicitFilter": "All"
|
"ExplicitFilter": "All",
|
||||||
|
"DownloadMode": "Track"
|
||||||
},
|
},
|
||||||
"Library": {
|
"Library": {
|
||||||
"DownloadPath": "./downloads"
|
"DownloadPath": "./downloads"
|
||||||
|
|||||||
Reference in New Issue
Block a user