fix: resolve circular dependency and fix failing tests

This commit is contained in:
V1ck3s
2026-01-15 23:10:04 +01:00
committed by Vickes
parent ebe6e90f39
commit e8e385b770
9 changed files with 105 additions and 45 deletions

View File

@@ -24,7 +24,6 @@ public class DeezerDownloadServiceTests : IDisposable
private readonly Mock<ILocalLibraryService> _localLibraryServiceMock; private readonly Mock<ILocalLibraryService> _localLibraryServiceMock;
private readonly Mock<IMusicMetadataService> _metadataServiceMock; private readonly Mock<IMusicMetadataService> _metadataServiceMock;
private readonly Mock<ILogger<DeezerDownloadService>> _loggerMock; private readonly Mock<ILogger<DeezerDownloadService>> _loggerMock;
private readonly Mock<IServiceProvider> _serviceProviderMock;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly string _testDownloadPath; private readonly string _testDownloadPath;
@@ -42,7 +41,6 @@ public class DeezerDownloadServiceTests : IDisposable
_localLibraryServiceMock = new Mock<ILocalLibraryService>(); _localLibraryServiceMock = new Mock<ILocalLibraryService>();
_metadataServiceMock = new Mock<IMusicMetadataService>(); _metadataServiceMock = new Mock<IMusicMetadataService>();
_loggerMock = new Mock<ILogger<DeezerDownloadService>>(); _loggerMock = new Mock<ILogger<DeezerDownloadService>>();
_serviceProviderMock = new Mock<IServiceProvider>();
_configuration = new ConfigurationBuilder() _configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> .AddInMemoryCollection(new Dictionary<string, string?>
@@ -85,6 +83,10 @@ public class DeezerDownloadServiceTests : IDisposable
Quality = null Quality = null
}); });
var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock.Setup(sp => sp.GetService(typeof(octo_fiesta.Services.Subsonic.PlaylistSyncService)))
.Returns(null);
return new DeezerDownloadService( return new DeezerDownloadService(
_httpClientFactoryMock.Object, _httpClientFactoryMock.Object,
config, config,
@@ -92,7 +94,7 @@ public class DeezerDownloadServiceTests : IDisposable
_metadataServiceMock.Object, _metadataServiceMock.Object,
subsonicSettings, subsonicSettings,
deezerSettings, deezerSettings,
_serviceProviderMock.Object, serviceProviderMock.Object,
_loggerMock.Object); _loggerMock.Object);
} }

View File

@@ -767,7 +767,7 @@ public class DeezerMetadataServiceTests
Assert.Equal(2, result.Count); Assert.Equal(2, result.Count);
Assert.Equal("Track 1", result[0].Title); Assert.Equal("Track 1", result[0].Title);
Assert.Equal("Artist A", result[0].Artist); Assert.Equal("Artist A", result[0].Artist);
Assert.Equal("ext-deezer-111", result[0].Id); Assert.Equal("ext-deezer-song-111", result[0].Id);
} }
[Fact] [Fact]

View File

@@ -22,7 +22,6 @@ public class QobuzDownloadServiceTests : IDisposable
private readonly Mock<IMusicMetadataService> _metadataServiceMock; private readonly Mock<IMusicMetadataService> _metadataServiceMock;
private readonly Mock<ILogger<QobuzBundleService>> _bundleServiceLoggerMock; private readonly Mock<ILogger<QobuzBundleService>> _bundleServiceLoggerMock;
private readonly Mock<ILogger<QobuzDownloadService>> _loggerMock; private readonly Mock<ILogger<QobuzDownloadService>> _loggerMock;
private readonly Mock<IServiceProvider> _serviceProviderMock;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly string _testDownloadPath; private readonly string _testDownloadPath;
private QobuzBundleService _bundleService; private QobuzBundleService _bundleService;
@@ -42,7 +41,6 @@ public class QobuzDownloadServiceTests : IDisposable
_metadataServiceMock = new Mock<IMusicMetadataService>(); _metadataServiceMock = new Mock<IMusicMetadataService>();
_bundleServiceLoggerMock = new Mock<ILogger<QobuzBundleService>>(); _bundleServiceLoggerMock = new Mock<ILogger<QobuzBundleService>>();
_loggerMock = new Mock<ILogger<QobuzDownloadService>>(); _loggerMock = new Mock<ILogger<QobuzDownloadService>>();
_serviceProviderMock = new Mock<IServiceProvider>();
// Create a real QobuzBundleService for testing (it will use the mocked HttpClient) // Create a real QobuzBundleService for testing (it will use the mocked HttpClient)
_bundleService = new QobuzBundleService(_httpClientFactoryMock.Object, _bundleServiceLoggerMock.Object); _bundleService = new QobuzBundleService(_httpClientFactoryMock.Object, _bundleServiceLoggerMock.Object);
@@ -88,6 +86,10 @@ public class QobuzDownloadServiceTests : IDisposable
Quality = quality Quality = quality
}); });
var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock.Setup(sp => sp.GetService(typeof(octo_fiesta.Services.Subsonic.PlaylistSyncService)))
.Returns(null);
return new QobuzDownloadService( return new QobuzDownloadService(
_httpClientFactoryMock.Object, _httpClientFactoryMock.Object,
config, config,
@@ -96,7 +98,7 @@ public class QobuzDownloadServiceTests : IDisposable
_bundleService, _bundleService,
subsonicSettings, subsonicSettings,
qobuzSettings, qobuzSettings,
_serviceProviderMock.Object, serviceProviderMock.Object,
_loggerMock.Object); _loggerMock.Object);
} }

View File

@@ -26,11 +26,14 @@ public class QobuzMetadataServiceTests
_httpClientFactoryMock = new Mock<IHttpClientFactory>(); _httpClientFactoryMock = new Mock<IHttpClientFactory>();
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient); _httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var httpClientFactory = Mock.Of<IHttpClientFactory>(); // Mock QobuzBundleService (methods are now virtual so can be mocked)
var bundleHttpClientFactoryMock = new Mock<IHttpClientFactory>();
bundleHttpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var bundleLogger = Mock.Of<ILogger<QobuzBundleService>>(); var bundleLogger = Mock.Of<ILogger<QobuzBundleService>>();
_bundleServiceMock = new Mock<QobuzBundleService>(bundleHttpClientFactoryMock.Object, bundleLogger) { CallBase = false };
_bundleServiceMock = new Mock<QobuzBundleService>(httpClientFactory, bundleLogger);
_bundleServiceMock.Setup(b => b.GetAppIdAsync()).ReturnsAsync("fake-app-id-12345"); _bundleServiceMock.Setup(b => b.GetAppIdAsync()).ReturnsAsync("fake-app-id-12345");
_bundleServiceMock.Setup(b => b.GetSecretsAsync()).ReturnsAsync(new List<string> { "fake-secret" });
_bundleServiceMock.Setup(b => b.GetSecretAsync(It.IsAny<int>())).ReturnsAsync("fake-secret");
_loggerMock = new Mock<ILogger<QobuzMetadataService>>(); _loggerMock = new Mock<ILogger<QobuzMetadataService>>();

View File

@@ -22,7 +22,7 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly IMusicMetadataService MetadataService; protected readonly IMusicMetadataService MetadataService;
protected readonly SubsonicSettings SubsonicSettings; protected readonly SubsonicSettings SubsonicSettings;
protected readonly ILogger Logger; protected readonly ILogger Logger;
protected readonly IServiceProvider ServiceProvider; private readonly IServiceProvider _serviceProvider;
protected readonly string DownloadPath; protected readonly string DownloadPath;
protected readonly string CachePath; protected readonly string CachePath;
@@ -30,6 +30,22 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new(); protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
protected readonly SemaphoreSlim DownloadLock = new(1, 1); protected readonly SemaphoreSlim DownloadLock = new(1, 1);
/// <summary>
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
/// </summary>
private PlaylistSyncService? _playlistSyncService;
protected PlaylistSyncService? PlaylistSyncService
{
get
{
if (_playlistSyncService == null)
{
_playlistSyncService = _serviceProvider.GetService<PlaylistSyncService>();
}
return _playlistSyncService;
}
}
/// <summary> /// <summary>
/// Provider name (e.g., "deezer", "qobuz") /// Provider name (e.g., "deezer", "qobuz")
/// </summary> /// </summary>
@@ -47,7 +63,7 @@ public abstract class BaseDownloadService : IDownloadService
LocalLibraryService = localLibraryService; LocalLibraryService = localLibraryService;
MetadataService = metadataService; MetadataService = metadataService;
SubsonicSettings = subsonicSettings; SubsonicSettings = subsonicSettings;
ServiceProvider = serviceProvider; _serviceProvider = serviceProvider;
Logger = logger; Logger = logger;
DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
@@ -269,22 +285,21 @@ public abstract class BaseDownloadService : IDownloadService
song.LocalPath = localPath; song.LocalPath = localPath;
// Check if this track belongs to a playlist and update M3U // Check if this track belongs to a playlist and update M3U
try if (PlaylistSyncService != null)
{ {
var playlistSyncService = ServiceProvider.GetService(typeof(PlaylistSyncService)) as PlaylistSyncService; try
if (playlistSyncService != null)
{ {
var playlistId = playlistSyncService.GetPlaylistIdForTrack(songId); var playlistId = PlaylistSyncService.GetPlaylistIdForTrack(songId);
if (playlistId != null) if (playlistId != null)
{ {
Logger.LogInformation("Track {SongId} belongs to playlist {PlaylistId}, adding to M3U", songId, playlistId); Logger.LogInformation("Track {SongId} belongs to playlist {PlaylistId}, adding to M3U", songId, playlistId);
await playlistSyncService.AddTrackToM3UAsync(playlistId, song, localPath); await PlaylistSyncService.AddTrackToM3UAsync(playlistId, song, localPath, isFullPlaylistDownload: false);
} }
} }
} catch (Exception ex)
catch (Exception ex) {
{ Logger.LogWarning(ex, "Failed to update playlist M3U for track {SongId}", songId);
Logger.LogWarning(ex, "Failed to update playlist M3U for track {SongId}", songId); }
} }
// Only register and scan if NOT in cache mode // Only register and scan if NOT in cache mode

View File

@@ -11,6 +11,7 @@ using octo_fiesta.Models.Search;
using octo_fiesta.Models.Subsonic; using octo_fiesta.Models.Subsonic;
using octo_fiesta.Services.Local; using octo_fiesta.Services.Local;
using octo_fiesta.Services.Common; using octo_fiesta.Services.Common;
using octo_fiesta.Services.Subsonic;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using IOFile = System.IO.File; using IOFile = System.IO.File;

View File

@@ -40,7 +40,7 @@ public class QobuzBundleService
/// <summary> /// <summary>
/// Gets the Qobuz App ID, extracting it from the bundle if not cached /// Gets the Qobuz App ID, extracting it from the bundle if not cached
/// </summary> /// </summary>
public async Task<string> GetAppIdAsync() public virtual async Task<string> GetAppIdAsync()
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return _cachedAppId!; return _cachedAppId!;
@@ -49,7 +49,7 @@ public class QobuzBundleService
/// <summary> /// <summary>
/// Gets the Qobuz secrets list, extracting them from the bundle if not cached /// Gets the Qobuz secrets list, extracting them from the bundle if not cached
/// </summary> /// </summary>
public async Task<List<string>> GetSecretsAsync() public virtual async Task<List<string>> GetSecretsAsync()
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return _cachedSecrets!; return _cachedSecrets!;
@@ -58,7 +58,7 @@ public class QobuzBundleService
/// <summary> /// <summary>
/// Gets a specific secret by index (used for signing requests) /// Gets a specific secret by index (used for signing requests)
/// </summary> /// </summary>
public async Task<string> GetSecretAsync(int index = 0) public virtual async Task<string> GetSecretAsync(int index = 0)
{ {
var secrets = await GetSecretsAsync(); var secrets = await GetSecretsAsync();
if (index < 0 || index >= secrets.Count) if (index < 0 || index >= secrets.Count)

View File

@@ -8,6 +8,7 @@ using octo_fiesta.Models.Search;
using octo_fiesta.Models.Subsonic; using octo_fiesta.Models.Subsonic;
using octo_fiesta.Services.Local; using octo_fiesta.Services.Local;
using octo_fiesta.Services.Common; using octo_fiesta.Services.Common;
using octo_fiesta.Services.Subsonic;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using IOFile = System.IO.File; using IOFile = System.IO.File;

View File

@@ -30,6 +30,10 @@ public class PlaylistSyncService
private readonly string _musicDirectory; private readonly string _musicDirectory;
private readonly string _playlistDirectory; private readonly string _playlistDirectory;
// Cancellation token for background cleanup task
private readonly CancellationTokenSource _cleanupCancellationTokenSource = new();
private readonly Task _cleanupTask;
public PlaylistSyncService( public PlaylistSyncService(
IEnumerable<IMusicMetadataService> metadataServices, IEnumerable<IMusicMetadataService> metadataServices,
IEnumerable<IDownloadService> downloadServices, IEnumerable<IDownloadService> downloadServices,
@@ -58,7 +62,20 @@ public class PlaylistSyncService
} }
// Start background cleanup task for expired cache entries // Start background cleanup task for expired cache entries
_ = Task.Run(CleanupExpiredCacheEntriesAsync); _cleanupTask = Task.Run(() => CleanupExpiredCacheEntriesAsync(_cleanupCancellationTokenSource.Token));
}
/// <summary>
/// Gets the metadata service for the specified provider
/// </summary>
private IMusicMetadataService? GetMetadataServiceForProvider(string provider)
{
return provider.ToLower() switch
{
"deezer" => _deezerMetadataService,
"qobuz" => _qobuzMetadataService,
_ => null
};
} }
/// <summary> /// <summary>
@@ -69,7 +86,7 @@ public class PlaylistSyncService
{ {
var expiresAt = DateTime.UtcNow.Add(CacheTTL); var expiresAt = DateTime.UtcNow.Add(CacheTTL);
_trackPlaylistCache[trackId] = (playlistId, expiresAt); _trackPlaylistCache[trackId] = (playlistId, expiresAt);
_logger.LogDebug("Added track {TrackId} to playlist cache with playlistId {PlaylistId}", trackId, playlistId); _logger.LogInformation("Added track {TrackId} to playlist cache with playlistId {PlaylistId}", trackId, playlistId);
} }
/// <summary> /// <summary>
@@ -112,12 +129,11 @@ public class PlaylistSyncService
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
// Get playlist metadata // Get playlist metadata
var metadataService = provider.ToLower() switch var metadataService = GetMetadataServiceForProvider(provider);
if (metadataService == null)
{ {
"deezer" => _deezerMetadataService, throw new NotSupportedException($"Provider '{provider}' not supported for playlists");
"qobuz" => _qobuzMetadataService, }
_ => throw new NotSupportedException($"Provider '{provider}' not supported for playlists")
};
var playlist = await metadataService.GetPlaylistAsync(provider, externalId); var playlist = await metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null) if (playlist == null)
@@ -145,7 +161,7 @@ public class PlaylistSyncService
return; return;
} }
// Download all tracks // Download all tracks (M3U will be created once at the end)
var downloadedTracks = new List<(Song Song, string LocalPath)>(); var downloadedTracks = new List<(Song Song, string LocalPath)>();
foreach (var track in tracks) foreach (var track in tracks)
@@ -159,6 +175,7 @@ public class PlaylistSyncService
} }
// Add track to playlist cache BEFORE downloading // Add track to playlist cache BEFORE downloading
// This marks it as part of a full playlist download, so AddTrackToM3UAsync will skip real-time updates
var trackId = $"ext-{provider}-{track.ExternalId}"; var trackId = $"ext-{provider}-{track.ExternalId}";
AddTrackToPlaylistCache(trackId, playlistId); AddTrackToPlaylistCache(trackId, playlistId);
@@ -180,7 +197,7 @@ public class PlaylistSyncService
return; return;
} }
// Create M3U file // Create M3U file ONCE at the end with all downloaded tracks
await CreateM3UPlaylistAsync(playlist.Name, downloadedTracks); await CreateM3UPlaylistAsync(playlist.Name, downloadedTracks);
_logger.LogInformation("Playlist download completed: {DownloadedCount}/{TotalCount} tracks for '{PlaylistName}'", _logger.LogInformation("Playlist download completed: {DownloadedCount}/{TotalCount} tracks for '{PlaylistName}'",
@@ -233,11 +250,19 @@ public class PlaylistSyncService
/// <summary> /// <summary>
/// Adds a track to an existing M3U playlist or creates it if it doesn't exist. /// Adds a track to an existing M3U playlist or creates it if it doesn't exist.
/// This is called progressively as tracks are downloaded. /// Called when individual tracks are played/downloaded (NOT during full playlist download).
/// The M3U is rebuilt in the correct playlist order each time. /// The M3U is rebuilt in the correct playlist order each time.
/// </summary> /// </summary>
public async Task AddTrackToM3UAsync(string playlistId, Song track, string localPath) /// <param name="isFullPlaylistDownload">If true, skips M3U update (will be done at the end by DownloadFullPlaylistAsync)</param>
public async Task AddTrackToM3UAsync(string playlistId, Song track, string localPath, bool isFullPlaylistDownload = false)
{ {
// Skip real-time updates during full playlist download (M3U will be created once at the end)
if (isFullPlaylistDownload)
{
_logger.LogDebug("Skipping M3U update for track {TrackId} (full playlist download in progress)", track.Id);
return;
}
try try
{ {
// Get playlist metadata to get the name and track order // Get playlist metadata to get the name and track order
@@ -249,13 +274,7 @@ public class PlaylistSyncService
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var metadataService = provider.ToLower() switch var metadataService = GetMetadataServiceForProvider(provider);
{
"deezer" => _deezerMetadataService,
"qobuz" => _qobuzMetadataService,
_ => null
};
if (metadataService == null) if (metadataService == null)
{ {
_logger.LogWarning("No metadata service found for provider '{Provider}'", provider); _logger.LogWarning("No metadata service found for provider '{Provider}'", provider);
@@ -342,13 +361,13 @@ public class PlaylistSyncService
/// <summary> /// <summary>
/// Background task to clean up expired cache entries every minute /// Background task to clean up expired cache entries every minute
/// </summary> /// </summary>
private async Task CleanupExpiredCacheEntriesAsync() private async Task CleanupExpiredCacheEntriesAsync(CancellationToken cancellationToken)
{ {
while (true) while (!cancellationToken.IsCancellationRequested)
{ {
try try
{ {
await Task.Delay(TimeSpan.FromMinutes(1)); await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var expiredKeys = _trackPlaylistCache var expiredKeys = _trackPlaylistCache
@@ -366,10 +385,27 @@ public class PlaylistSyncService
_logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count); _logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
} }
} }
catch (OperationCanceledException)
{
// Expected when cancellation is requested
break;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Error during playlist cache cleanup"); _logger.LogWarning(ex, "Error during playlist cache cleanup");
} }
} }
_logger.LogInformation("Playlist cache cleanup task stopped");
}
/// <summary>
/// Stops the background cleanup task
/// </summary>
public async Task StopCleanupAsync()
{
_cleanupCancellationTokenSource.Cancel();
await _cleanupTask;
_cleanupCancellationTokenSource.Dispose();
} }
} }