From dbb1964f46057539aa455f7be1c0e57e3cc89415 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Sat, 10 Jan 2026 00:46:18 +0100 Subject: [PATCH 01/14] feat: add cache-only storage mode for streaming without library persistence --- .env.example | 14 +- .../Models/Settings/SubsonicSettings.cs | 32 ++++ octo-fiesta/Program.cs | 4 + .../Services/Common/BaseDownloadService.cs | 106 +++++++++--- .../Services/Common/CacheCleanupService.cs | 163 ++++++++++++++++++ .../Services/Deezer/DeezerDownloadService.cs | 3 +- .../Services/Qobuz/QobuzDownloadService.cs | 3 +- octo-fiesta/appsettings.json | 4 +- 8 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 octo-fiesta/Services/Common/CacheCleanupService.cs diff --git a/.env.example b/.env.example index cde2e68..ffd4b06 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Navidrome/Subsonic server URL SUBSONIC_URL=http://localhost:4533 -# Path where downloaded songs will be stored on the host +# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent) DOWNLOAD_PATH=./downloads # Music service to use: Deezer or Qobuz (default: Deezer) @@ -46,3 +46,15 @@ EXPLICIT_FILTER=All # - Album: When playing a track, download the entire album in background # The played track is downloaded first, remaining tracks are queued DOWNLOAD_MODE=Track + +# Storage mode (optional, default: Permanent) +# - Permanent: Files are saved to the library permanently and registered in Navidrome +# - Cache: Files are stored in /tmp and automatically cleaned up after CACHE_DURATION_HOURS +# Not registered in Navidrome, ideal for streaming without library bloat +STORAGE_MODE=Permanent + +# Cache duration in hours (optional, default: 1) +# Files older than this duration will be automatically deleted when STORAGE_MODE=Cache +# Based on last access time (updated each time the file is streamed) +# Cache location: /tmp/octo-fiesta-cache (automatic, no configuration needed) +CACHE_DURATION_HOURS=1 diff --git a/octo-fiesta/Models/Settings/SubsonicSettings.cs b/octo-fiesta/Models/Settings/SubsonicSettings.cs index 6a8cb0d..7377924 100644 --- a/octo-fiesta/Models/Settings/SubsonicSettings.cs +++ b/octo-fiesta/Models/Settings/SubsonicSettings.cs @@ -40,6 +40,23 @@ public enum ExplicitFilter CleanOnly } +/// +/// Storage mode for downloaded tracks +/// +public enum StorageMode +{ + /// + /// Files are permanently stored in the library and registered in the database + /// + Permanent, + + /// + /// Files are stored in a temporary cache and automatically cleaned up + /// Not registered in the database, no Navidrome scan triggered + /// + Cache +} + /// /// Music service provider /// @@ -81,4 +98,19 @@ public class SubsonicSettings /// Values: "Deezer", "Qobuz" /// public MusicService MusicService { get; set; } = MusicService.Deezer; + + /// + /// Storage mode for downloaded files (default: Permanent) + /// Environment variable: STORAGE_MODE + /// Values: "Permanent" (files saved to library), "Cache" (temporary files, auto-cleanup) + /// + public StorageMode StorageMode { get; set; } = StorageMode.Permanent; + + /// + /// Cache duration in hours for Cache storage mode (default: 1) + /// Environment variable: CACHE_DURATION_HOURS + /// Files older than this duration will be automatically deleted + /// Only applies when StorageMode is Cache + /// + public int CacheDurationHours { get; set; } = 1; } \ No newline at end of file diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index ddd47ff..6eaddcf 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -5,6 +5,7 @@ using octo_fiesta.Services.Qobuz; using octo_fiesta.Services.Local; using octo_fiesta.Services.Validation; using octo_fiesta.Services.Subsonic; +using octo_fiesta.Services.Common; using octo_fiesta.Middleware; var builder = WebApplication.CreateBuilder(args); @@ -64,6 +65,9 @@ builder.Services.AddSingleton(); // Register orchestrator as hosted service builder.Services.AddHostedService(); +// Register cache cleanup service (only runs when StorageMode is Cache) +builder.Services.AddHostedService(); + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs index 050d651..5709505 100644 --- a/octo-fiesta/Services/Common/BaseDownloadService.cs +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -23,6 +23,7 @@ public abstract class BaseDownloadService : IDownloadService protected readonly ILogger Logger; protected readonly string DownloadPath; + protected readonly string CachePath; protected readonly Dictionary ActiveDownloads = new(); protected readonly SemaphoreSlim DownloadLock = new(1, 1); @@ -46,11 +47,17 @@ public abstract class BaseDownloadService : IDownloadService Logger = logger; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; + CachePath = Path.Combine(Path.GetTempPath(), "octo-fiesta-cache"); if (!Directory.Exists(DownloadPath)) { Directory.CreateDirectory(DownloadPath); } + + if (!Directory.Exists(CachePath)) + { + Directory.CreateDirectory(CachePath); + } } #region IDownloadService Implementation @@ -62,7 +69,7 @@ public abstract class BaseDownloadService : IDownloadService public async Task DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - var localPath = await DownloadSongAsync(externalProvider, externalId, cancellationToken); + var localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); return IOFile.OpenRead(localPath); } @@ -130,13 +137,29 @@ public abstract class BaseDownloadService : IDownloadService } var songId = $"ext-{externalProvider}-{externalId}"; + var isCache = SubsonicSettings.StorageMode == StorageMode.Cache; - // Check if already downloaded - var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); - if (existingPath != null && IOFile.Exists(existingPath)) + // Check if already downloaded (skip for cache mode as we want to check cache folder) + if (!isCache) { - Logger.LogInformation("Song already downloaded: {Path}", existingPath); - return existingPath; + var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); + if (existingPath != null && IOFile.Exists(existingPath)) + { + Logger.LogInformation("Song already downloaded: {Path}", existingPath); + return existingPath; + } + } + else + { + // For cache mode, check if file exists in cache directory + var cachedPath = GetCachedFilePath(externalProvider, externalId); + if (cachedPath != null && IOFile.Exists(cachedPath)) + { + Logger.LogInformation("Song found in cache: {Path}", cachedPath); + // Update file access time for cache cleanup logic + IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow); + return cachedPath; + } } // Check if download in progress @@ -185,30 +208,39 @@ public abstract class BaseDownloadService : IDownloadService downloadInfo.CompletedAt = DateTime.UtcNow; song.LocalPath = localPath; - await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath); - // Trigger a Subsonic library rescan (with debounce) - _ = Task.Run(async () => + // Only register and scan if NOT in cache mode + if (!isCache) { - try + await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath); + + // Trigger a Subsonic library rescan (with debounce) + _ = Task.Run(async () => { - await LocalLibraryService.TriggerLibraryScanAsync(); - } - catch (Exception ex) + try + { + await LocalLibraryService.TriggerLibraryScanAsync(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to trigger library scan after download"); + } + }); + + // 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)) { - Logger.LogWarning(ex, "Failed to trigger library scan after download"); + 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); + } } - }); - - // 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)) + } + else { - 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("Cache mode: skipping library registration and scan"); } Logger.LogInformation("Download completed: {Path}", localPath); @@ -401,5 +433,31 @@ public abstract class BaseDownloadService : IDownloadService } } + /// + /// Gets the cached file path for a given provider and external ID + /// Returns null if no cached file exists + /// + protected string? GetCachedFilePath(string provider, string externalId) + { + try + { + // Search for cached files matching the pattern: {provider}_{externalId}.* + var pattern = $"{provider}_{externalId}.*"; + var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories); + + if (files.Length > 0) + { + return files[0]; // Return first match + } + + return null; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId); + return null; + } + } + #endregion } diff --git a/octo-fiesta/Services/Common/CacheCleanupService.cs b/octo-fiesta/Services/Common/CacheCleanupService.cs new file mode 100644 index 0000000..13d7f0a --- /dev/null +++ b/octo-fiesta/Services/Common/CacheCleanupService.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Options; +using octo_fiesta.Models.Settings; + +namespace octo_fiesta.Services.Common; + +/// +/// Background service that periodically cleans up old cached files +/// Only runs when StorageMode is set to Cache +/// +public class CacheCleanupService : BackgroundService +{ + private readonly IConfiguration _configuration; + private readonly SubsonicSettings _subsonicSettings; + private readonly ILogger _logger; + private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1); + + public CacheCleanupService( + IConfiguration configuration, + IOptions subsonicSettings, + ILogger logger) + { + _configuration = configuration; + _subsonicSettings = subsonicSettings.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Only run if storage mode is Cache + if (_subsonicSettings.StorageMode != StorageMode.Cache) + { + _logger.LogInformation("CacheCleanupService disabled: StorageMode is not Cache"); + return; + } + + _logger.LogInformation("CacheCleanupService started with cleanup interval of {Interval} and retention of {Hours} hours", + _cleanupInterval, _subsonicSettings.CacheDurationHours); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CleanupOldCachedFilesAsync(stoppingToken); + await Task.Delay(_cleanupInterval, stoppingToken); + } + catch (OperationCanceledException) + { + // Service is stopping, exit gracefully + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during cache cleanup"); + // Continue running even if cleanup fails + await Task.Delay(_cleanupInterval, stoppingToken); + } + } + + _logger.LogInformation("CacheCleanupService stopped"); + } + + private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken) + { + var cachePath = Path.Combine(Path.GetTempPath(), "octo-fiesta-cache"); + + if (!Directory.Exists(cachePath)) + { + _logger.LogDebug("Cache directory does not exist: {Path}", cachePath); + return; + } + + var cutoffTime = DateTime.UtcNow.AddHours(-_subsonicSettings.CacheDurationHours); + var deletedCount = 0; + var totalSize = 0L; + + _logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime); + + try + { + // Get all files in cache directory and subdirectories + var files = Directory.GetFiles(cachePath, "*.*", SearchOption.AllDirectories); + + foreach (var filePath in files) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + var fileInfo = new FileInfo(filePath); + + // Use last access time to determine if file should be deleted + // This gets updated when a cached file is streamed + if (fileInfo.LastAccessTimeUtc < cutoffTime) + { + var size = fileInfo.Length; + File.Delete(filePath); + deletedCount++; + totalSize += size; + _logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})", + filePath, fileInfo.LastAccessTimeUtc); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete cached file: {Path}", filePath); + } + } + + // Clean up empty directories + await CleanupEmptyDirectoriesAsync(cachePath, cancellationToken); + + if (deletedCount > 0) + { + var sizeMB = totalSize / (1024.0 * 1024.0); + _logger.LogInformation("Cache cleanup completed: deleted {Count} files, freed {Size:F2} MB", + deletedCount, sizeMB); + } + else + { + _logger.LogDebug("Cache cleanup completed: no files to delete"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during cache cleanup"); + } + } + + private async Task CleanupEmptyDirectoriesAsync(string rootPath, CancellationToken cancellationToken) + { + try + { + var directories = Directory.GetDirectories(rootPath, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length); // Process deepest directories first + + foreach (var directory in directories) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + if (!Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + _logger.LogDebug("Deleted empty directory: {Path}", directory); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete empty directory: {Path}", directory); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error cleaning up empty directories"); + } + + await Task.CompletedTask; + } +} diff --git a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs index 0bc843d..e813da5 100644 --- a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs +++ b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs @@ -109,7 +109,8 @@ public class DeezerDownloadService : BaseDownloadService // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) var artistForPath = song.AlbumArtist ?? song.Artist; - var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension); + var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath; + var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); // Create directories if they don't exist var albumFolder = Path.GetDirectoryName(outputPath)!; diff --git a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs index 5abddfa..3de4ea2 100644 --- a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs @@ -108,7 +108,8 @@ public class QobuzDownloadService : BaseDownloadService // Build organized folder structure using AlbumArtist (fallback to Artist for singles) var artistForPath = song.AlbumArtist ?? song.Artist; - var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension); + var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath; + var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); var albumFolder = Path.GetDirectoryName(outputPath)!; EnsureDirectoryExists(albumFolder); diff --git a/octo-fiesta/appsettings.json b/octo-fiesta/appsettings.json index 15b05ce..e8509c1 100644 --- a/octo-fiesta/appsettings.json +++ b/octo-fiesta/appsettings.json @@ -9,7 +9,9 @@ "Subsonic": { "Url": "http://localhost:4533", "ExplicitFilter": "All", - "DownloadMode": "Track" + "DownloadMode": "Track", + "StorageMode": "Permanent", + "CacheDurationHours": 1 }, "Library": { "DownloadPath": "./downloads" From 61a21b0e0c271b0ee947d7aefd607ec959e740ed Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Sun, 11 Jan 2026 00:24:29 +0100 Subject: [PATCH 02/14] refactor: centralize cache path logic and add TMPDIR documentation --- .env.example | 3 ++- octo-fiesta/Services/Common/BaseDownloadService.cs | 2 +- octo-fiesta/Services/Common/CacheCleanupService.cs | 2 +- octo-fiesta/Services/Common/PathHelper.cs | 11 +++++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index ffd4b06..6b2dd89 100644 --- a/.env.example +++ b/.env.example @@ -51,10 +51,11 @@ DOWNLOAD_MODE=Track # - Permanent: Files are saved to the library permanently and registered in Navidrome # - Cache: Files are stored in /tmp and automatically cleaned up after CACHE_DURATION_HOURS # Not registered in Navidrome, ideal for streaming without library bloat +# Note: On Linux/Docker, you can customize cache location by setting TMPDIR environment variable STORAGE_MODE=Permanent # Cache duration in hours (optional, default: 1) # Files older than this duration will be automatically deleted when STORAGE_MODE=Cache # Based on last access time (updated each time the file is streamed) -# Cache location: /tmp/octo-fiesta-cache (automatic, no configuration needed) +# Cache location: /tmp/octo-fiesta-cache (or $TMPDIR/octo-fiesta-cache if TMPDIR is set) CACHE_DURATION_HOURS=1 diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs index 5709505..b41aaa2 100644 --- a/octo-fiesta/Services/Common/BaseDownloadService.cs +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -47,7 +47,7 @@ public abstract class BaseDownloadService : IDownloadService Logger = logger; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; - CachePath = Path.Combine(Path.GetTempPath(), "octo-fiesta-cache"); + CachePath = PathHelper.GetCachePath(); if (!Directory.Exists(DownloadPath)) { diff --git a/octo-fiesta/Services/Common/CacheCleanupService.cs b/octo-fiesta/Services/Common/CacheCleanupService.cs index 13d7f0a..d49752b 100644 --- a/octo-fiesta/Services/Common/CacheCleanupService.cs +++ b/octo-fiesta/Services/Common/CacheCleanupService.cs @@ -61,7 +61,7 @@ public class CacheCleanupService : BackgroundService private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken) { - var cachePath = Path.Combine(Path.GetTempPath(), "octo-fiesta-cache"); + var cachePath = PathHelper.GetCachePath(); if (!Directory.Exists(cachePath)) { diff --git a/octo-fiesta/Services/Common/PathHelper.cs b/octo-fiesta/Services/Common/PathHelper.cs index 93f43ad..94b226c 100644 --- a/octo-fiesta/Services/Common/PathHelper.cs +++ b/octo-fiesta/Services/Common/PathHelper.cs @@ -8,6 +8,17 @@ namespace octo_fiesta.Services.Common; /// public static class PathHelper { + /// + /// Gets the cache directory path for temporary file storage. + /// Uses system temp directory combined with octo-fiesta-cache subfolder. + /// Respects TMPDIR environment variable on Linux/macOS. + /// + /// Full path to the cache directory. + public static string GetCachePath() + { + return Path.Combine(Path.GetTempPath(), "octo-fiesta-cache"); + } + /// /// Builds the output path for a downloaded track following the Artist/Album/Track structure. /// From ad4a3af41e8795fbc0fae7b29e3e2cdf101394ba Mon Sep 17 00:00:00 2001 From: bransoned Date: Sun, 11 Jan 2026 18:47:13 -0500 Subject: [PATCH 03/14] #31 (upstream) Fix iOS client local streaming issues --- octo-fiesta/Program.cs | 1 + .../Services/Subsonic/SubsonicProxyService.cs | 46 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 6eaddcf..7c08988 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -16,6 +16,7 @@ builder.Services.AddControllers(); builder.Services.AddHttpClient(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddHttpContextAccessor(); // Exception handling builder.Services.AddExceptionHandler(); diff --git a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs index ff531f2..6de4595 100644 --- a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs +++ b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs @@ -10,13 +10,16 @@ public class SubsonicProxyService { private readonly HttpClient _httpClient; private readonly SubsonicSettings _subsonicSettings; + private readonly IHttpContextAccessor _httpContextAccessor; public SubsonicProxyService( IHttpClientFactory httpClientFactory, - Microsoft.Extensions.Options.IOptions subsonicSettings) + Microsoft.Extensions.Options.IOptions subsonicSettings, + IHttpContextAccessor httpContextAccessor) { _httpClient = httpClientFactory.CreateClient(); _subsonicSettings = subsonicSettings.Value; + _httpContextAccessor = httpContextAccessor; } /// @@ -66,11 +69,33 @@ public class SubsonicProxyService { try { + // Get HTTP context for request/response forwarding + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + { + return new StatusCodeResult(500); + } + + var incomingRequest = httpContext.Request; + var outgoingResponse = httpContext.Response; + var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var url = $"{_subsonicSettings.Url}/rest/stream?{query}"; - + using var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Forward Range headers (fix for iOS client) + if (incomingRequest.Headers.TryGetValue("Range", out var range)) + { + request.Headers.TryAddWithoutValidation("Range", range.ToString()); + } + + if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange)) + { + request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToString()); + } + var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, @@ -81,6 +106,23 @@ public class SubsonicProxyService return new StatusCodeResult((int)response.StatusCode); } + // Iterate over and forward streaming-required headers + foreach (var header in new[] + { + "Accept-Ranges", + "Content-Range", + "Content-Length", + "ETag", + "Last-Modified" + }) + { + if (response.Headers.TryGetValues(header, out var values) || + response.Content.Headers.TryGetValues(header, out values)) + { + outgoingResponse.Headers[header] = values.ToArray(); + } + } + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; From 428b7f06c42f65fe81da332d985269fa37063b94 Mon Sep 17 00:00:00 2001 From: bransoned Date: Sun, 11 Jan 2026 19:03:21 -0500 Subject: [PATCH 04/14] #31 (upstream) Add http context handling to tests --- octo-fiesta.Tests/SubsonicProxyServiceTests.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs index f5b40e1..09b524d 100644 --- a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs +++ b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Http; using Moq; using Moq.Protected; using octo_fiesta.Models.Settings; @@ -18,16 +19,22 @@ public class SubsonicProxyServiceTests { _mockHttpMessageHandler = new Mock(); var httpClient = new HttpClient(_mockHttpMessageHandler.Object); - + _mockHttpClientFactory = new Mock(); _mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); - - var settings = Options.Create(new SubsonicSettings + + var settings = Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }); - _service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings); + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = httpContext + }; + + _service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings, httpContextAccessor); } [Fact] From 62246cc48fa9fceff54b42018ec3647c162b4293 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Sun, 11 Jan 2026 22:56:19 +0100 Subject: [PATCH 05/14] fix: prevent duplicate downloads in album mode --- .../Services/Common/BaseDownloadService.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs index b41aaa2..c37f309 100644 --- a/octo-fiesta/Services/Common/BaseDownloadService.cs +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -290,6 +290,23 @@ public abstract class BaseDownloadService : IDownloadService continue; } + // Check if download is already in progress or recently completed + var songId = $"ext-{ProviderName}-{track.ExternalId}"; + if (ActiveDownloads.TryGetValue(songId, out var activeDownload)) + { + if (activeDownload.Status == DownloadStatus.InProgress) + { + Logger.LogDebug("Track {TrackId} download already in progress, skipping", track.ExternalId); + continue; + } + + if (activeDownload.Status == DownloadStatus.Completed) + { + Logger.LogDebug("Track {TrackId} already downloaded in this session, skipping", track.ExternalId); + continue; + } + } + Logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title); await DownloadSongInternalAsync(ProviderName, track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None); } From 1d4c46b4f3b450bd0a56feb9b3efcbe01adf6989 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Mon, 12 Jan 2026 13:50:30 +0100 Subject: [PATCH 06/14] fix: use album artist for folder organization in Album download mode --- .../Services/Common/BaseDownloadService.cs | 30 ++++++++++++++++++- .../Services/Deezer/DeezerMetadataService.cs | 7 +++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs index c37f309..2b96507 100644 --- a/octo-fiesta/Services/Common/BaseDownloadService.cs +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -183,7 +183,35 @@ public abstract class BaseDownloadService : IDownloadService try { // Get metadata - var song = await MetadataService.GetSongAsync(externalProvider, externalId); + // In Album mode, fetch the full album first to ensure AlbumArtist is correctly set + Song? song = null; + + if (SubsonicSettings.DownloadMode == DownloadMode.Album) + { + // First try to get the song to extract album ID + var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId); + if (tempSong != null && !string.IsNullOrEmpty(tempSong.AlbumId)) + { + var albumExternalId = ExtractExternalIdFromAlbumId(tempSong.AlbumId); + if (!string.IsNullOrEmpty(albumExternalId)) + { + // Get full album with correct AlbumArtist + var album = await MetadataService.GetAlbumAsync(externalProvider, albumExternalId); + if (album != null) + { + // Find the track in the album + song = album.Songs.FirstOrDefault(s => s.ExternalId == externalId); + } + } + } + } + + // Fallback to individual song fetch if not in Album mode or album fetch failed + if (song == null) + { + song = await MetadataService.GetSongAsync(externalProvider, externalId); + } + if (song == null) { throw new Exception("Song not found"); diff --git a/octo-fiesta/Services/Deezer/DeezerMetadataService.cs b/octo-fiesta/Services/Deezer/DeezerMetadataService.cs index 60394cd..43aa38c 100644 --- a/octo-fiesta/Services/Deezer/DeezerMetadataService.cs +++ b/octo-fiesta/Services/Deezer/DeezerMetadataService.cs @@ -229,8 +229,8 @@ public class DeezerMetadataService : IMusicMetadataService int trackIndex = 1; foreach (var track in tracksData.EnumerateArray()) { - // Pass the index as fallback for track_position (Deezer doesn't include it in album tracks) - var song = ParseDeezerTrack(track, trackIndex); + // Pass the album artist to ensure proper folder organization + var song = ParseDeezerTrack(track, trackIndex, album.Artist); if (ShouldIncludeSong(song)) { album.Songs.Add(song); @@ -283,7 +283,7 @@ public class DeezerMetadataService : IMusicMetadataService return albums; } - private Song ParseDeezerTrack(JsonElement track, int? fallbackTrackNumber = null) + private Song ParseDeezerTrack(JsonElement track, int? fallbackTrackNumber = null, string? albumArtist = null) { var externalId = track.GetProperty("id").GetInt64().ToString(); @@ -321,6 +321,7 @@ public class DeezerMetadataService : IMusicMetadataService albumForCover.TryGetProperty("cover_medium", out var cover) ? cover.GetString() : null, + AlbumArtist = albumArtist, IsLocal = false, ExternalProvider = "deezer", ExternalId = externalId, From 7a08f1552384ac3803eb28faa74deefb611d233f Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Mon, 12 Jan 2026 18:40:45 +0100 Subject: [PATCH 07/14] fix: prevent duplicate downloads from concurrent stream requests --- .../Services/Common/BaseDownloadService.cs | 169 +++++++++--------- 1 file changed, 87 insertions(+), 82 deletions(-) diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs index 2b96507..bb70f79 100644 --- a/octo-fiesta/Services/Common/BaseDownloadService.cs +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -139,49 +139,54 @@ public abstract class BaseDownloadService : IDownloadService var songId = $"ext-{externalProvider}-{externalId}"; var isCache = SubsonicSettings.StorageMode == StorageMode.Cache; - // Check if already downloaded (skip for cache mode as we want to check cache folder) - if (!isCache) - { - var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); - if (existingPath != null && IOFile.Exists(existingPath)) - { - Logger.LogInformation("Song already downloaded: {Path}", existingPath); - return existingPath; - } - } - else - { - // For cache mode, check if file exists in cache directory - var cachedPath = GetCachedFilePath(externalProvider, externalId); - if (cachedPath != null && IOFile.Exists(cachedPath)) - { - Logger.LogInformation("Song found in cache: {Path}", cachedPath); - // Update file access time for cache cleanup logic - IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow); - return cachedPath; - } - } - - // Check if download in progress - if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) - { - Logger.LogInformation("Download already in progress for {SongId}", songId); - while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress) - { - await Task.Delay(500, cancellationToken); - } - - if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) - { - return activeDownload.LocalPath; - } - - throw new Exception(activeDownload?.ErrorMessage ?? "Download failed"); - } - + // Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests await DownloadLock.WaitAsync(cancellationToken); + try { + // Check if already downloaded (skip for cache mode as we want to check cache folder) + if (!isCache) + { + var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); + if (existingPath != null && IOFile.Exists(existingPath)) + { + Logger.LogInformation("Song already downloaded: {Path}", existingPath); + return existingPath; + } + } + else + { + // For cache mode, check if file exists in cache directory + var cachedPath = GetCachedFilePath(externalProvider, externalId); + if (cachedPath != null && IOFile.Exists(cachedPath)) + { + Logger.LogInformation("Song found in cache: {Path}", cachedPath); + // Update file access time for cache cleanup logic + IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow); + return cachedPath; + } + } + + // Check if download in progress + if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) + { + Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId); + // Release lock while waiting + DownloadLock.Release(); + + while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress) + { + await Task.Delay(500, cancellationToken); + } + + if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) + { + return activeDownload.LocalPath; + } + + throw new Exception(activeDownload?.ErrorMessage ?? "Download failed"); + } + // Get metadata // In Album mode, fetch the full album first to ensure AlbumArtist is correctly set Song? song = null; @@ -227,60 +232,60 @@ public abstract class BaseDownloadService : IDownloadService }; ActiveDownloads[songId] = downloadInfo; - try + var localPath = await DownloadTrackAsync(externalId, song, cancellationToken); + + downloadInfo.Status = DownloadStatus.Completed; + downloadInfo.LocalPath = localPath; + downloadInfo.CompletedAt = DateTime.UtcNow; + + song.LocalPath = localPath; + + // Only register and scan if NOT in cache mode + if (!isCache) { - var localPath = await DownloadTrackAsync(externalId, song, cancellationToken); + await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath); - downloadInfo.Status = DownloadStatus.Completed; - downloadInfo.LocalPath = localPath; - downloadInfo.CompletedAt = DateTime.UtcNow; - - song.LocalPath = localPath; - - // Only register and scan if NOT in cache mode - if (!isCache) + // Trigger a Subsonic library rescan (with debounce) + _ = Task.Run(async () => { - await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath); - - // Trigger a Subsonic library rescan (with debounce) - _ = Task.Run(async () => + try { - try - { - await LocalLibraryService.TriggerLibraryScanAsync(); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to trigger library scan after download"); - } - }); - - // 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)) + await LocalLibraryService.TriggerLibraryScanAsync(); + } + catch (Exception ex) { - 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.LogWarning(ex, "Failed to trigger library scan after download"); + } + }); + + // 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)) + { + 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); } } - else - { - Logger.LogInformation("Cache mode: skipping library registration and scan"); - } - - Logger.LogInformation("Download completed: {Path}", localPath); - return localPath; } - catch (Exception ex) + else + { + Logger.LogInformation("Cache mode: skipping library registration and scan"); + } + + Logger.LogInformation("Download completed: {Path}", localPath); + return localPath; + } + catch (Exception ex) + { + if (ActiveDownloads.TryGetValue(songId, out var downloadInfo)) { downloadInfo.Status = DownloadStatus.Failed; downloadInfo.ErrorMessage = ex.Message; - Logger.LogError(ex, "Download failed for {SongId}", songId); - throw; } + Logger.LogError(ex, "Download failed for {SongId}", songId); + throw; } finally { From c8c4fd83221849117ac8c395bac47a9fb2e97e70 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Mon, 12 Jan 2026 19:35:29 +0100 Subject: [PATCH 08/14] fix: add HTTP Range header forwarding for iOS clients --- .../SubsonicProxyServiceTests.cs | 105 ++++++++++++++++-- octo-fiesta/Program.cs | 2 +- .../Services/Subsonic/SubsonicProxyService.cs | 86 +++++++------- 3 files changed, 145 insertions(+), 48 deletions(-) diff --git a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs index 09b524d..546608a 100644 --- a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs +++ b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs @@ -22,17 +22,17 @@ public class SubsonicProxyServiceTests _mockHttpClientFactory = new Mock(); _mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); - - var settings = Options.Create(new SubsonicSettings + + var settings = Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }); - var httpContext = new DefaultHttpContext(); - var httpContextAccessor = new HttpContextAccessor - { - HttpContext = httpContext - }; + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = httpContext + }; _service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings, httpContextAccessor); } @@ -329,4 +329,95 @@ public class SubsonicProxyServiceTests var fileResult = Assert.IsType(result); Assert.Equal("audio/mpeg", fileResult.ContentType); } + + [Fact] + public async Task RelayStreamAsync_WithRangeHeader_ForwardsRangeToUpstream() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + var streamContent = new byte[] { 1, 2, 3, 4, 5 }; + var responseMessage = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(streamContent) + }; + responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("audio/mpeg"); + + _mockHttpMessageHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(responseMessage); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Range"] = "bytes=0-1023"; + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var service = new SubsonicProxyService(_mockHttpClientFactory.Object, + Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }), + httpContextAccessor); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + await service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest!.Headers.Contains("Range")); + Assert.Equal("bytes=0-1023", capturedRequest.Headers.GetValues("Range").First()); + } + + [Fact] + public async Task RelayStreamAsync_WithIfRangeHeader_ForwardsIfRangeToUpstream() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + var streamContent = new byte[] { 1, 2, 3 }; + var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(streamContent) + }; + + _mockHttpMessageHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(responseMessage); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["If-Range"] = "\"etag123\""; + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var service = new SubsonicProxyService(_mockHttpClientFactory.Object, + Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }), + httpContextAccessor); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + await service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest!.Headers.Contains("If-Range")); + } + + [Fact] + public async Task RelayStreamAsync_NullHttpContext_ReturnsError() + { + // Arrange + var httpContextAccessor = new HttpContextAccessor { HttpContext = null }; + var service = new SubsonicProxyService(_mockHttpClientFactory.Object, + Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }), + httpContextAccessor); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + var result = await service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(500, objectResult.StatusCode); + } } diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 7c08988..9a6a783 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -41,7 +41,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Register music service based on configuration if (musicService == MusicService.Qobuz) diff --git a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs index 6de4595..da4eecc 100644 --- a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs +++ b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs @@ -10,16 +10,16 @@ public class SubsonicProxyService { private readonly HttpClient _httpClient; private readonly SubsonicSettings _subsonicSettings; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IHttpContextAccessor _httpContextAccessor; public SubsonicProxyService( IHttpClientFactory httpClientFactory, Microsoft.Extensions.Options.IOptions subsonicSettings, - IHttpContextAccessor httpContextAccessor) + IHttpContextAccessor httpContextAccessor) { _httpClient = httpClientFactory.CreateClient(); _subsonicSettings = subsonicSettings.Value; - _httpContextAccessor = httpContextAccessor; + _httpContextAccessor = httpContextAccessor; } /// @@ -69,33 +69,36 @@ public class SubsonicProxyService { try { - // Get HTTP context for request/response forwarding - var httpContext = _httpContextAccessor.HttpContext; - if (httpContext == null) - { - return new StatusCodeResult(500); - } - - var incomingRequest = httpContext.Request; - var outgoingResponse = httpContext.Response; + // Get HTTP context for request/response forwarding + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + { + return new ObjectResult(new { error = "HTTP context not available" }) + { + StatusCode = 500 + }; + } + + var incomingRequest = httpContext.Request; + var outgoingResponse = httpContext.Response; var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var url = $"{_subsonicSettings.Url}/rest/stream?{query}"; - + using var request = new HttpRequestMessage(HttpMethod.Get, url); - // Forward Range headers (fix for iOS client) - if (incomingRequest.Headers.TryGetValue("Range", out var range)) - { - request.Headers.TryAddWithoutValidation("Range", range.ToString()); - } - - if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange)) - { - request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToString()); - } - + // Forward Range headers for progressive streaming support (iOS clients) + if (incomingRequest.Headers.TryGetValue("Range", out var range)) + { + request.Headers.TryAddWithoutValidation("Range", range.ToArray()); + } + + if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange)) + { + request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToArray()); + } + var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, @@ -106,22 +109,25 @@ public class SubsonicProxyService return new StatusCodeResult((int)response.StatusCode); } - // Iterate over and forward streaming-required headers - foreach (var header in new[] - { - "Accept-Ranges", - "Content-Range", - "Content-Length", - "ETag", - "Last-Modified" - }) - { - if (response.Headers.TryGetValues(header, out var values) || - response.Content.Headers.TryGetValues(header, out values)) - { - outgoingResponse.Headers[header] = values.ToArray(); - } - } + // Forward HTTP status code (e.g., 206 Partial Content for range requests) + outgoingResponse.StatusCode = (int)response.StatusCode; + + // Forward streaming-required headers from upstream response + foreach (var header in new[] + { + "Accept-Ranges", + "Content-Range", + "Content-Length", + "ETag", + "Last-Modified" + }) + { + if (response.Headers.TryGetValues(header, out var values) || + response.Content.Headers.TryGetValues(header, out values)) + { + outgoingResponse.Headers[header] = values.ToArray(); + } + } var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; From 2c5daeefedef0fc313bd7c50ba1bd29db185528f Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Mon, 12 Jan 2026 19:50:46 +0100 Subject: [PATCH 09/14] docs: add Narjo to iOS compatible clients section --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2d09d4b..70e5b31 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ The name was randomly generated by GitHub when creating the repository. We found - [Tempus](https://github.com/eddyizm/tempus) - [Substreamer](https://substreamerapp.com/) +### iOS + +- [Narjo](https://www.reddit.com/r/NarjoApp/) + > **Want to improve client compatibility?** Pull requests are welcome! ### Incompatible Clients From 183f59993f68a6369922572dc4c25c71c9019e2a Mon Sep 17 00:00:00 2001 From: bransoned Date: Tue, 13 Jan 2026 21:39:42 +0000 Subject: [PATCH 10/14] Change repeated array allocation into predefined constant --- .../Services/Subsonic/SubsonicProxyService.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs index da4eecc..9ec61d9 100644 --- a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs +++ b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs @@ -60,6 +60,16 @@ public class SubsonicProxyService } } + private static ReadOnlySpan StreamingRequiredHeaders => + new + { + "Accept-Ranges", + "Content-Range", + "Content-Length", + "ETag", + "Last-Modified" + }; + /// /// Relays a stream request to the Subsonic server with range processing support. /// @@ -113,14 +123,7 @@ public class SubsonicProxyService outgoingResponse.StatusCode = (int)response.StatusCode; // Forward streaming-required headers from upstream response - foreach (var header in new[] - { - "Accept-Ranges", - "Content-Range", - "Content-Length", - "ETag", - "Last-Modified" - }) + foreach (var header in StreamingRequiredHeaders) { if (response.Headers.TryGetValues(header, out var values) || response.Content.Headers.TryGetValues(header, out values)) From c4e62cdeaba8e1b0bfbd153ab14db34fec0359c4 Mon Sep 17 00:00:00 2001 From: Branson Date: Tue, 13 Jan 2026 16:56:51 -0500 Subject: [PATCH 11/14] Fix syntax error --- octo-fiesta/Services/Subsonic/SubsonicProxyService.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs index 9ec61d9..265575f 100644 --- a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs +++ b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs @@ -60,8 +60,7 @@ public class SubsonicProxyService } } - private static ReadOnlySpan StreamingRequiredHeaders => - new + private static readonly string[] StreamingRequiredHeaders = { "Accept-Ranges", "Content-Range", From ebe6e90f39c51e8b203a48cf0a0e01088d39bf59 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Wed, 14 Jan 2026 23:18:27 +0100 Subject: [PATCH 12/14] feat: playlist implementation --- .../DeezerDownloadServiceTests.cs | 3 + .../DeezerMetadataServiceTests.cs | 222 ++++++ octo-fiesta.Tests/PlaylistIdHelperTests.cs | 375 ++++++++++ .../QobuzDownloadServiceTests.cs | 3 + .../QobuzMetadataServiceTests.cs | 659 ++++++++++++++++++ octo-fiesta.Tests/SubsonicModelMapperTests.cs | 40 +- octo-fiesta/Controllers/SubSonicController.cs | 162 ++++- .../Models/Settings/SubsonicSettings.cs | 16 + .../Models/Subsonic/ExternalPlaylist.cs | 58 ++ octo-fiesta/Program.cs | 24 +- .../Services/Common/BaseDownloadService.cs | 47 ++ .../Services/Common/PlaylistIdHelper.cs | 76 ++ .../Services/Deezer/DeezerDownloadService.cs | 3 +- .../Services/Deezer/DeezerMetadataService.cs | 163 +++++ octo-fiesta/Services/IDownloadService.cs | 8 + octo-fiesta/Services/IMusicMetadataService.cs | 24 + .../Services/Qobuz/QobuzDownloadService.cs | 3 +- .../Services/Qobuz/QobuzMetadataService.cs | 180 +++++ .../Services/Subsonic/PlaylistSyncService.cs | 375 ++++++++++ .../Services/Subsonic/SubsonicModelMapper.cs | 98 ++- .../Subsonic/SubsonicResponseBuilder.cs | 76 ++ 21 files changed, 2561 insertions(+), 54 deletions(-) create mode 100644 octo-fiesta.Tests/PlaylistIdHelperTests.cs create mode 100644 octo-fiesta.Tests/QobuzMetadataServiceTests.cs create mode 100644 octo-fiesta/Models/Subsonic/ExternalPlaylist.cs create mode 100644 octo-fiesta/Services/Common/PlaylistIdHelper.cs create mode 100644 octo-fiesta/Services/Subsonic/PlaylistSyncService.cs diff --git a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs index 2134cde..11f7f2e 100644 --- a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs +++ b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs @@ -24,6 +24,7 @@ public class DeezerDownloadServiceTests : IDisposable private readonly Mock _localLibraryServiceMock; private readonly Mock _metadataServiceMock; private readonly Mock> _loggerMock; + private readonly Mock _serviceProviderMock; private readonly IConfiguration _configuration; private readonly string _testDownloadPath; @@ -41,6 +42,7 @@ public class DeezerDownloadServiceTests : IDisposable _localLibraryServiceMock = new Mock(); _metadataServiceMock = new Mock(); _loggerMock = new Mock>(); + _serviceProviderMock = new Mock(); _configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -90,6 +92,7 @@ public class DeezerDownloadServiceTests : IDisposable _metadataServiceMock.Object, subsonicSettings, deezerSettings, + _serviceProviderMock.Object, _loggerMock.Object); } diff --git a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs index fe72a97..b09042b 100644 --- a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs +++ b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs @@ -580,4 +580,226 @@ public class DeezerMetadataServiceTests } #endregion + + #region Playlist Tests + + [Fact] + public async Task SearchPlaylistsAsync_ReturnsListOfPlaylists() + { + // Arrange + var deezerResponse = new + { + data = new[] + { + new + { + id = 12345, + title = "Chill Vibes", + nb_tracks = 50, + picture_medium = "https://example.com/playlist1.jpg", + user = new { name = "Test User" } + }, + new + { + id = 67890, + title = "Workout Mix", + nb_tracks = 30, + picture_medium = "https://example.com/playlist2.jpg", + user = new { name = "Gym Buddy" } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.SearchPlaylistsAsync("chill"); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Chill Vibes", result[0].Name); + Assert.Equal(50, result[0].TrackCount); + Assert.Equal("pl-deezer-12345", result[0].Id); + } + + [Fact] + public async Task SearchPlaylistsAsync_WithLimit_RespectsLimit() + { + // Arrange + var deezerResponse = new + { + data = new[] + { + new + { + id = 12345, + title = "Playlist 1", + nb_tracks = 10, + picture_medium = "https://example.com/p1.jpg", + user = new { name = "User 1" } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.SearchPlaylistsAsync("test", 1); + + // Assert + Assert.Single(result); + } + + [Fact] + public async Task SearchPlaylistsAsync_WithEmptyResults_ReturnsEmptyList() + { + // Arrange + var deezerResponse = new + { + data = new object[] { } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.SearchPlaylistsAsync("nonexistent"); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetPlaylistAsync_WithValidId_ReturnsPlaylist() + { + // Arrange + var deezerResponse = new + { + id = 12345, + title = "Best Of Jazz", + description = "The best jazz tracks", + nb_tracks = 100, + picture_medium = "https://example.com/jazz.jpg", + user = new { name = "Jazz Lover" } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.GetPlaylistAsync("deezer", "12345"); + + // Assert + Assert.NotNull(result); + Assert.Equal("Best Of Jazz", result.Name); + Assert.Equal(100, result.TrackCount); + Assert.Equal("pl-deezer-12345", result.Id); + } + + [Fact] + public async Task GetPlaylistAsync_WithWrongProvider_ReturnsNull() + { + // Act + var result = await _service.GetPlaylistAsync("qobuz", "12345"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetPlaylistTracksAsync_ReturnsListOfSongs() + { + // Arrange + var deezerResponse = new + { + tracks = new + { + data = new[] + { + new + { + id = 111, + title = "Track 1", + duration = 200, + track_position = 1, + disk_number = 1, + artist = new + { + id = 999, + name = "Artist A" + }, + album = new + { + id = 888, + title = "Album X", + release_date = "2020-01-15", + cover_medium = "https://example.com/cover.jpg" + } + }, + new + { + id = 222, + title = "Track 2", + duration = 180, + track_position = 2, + disk_number = 1, + artist = new + { + id = 777, + name = "Artist B" + }, + album = new + { + id = 666, + title = "Album Y", + release_date = "2021-05-20", + cover_medium = "https://example.com/cover2.jpg" + } + } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.GetPlaylistTracksAsync("deezer", "12345"); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Track 1", result[0].Title); + Assert.Equal("Artist A", result[0].Artist); + Assert.Equal("ext-deezer-111", result[0].Id); + } + + [Fact] + public async Task GetPlaylistTracksAsync_WithWrongProvider_ReturnsEmptyList() + { + // Act + var result = await _service.GetPlaylistTracksAsync("qobuz", "12345"); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetPlaylistTracksAsync_WithEmptyPlaylist_ReturnsEmptyList() + { + // Arrange + var deezerResponse = new + { + tracks = new + { + data = new object[] { } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.GetPlaylistTracksAsync("deezer", "12345"); + + // Assert + Assert.Empty(result); + } + + #endregion } diff --git a/octo-fiesta.Tests/PlaylistIdHelperTests.cs b/octo-fiesta.Tests/PlaylistIdHelperTests.cs new file mode 100644 index 0000000..cbb3516 --- /dev/null +++ b/octo-fiesta.Tests/PlaylistIdHelperTests.cs @@ -0,0 +1,375 @@ +using octo_fiesta.Services.Common; +using Xunit; + +namespace octo_fiesta.Tests; + +public class PlaylistIdHelperTests +{ + #region IsExternalPlaylist Tests + + [Fact] + public void IsExternalPlaylist_WithValidPlaylistId_ReturnsTrue() + { + // Arrange + var id = "pl-deezer-123456"; + + // Act + var result = PlaylistIdHelper.IsExternalPlaylist(id); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsExternalPlaylist_WithValidQobuzPlaylistId_ReturnsTrue() + { + // Arrange + var id = "pl-qobuz-789012"; + + // Act + var result = PlaylistIdHelper.IsExternalPlaylist(id); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsExternalPlaylist_WithUpperCasePrefix_ReturnsTrue() + { + // Arrange + var id = "PL-deezer-123456"; + + // Act + var result = PlaylistIdHelper.IsExternalPlaylist(id); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsExternalPlaylist_WithRegularAlbumId_ReturnsFalse() + { + // Arrange + var id = "ext-deezer-album-123456"; + + // Act + var result = PlaylistIdHelper.IsExternalPlaylist(id); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsExternalPlaylist_WithNullId_ReturnsFalse() + { + // Arrange + string? id = null; + + // Act + var result = PlaylistIdHelper.IsExternalPlaylist(id); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsExternalPlaylist_WithEmptyString_ReturnsFalse() + { + // Arrange + var id = ""; + + // Act + var result = PlaylistIdHelper.IsExternalPlaylist(id); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsExternalPlaylist_WithRandomString_ReturnsFalse() + { + // Arrange + var id = "random-string-123"; + + // Act + var result = PlaylistIdHelper.IsExternalPlaylist(id); + + // Assert + Assert.False(result); + } + + #endregion + + #region ParsePlaylistId Tests + + [Fact] + public void ParsePlaylistId_WithValidDeezerPlaylistId_ReturnsProviderAndExternalId() + { + // Arrange + var id = "pl-deezer-123456"; + + // Act + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); + + // Assert + Assert.Equal("deezer", provider); + Assert.Equal("123456", externalId); + } + + [Fact] + public void ParsePlaylistId_WithValidQobuzPlaylistId_ReturnsProviderAndExternalId() + { + // Arrange + var id = "pl-qobuz-789012"; + + // Act + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); + + // Assert + Assert.Equal("qobuz", provider); + Assert.Equal("789012", externalId); + } + + [Fact] + public void ParsePlaylistId_WithExternalIdContainingDashes_ParsesCorrectly() + { + // Arrange + var id = "pl-deezer-abc-def-123"; + + // Act + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); + + // Assert + Assert.Equal("deezer", provider); + Assert.Equal("abc-def-123", externalId); + } + + [Fact] + public void ParsePlaylistId_WithInvalidFormatNoProvider_ThrowsArgumentException() + { + // Arrange + var id = "pl-123456"; + + // Act & Assert + var exception = Assert.Throws(() => PlaylistIdHelper.ParsePlaylistId(id)); + Assert.Contains("Invalid playlist ID format", exception.Message); + } + + [Fact] + public void ParsePlaylistId_WithNonPlaylistId_ThrowsArgumentException() + { + // Arrange + var id = "ext-deezer-album-123456"; + + // Act & Assert + var exception = Assert.Throws(() => PlaylistIdHelper.ParsePlaylistId(id)); + Assert.Contains("Invalid playlist ID format", exception.Message); + } + + [Fact] + public void ParsePlaylistId_WithNullId_ThrowsArgumentException() + { + // Arrange + string? id = null; + + // Act & Assert + Assert.Throws(() => PlaylistIdHelper.ParsePlaylistId(id!)); + } + + [Fact] + public void ParsePlaylistId_WithEmptyString_ThrowsArgumentException() + { + // Arrange + var id = ""; + + // Act & Assert + Assert.Throws(() => PlaylistIdHelper.ParsePlaylistId(id)); + } + + [Fact] + public void ParsePlaylistId_WithOnlyPrefix_ThrowsArgumentException() + { + // Arrange + var id = "pl-"; + + // Act & Assert + var exception = Assert.Throws(() => PlaylistIdHelper.ParsePlaylistId(id)); + Assert.Contains("Invalid playlist ID format", exception.Message); + } + + #endregion + + #region CreatePlaylistId Tests + + [Fact] + public void CreatePlaylistId_WithValidDeezerProviderAndId_ReturnsCorrectFormat() + { + // Arrange + var provider = "deezer"; + var externalId = "123456"; + + // Act + var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); + + // Assert + Assert.Equal("pl-deezer-123456", result); + } + + [Fact] + public void CreatePlaylistId_WithValidQobuzProviderAndId_ReturnsCorrectFormat() + { + // Arrange + var provider = "qobuz"; + var externalId = "789012"; + + // Act + var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); + + // Assert + Assert.Equal("pl-qobuz-789012", result); + } + + [Fact] + public void CreatePlaylistId_WithUpperCaseProvider_ConvertsToLowerCase() + { + // Arrange + var provider = "DEEZER"; + var externalId = "123456"; + + // Act + var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); + + // Assert + Assert.Equal("pl-deezer-123456", result); + } + + [Fact] + public void CreatePlaylistId_WithMixedCaseProvider_ConvertsToLowerCase() + { + // Arrange + var provider = "DeEzEr"; + var externalId = "123456"; + + // Act + var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); + + // Assert + Assert.Equal("pl-deezer-123456", result); + } + + [Fact] + public void CreatePlaylistId_WithExternalIdContainingDashes_PreservesDashes() + { + // Arrange + var provider = "deezer"; + var externalId = "abc-def-123"; + + // Act + var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); + + // Assert + Assert.Equal("pl-deezer-abc-def-123", result); + } + + [Fact] + public void CreatePlaylistId_WithNullProvider_ThrowsArgumentException() + { + // Arrange + string? provider = null; + var externalId = "123456"; + + // Act & Assert + var exception = Assert.Throws(() => PlaylistIdHelper.CreatePlaylistId(provider!, externalId)); + Assert.Contains("Provider cannot be null or empty", exception.Message); + } + + [Fact] + public void CreatePlaylistId_WithEmptyProvider_ThrowsArgumentException() + { + // Arrange + var provider = ""; + var externalId = "123456"; + + // Act & Assert + var exception = Assert.Throws(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId)); + Assert.Contains("Provider cannot be null or empty", exception.Message); + } + + [Fact] + public void CreatePlaylistId_WithNullExternalId_ThrowsArgumentException() + { + // Arrange + var provider = "deezer"; + string? externalId = null; + + // Act & Assert + var exception = Assert.Throws(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId!)); + Assert.Contains("External ID cannot be null or empty", exception.Message); + } + + [Fact] + public void CreatePlaylistId_WithEmptyExternalId_ThrowsArgumentException() + { + // Arrange + var provider = "deezer"; + var externalId = ""; + + // Act & Assert + var exception = Assert.Throws(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId)); + Assert.Contains("External ID cannot be null or empty", exception.Message); + } + + #endregion + + #region Round-Trip Tests + + [Fact] + public void RoundTrip_CreateAndParse_ReturnsOriginalValues() + { + // Arrange + var originalProvider = "deezer"; + var originalExternalId = "123456"; + + // Act + var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId); + var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + + // Assert + Assert.Equal(originalProvider, parsedProvider); + Assert.Equal(originalExternalId, parsedExternalId); + } + + [Fact] + public void RoundTrip_CreateWithUpperCaseAndParse_ReturnsLowerCaseProvider() + { + // Arrange + var originalProvider = "QOBUZ"; + var originalExternalId = "789012"; + + // Act + var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId); + var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + + // Assert + Assert.Equal("qobuz", parsedProvider); // Converted to lowercase + Assert.Equal(originalExternalId, parsedExternalId); + } + + [Fact] + public void RoundTrip_WithComplexExternalId_PreservesValue() + { + // Arrange + var originalProvider = "deezer"; + var originalExternalId = "abc-123-def-456"; + + // Act + var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId); + var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + + // Assert + Assert.Equal(originalProvider, parsedProvider); + Assert.Equal(originalExternalId, parsedExternalId); + } + + #endregion +} diff --git a/octo-fiesta.Tests/QobuzDownloadServiceTests.cs b/octo-fiesta.Tests/QobuzDownloadServiceTests.cs index 1fbba88..0027d03 100644 --- a/octo-fiesta.Tests/QobuzDownloadServiceTests.cs +++ b/octo-fiesta.Tests/QobuzDownloadServiceTests.cs @@ -22,6 +22,7 @@ public class QobuzDownloadServiceTests : IDisposable private readonly Mock _metadataServiceMock; private readonly Mock> _bundleServiceLoggerMock; private readonly Mock> _loggerMock; + private readonly Mock _serviceProviderMock; private readonly IConfiguration _configuration; private readonly string _testDownloadPath; private QobuzBundleService _bundleService; @@ -41,6 +42,7 @@ public class QobuzDownloadServiceTests : IDisposable _metadataServiceMock = new Mock(); _bundleServiceLoggerMock = new Mock>(); _loggerMock = new Mock>(); + _serviceProviderMock = new Mock(); // Create a real QobuzBundleService for testing (it will use the mocked HttpClient) _bundleService = new QobuzBundleService(_httpClientFactoryMock.Object, _bundleServiceLoggerMock.Object); @@ -94,6 +96,7 @@ public class QobuzDownloadServiceTests : IDisposable _bundleService, subsonicSettings, qobuzSettings, + _serviceProviderMock.Object, _loggerMock.Object); } diff --git a/octo-fiesta.Tests/QobuzMetadataServiceTests.cs b/octo-fiesta.Tests/QobuzMetadataServiceTests.cs new file mode 100644 index 0000000..5789942 --- /dev/null +++ b/octo-fiesta.Tests/QobuzMetadataServiceTests.cs @@ -0,0 +1,659 @@ +using octo_fiesta.Services.Qobuz; +using octo_fiesta.Models.Domain; +using octo_fiesta.Models.Settings; +using octo_fiesta.Models.Subsonic; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using System.Net; + +namespace octo_fiesta.Tests; + +public class QobuzMetadataServiceTests +{ + private readonly Mock _httpClientFactoryMock; + private readonly Mock _httpMessageHandlerMock; + private readonly Mock _bundleServiceMock; + private readonly Mock> _loggerMock; + private readonly QobuzMetadataService _service; + + public QobuzMetadataServiceTests() + { + _httpMessageHandlerMock = new Mock(); + var httpClient = new HttpClient(_httpMessageHandlerMock.Object); + + _httpClientFactoryMock = new Mock(); + _httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + var httpClientFactory = Mock.Of(); + var bundleLogger = Mock.Of>(); + + _bundleServiceMock = new Mock(httpClientFactory, bundleLogger); + _bundleServiceMock.Setup(b => b.GetAppIdAsync()).ReturnsAsync("fake-app-id-12345"); + + _loggerMock = new Mock>(); + + var subsonicSettings = Options.Create(new SubsonicSettings()); + var qobuzSettings = Options.Create(new QobuzSettings + { + UserAuthToken = "fake-user-auth-token", + UserId = "8807208" + }); + + _service = new QobuzMetadataService( + _httpClientFactoryMock.Object, + subsonicSettings, + qobuzSettings, + _bundleServiceMock.Object, + _loggerMock.Object); + } + + #region SearchPlaylistsAsync Tests + + [Fact] + public async Task SearchPlaylistsAsync_WithValidQuery_ReturnsPlaylists() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""playlists"": { + ""items"": [ + { + ""id"": 1578664, + ""name"": ""Jazz Classics"", + ""description"": ""Best of classic jazz music"", + ""tracks_count"": 50, + ""duration"": 12000, + ""owner"": { + ""name"": ""Qobuz Editorial"" + }, + ""created_at"": 1609459200, + ""images300"": [""https://example.com/cover.jpg""] + } + ] + } + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.SearchPlaylistsAsync("jazz", 20); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("Jazz Classics", result[0].Name); + Assert.Equal("Best of classic jazz music", result[0].Description); + Assert.Equal(50, result[0].TrackCount); + Assert.Equal(12000, result[0].Duration); + Assert.Equal("qobuz", result[0].Provider); + Assert.Equal("1578664", result[0].ExternalId); + Assert.Equal("pl-qobuz-1578664", result[0].Id); + Assert.Equal("Qobuz Editorial", result[0].CuratorName); + } + + [Fact] + public async Task SearchPlaylistsAsync_WithEmptyResults_ReturnsEmptyList() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""playlists"": { + ""items"": [] + } + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.SearchPlaylistsAsync("nonexistent", 20); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task SearchPlaylistsAsync_WhenHttpFails_ReturnsEmptyList() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.InternalServerError + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.SearchPlaylistsAsync("jazz", 20); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + #endregion + + #region GetPlaylistAsync Tests + + [Fact] + public async Task GetPlaylistAsync_WithValidId_ReturnsPlaylist() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""id"": 1578664, + ""name"": ""Best Of Jazz"", + ""description"": ""Top jazz tracks"", + ""tracks_count"": 100, + ""duration"": 24000, + ""owner"": { + ""name"": ""Qobuz Editor"" + }, + ""created_at"": 1609459200, + ""image_rectangle"": [""https://example.com/cover-large.jpg""] + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.GetPlaylistAsync("qobuz", "1578664"); + + // Assert + Assert.NotNull(result); + Assert.Equal("Best Of Jazz", result.Name); + Assert.Equal("Top jazz tracks", result.Description); + Assert.Equal(100, result.TrackCount); + Assert.Equal(24000, result.Duration); + Assert.Equal("pl-qobuz-1578664", result.Id); + Assert.Equal("Qobuz Editor", result.CuratorName); + Assert.Equal("https://example.com/cover-large.jpg", result.CoverUrl); + } + + [Fact] + public async Task GetPlaylistAsync_WithWrongProvider_ReturnsNull() + { + // Act + var result = await _service.GetPlaylistAsync("deezer", "12345"); + + // Assert + Assert.Null(result); + } + + #endregion + + #region GetPlaylistTracksAsync Tests + + [Fact] + public async Task GetPlaylistTracksAsync_WithValidId_ReturnsTracks() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""id"": 1578664, + ""name"": ""My Jazz Playlist"", + ""tracks"": { + ""items"": [ + { + ""id"": 123456789, + ""title"": ""Take Five"", + ""duration"": 324, + ""track_number"": 1, + ""media_number"": 1, + ""performer"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""album"": { + ""id"": 222, + ""title"": ""Time Out"", + ""artist"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""image"": { + ""thumbnail"": ""https://example.com/time-out.jpg"" + } + } + }, + { + ""id"": 987654321, + ""title"": ""So What"", + ""duration"": 562, + ""track_number"": 2, + ""media_number"": 1, + ""performer"": { + ""id"": 333, + ""name"": ""Miles Davis"" + }, + ""album"": { + ""id"": 444, + ""title"": ""Kind of Blue"", + ""artist"": { + ""id"": 333, + ""name"": ""Miles Davis"" + }, + ""image"": { + ""thumbnail"": ""https://example.com/kind-of-blue.jpg"" + } + } + } + ] + } + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.GetPlaylistTracksAsync("qobuz", "1578664"); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + // First track + Assert.Equal("Take Five", result[0].Title); + Assert.Equal("Dave Brubeck Quartet", result[0].Artist); + Assert.Equal("My Jazz Playlist", result[0].Album); // Album should be playlist name + Assert.Equal(1, result[0].Track); // Track index starts at 1 + Assert.Equal("ext-qobuz-song-123456789", result[0].Id); + Assert.Equal("qobuz", result[0].ExternalProvider); + Assert.Equal("123456789", result[0].ExternalId); + + // Second track + Assert.Equal("So What", result[1].Title); + Assert.Equal("Miles Davis", result[1].Artist); + Assert.Equal("My Jazz Playlist", result[1].Album); // Album should be playlist name + Assert.Equal(2, result[1].Track); // Track index increments + Assert.Equal("ext-qobuz-song-987654321", result[1].Id); + } + + [Fact] + public async Task GetPlaylistTracksAsync_WithWrongProvider_ReturnsEmptyList() + { + // Act + var result = await _service.GetPlaylistTracksAsync("deezer", "12345"); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetPlaylistTracksAsync_WhenHttpFails_ReturnsEmptyList() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.NotFound + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.GetPlaylistTracksAsync("qobuz", "999999"); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetPlaylistTracksAsync_WithMissingPlaylistName_UsesDefaultName() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""id"": 1578664, + ""tracks"": { + ""items"": [ + { + ""id"": 123, + ""title"": ""Test Track"", + ""performer"": { + ""id"": 1, + ""name"": ""Test Artist"" + }, + ""album"": { + ""id"": 2, + ""title"": ""Test Album"", + ""artist"": { + ""id"": 1, + ""name"": ""Test Artist"" + } + } + } + ] + } + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.GetPlaylistTracksAsync("qobuz", "1578664"); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("Unknown Playlist", result[0].Album); + } + + #endregion + + #region SearchSongsAsync Tests + + [Fact] + public async Task SearchSongsAsync_WithValidQuery_ReturnsSongs() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""tracks"": { + ""items"": [ + { + ""id"": 123456789, + ""title"": ""Take Five"", + ""duration"": 324, + ""track_number"": 1, + ""performer"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""album"": { + ""id"": 222, + ""title"": ""Time Out"", + ""artist"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + } + } + } + ] + } + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.SearchSongsAsync("Take Five", 20); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("Take Five", result[0].Title); + Assert.Equal("Dave Brubeck Quartet", result[0].Artist); + } + + #endregion + + #region SearchAlbumsAsync Tests + + [Fact] + public async Task SearchAlbumsAsync_WithValidQuery_ReturnsAlbums() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""albums"": { + ""items"": [ + { + ""id"": 222, + ""title"": ""Time Out"", + ""tracks_count"": 7, + ""artist"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""release_date_original"": ""1959-12-14"" + } + ] + } + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.SearchAlbumsAsync("Time Out", 20); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("Time Out", result[0].Title); + Assert.Equal("Dave Brubeck Quartet", result[0].Artist); + Assert.Equal(1959, result[0].Year); + } + + #endregion + + #region GetSongAsync Tests + + [Fact] + public async Task GetSongAsync_WithValidId_ReturnsSong() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""id"": 123456789, + ""title"": ""Take Five"", + ""duration"": 324, + ""track_number"": 1, + ""isrc"": ""USCO10300456"", + ""copyright"": ""(P) 1959 Columbia Records"", + ""performer"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""composer"": { + ""id"": 999, + ""name"": ""Paul Desmond"" + }, + ""album"": { + ""id"": 222, + ""title"": ""Time Out"", + ""tracks_count"": 7, + ""release_date_original"": ""1959-12-14"", + ""artist"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""genres_list"": [""Jazz"", ""Jazz→Cool Jazz""] + } + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.GetSongAsync("qobuz", "123456789"); + + // Assert + Assert.NotNull(result); + Assert.Equal("Take Five", result.Title); + Assert.Equal("Dave Brubeck Quartet", result.Artist); + Assert.Equal("Time Out", result.Album); + Assert.Equal("USCO10300456", result.Isrc); + Assert.Equal("℗ 1959 Columbia Records", result.Copyright); + Assert.Equal(1959, result.Year); + Assert.Equal("1959-12-14", result.ReleaseDate); + Assert.Contains("Paul Desmond", result.Contributors); + Assert.Equal("Jazz, Cool Jazz", result.Genre); + } + + [Fact] + public async Task GetSongAsync_WithWrongProvider_ReturnsNull() + { + // Act + var result = await _service.GetSongAsync("deezer", "123456789"); + + // Assert + Assert.Null(result); + } + + #endregion + + #region GetAlbumAsync Tests + + [Fact] + public async Task GetAlbumAsync_WithValidId_ReturnsAlbumWithTracks() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ + ""id"": 222, + ""title"": ""Time Out"", + ""tracks_count"": 2, + ""release_date_original"": ""1959-12-14"", + ""artist"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""genres_list"": [""Jazz""], + ""tracks"": { + ""items"": [ + { + ""id"": 1, + ""title"": ""Blue Rondo à la Turk"", + ""track_number"": 1, + ""performer"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""album"": { + ""id"": 222, + ""title"": ""Time Out"", + ""artist"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + } + } + }, + { + ""id"": 2, + ""title"": ""Take Five"", + ""track_number"": 2, + ""performer"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + }, + ""album"": { + ""id"": 222, + ""title"": ""Time Out"", + ""artist"": { + ""id"": 111, + ""name"": ""Dave Brubeck Quartet"" + } + } + } + ] + } + }") + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + + // Act + var result = await _service.GetAlbumAsync("qobuz", "222"); + + // Assert + Assert.NotNull(result); + Assert.Equal("Time Out", result.Title); + Assert.Equal("Dave Brubeck Quartet", result.Artist); + Assert.Equal(1959, result.Year); + Assert.Equal(2, result.Songs.Count); + Assert.Equal("Blue Rondo à la Turk", result.Songs[0].Title); + Assert.Equal("Take Five", result.Songs[1].Title); + } + + [Fact] + public async Task GetAlbumAsync_WithWrongProvider_ReturnsNull() + { + // Act + var result = await _service.GetAlbumAsync("deezer", "222"); + + // Assert + Assert.Null(result); + } + + #endregion +} diff --git a/octo-fiesta.Tests/SubsonicModelMapperTests.cs b/octo-fiesta.Tests/SubsonicModelMapperTests.cs index 98249b8..f0d7e41 100644 --- a/octo-fiesta.Tests/SubsonicModelMapperTests.cs +++ b/octo-fiesta.Tests/SubsonicModelMapperTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Moq; using octo_fiesta.Models.Domain; using octo_fiesta.Models.Search; +using octo_fiesta.Models.Subsonic; using octo_fiesta.Services.Subsonic; using System.Text; using System.Text.Json; @@ -187,39 +188,12 @@ public class SubsonicModelMapperTests // Act var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( - localSongs, new List(), new List(), externalResult, true); + localSongs, new List(), new List(), externalResult, new List(), true); // Assert Assert.Equal(2, mergedSongs.Count); } - [Fact] - public void MergeSearchResults_Json_DeduplicatesArtists() - { - // Arrange - var localArtists = new List - { - new Dictionary { ["id"] = "local1", ["name"] = "Test Artist" } - }; - var externalResult = new SearchResult - { - Songs = new List(), - Albums = new List(), - Artists = new List - { - new Artist { Id = "ext1", Name = "Test Artist" }, // Same name - should be filtered - new Artist { Id = "ext2", Name = "Different Artist" } // Different name - should be included - } - }; - - // Act - var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( - new List(), new List(), localArtists, externalResult, true); - - // Assert - Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered) - } - [Fact] public void MergeSearchResults_Json_CaseInsensitiveDeduplication() { @@ -240,7 +214,7 @@ public class SubsonicModelMapperTests // Act var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( - new List(), new List(), localArtists, externalResult, true); + new List(), new List(), localArtists, externalResult, new List(), true); // Assert Assert.Single(mergedArtists); // Only the local artist @@ -267,7 +241,7 @@ public class SubsonicModelMapperTests // Act var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( - localSongs, new List(), new List(), externalResult, false); + localSongs, new List(), new List(), externalResult, new List(), false); // Assert Assert.Equal(2, mergedSongs.Count); @@ -294,7 +268,7 @@ public class SubsonicModelMapperTests // Act var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( - new List(), new List(), localArtists, externalResult, false); + new List(), new List(), localArtists, externalResult, new List(), false); // Assert Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered) @@ -313,7 +287,7 @@ public class SubsonicModelMapperTests // Act var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( - new List(), new List(), new List(), externalResult, true); + new List(), new List(), new List(), externalResult, new List(), true); // Assert Assert.Single(mergedSongs); @@ -337,7 +311,7 @@ public class SubsonicModelMapperTests // Act var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( - localSongs, localAlbums, localArtists, externalResult, true); + localSongs, localAlbums, localArtists, externalResult, new List(), true); // Assert Assert.Single(mergedSongs); diff --git a/octo-fiesta/Controllers/SubSonicController.cs b/octo-fiesta/Controllers/SubSonicController.cs index 0ad27a0..e2e49f6 100644 --- a/octo-fiesta/Controllers/SubSonicController.cs +++ b/octo-fiesta/Controllers/SubSonicController.cs @@ -9,6 +9,7 @@ using octo_fiesta.Models.Download; using octo_fiesta.Models.Search; using octo_fiesta.Models.Subsonic; using octo_fiesta.Services; +using octo_fiesta.Services.Common; using octo_fiesta.Services.Local; using octo_fiesta.Services.Subsonic; @@ -26,6 +27,7 @@ public class SubsonicController : ControllerBase private readonly SubsonicResponseBuilder _responseBuilder; private readonly SubsonicModelMapper _modelMapper; private readonly SubsonicProxyService _proxyService; + private readonly PlaylistSyncService? _playlistSyncService; private readonly ILogger _logger; public SubsonicController( @@ -37,7 +39,8 @@ public class SubsonicController : ControllerBase SubsonicResponseBuilder responseBuilder, SubsonicModelMapper modelMapper, SubsonicProxyService proxyService, - ILogger logger) + ILogger logger, + PlaylistSyncService? playlistSyncService = null) { _subsonicSettings = subsonicSettings.Value; _metadataService = metadataService; @@ -47,6 +50,7 @@ public class SubsonicController : ControllerBase _responseBuilder = responseBuilder; _modelMapper = modelMapper; _proxyService = proxyService; + _playlistSyncService = playlistSyncService; _logger = logger; if (string.IsNullOrWhiteSpace(_subsonicSettings.Url)) @@ -96,13 +100,19 @@ public class SubsonicController : ControllerBase int.TryParse(parameters.GetValueOrDefault("albumCount", "20"), out var ac) ? ac : 20, int.TryParse(parameters.GetValueOrDefault("artistCount", "20"), out var arc) ? arc : 20 ); + + // Search playlists if enabled + Task> playlistTask = _subsonicSettings.EnableExternalPlaylists + ? _metadataService.SearchPlaylistsAsync(cleanQuery, ac) // Use same limit as albums + : Task.FromResult(new List()); - await Task.WhenAll(subsonicTask, externalTask); + await Task.WhenAll(subsonicTask, externalTask, playlistTask); var subsonicResult = await subsonicTask; var externalResult = await externalTask; + var playlistResult = await playlistTask; - return MergeSearchResults(subsonicResult, externalResult, format); + return MergeSearchResults(subsonicResult, externalResult, playlistResult, format); } /// @@ -339,12 +349,54 @@ public class SubsonicController : ControllerBase { return _responseBuilder.CreateError(format, 10, "Missing id parameter"); } + + // Check if this is an external playlist + if (PlaylistIdHelper.IsExternalPlaylist(id)) + { + try + { + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); + + // Get playlist metadata + var playlist = await _metadataService.GetPlaylistAsync(provider, externalId); + if (playlist == null) + { + return _responseBuilder.CreateError(format, 70, "Playlist not found"); + } + + // Get playlist tracks + var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId); + + // Add all tracks to playlist cache so when they're played, we know they belong to this playlist + if (_playlistSyncService != null) + { + foreach (var track in tracks) + { + if (!string.IsNullOrEmpty(track.ExternalId)) + { + var trackId = $"ext-{provider}-{track.ExternalId}"; + _playlistSyncService.AddTrackToPlaylistCache(trackId, id); + } + } + + _logger.LogDebug("Added {TrackCount} tracks to playlist cache for {PlaylistId}", tracks.Count, id); + } + + // Convert to album response (playlist as album) + return _responseBuilder.CreatePlaylistAsAlbumResponse(format, playlist, tracks); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting playlist {Id}", id); + return _responseBuilder.CreateError(format, 70, "Playlist not found"); + } + } - var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id); + var (isExternal, albumProvider, albumExternalId) = _localLibraryService.ParseSongId(id); if (isExternal) { - var album = await _metadataService.GetAlbumAsync(provider!, externalId!); + var album = await _metadataService.GetAlbumAsync(albumProvider!, albumExternalId!); if (album == null) { @@ -491,8 +543,39 @@ public class SubsonicController : ControllerBase { return NotFound(); } + + // Check if this is a playlist cover art request + if (PlaylistIdHelper.IsExternalPlaylist(id)) + { + try + { + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); + var playlist = await _metadataService.GetPlaylistAsync(provider, externalId); + + if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl)) + { + return NotFound(); + } + + // Download and return the cover image + var imageResponse = await new HttpClient().GetAsync(playlist.CoverUrl); + if (!imageResponse.IsSuccessStatusCode) + { + return NotFound(); + } + + var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync(); + var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg"; + return File(imageBytes, contentType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting playlist cover art for {Id}", id); + return NotFound(); + } + } - var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(id); + var (isExternal, coverProvider, type, coverExternalId) = _localLibraryService.ParseExternalId(id); if (!isExternal) { @@ -514,7 +597,7 @@ public class SubsonicController : ControllerBase switch (type) { case "artist": - var artist = await _metadataService.GetArtistAsync(provider!, externalId!); + var artist = await _metadataService.GetArtistAsync(coverProvider!, coverExternalId!); if (artist?.ImageUrl != null) { coverUrl = artist.ImageUrl; @@ -522,7 +605,7 @@ public class SubsonicController : ControllerBase break; case "album": - var album = await _metadataService.GetAlbumAsync(provider!, externalId!); + var album = await _metadataService.GetAlbumAsync(coverProvider!, coverExternalId!); if (album?.CoverArtUrl != null) { coverUrl = album.CoverArtUrl; @@ -532,7 +615,7 @@ public class SubsonicController : ControllerBase case "song": default: // For songs, try to get from song first, then album - var song = await _metadataService.GetSongAsync(provider!, externalId!); + var song = await _metadataService.GetSongAsync(coverProvider!, coverExternalId!); if (song?.CoverArtUrl != null) { coverUrl = song.CoverArtUrl; @@ -540,7 +623,7 @@ public class SubsonicController : ControllerBase else { // Fallback: try album with same ID (legacy behavior) - var albumFallback = await _metadataService.GetAlbumAsync(provider!, externalId!); + var albumFallback = await _metadataService.GetAlbumAsync(coverProvider!, coverExternalId!); if (albumFallback?.CoverArtUrl != null) { coverUrl = albumFallback.CoverArtUrl; @@ -569,6 +652,7 @@ public class SubsonicController : ControllerBase private IActionResult MergeSearchResults( (byte[]? Body, string? ContentType, bool Success) subsonicResult, SearchResult externalResult, + List playlistResult, string format) { var (localSongs, localAlbums, localArtists) = subsonicResult.Success && subsonicResult.Body != null @@ -580,7 +664,8 @@ public class SubsonicController : ControllerBase localSongs, localAlbums, localArtists, - externalResult, + externalResult, + playlistResult, isJson); if (isJson) @@ -643,7 +728,60 @@ public class SubsonicController : ControllerBase } #endregion - + + /// + /// Stars (favorites) an item. For playlists, this triggers a full download. + /// + [HttpGet, HttpPost] + [Route("rest/star")] + [Route("rest/star.view")] + public async Task Star() + { + var parameters = await ExtractAllParameters(); + var format = parameters.GetValueOrDefault("f", "xml"); + + // Check if this is a playlist + var playlistId = parameters.GetValueOrDefault("id", ""); + + if (!string.IsNullOrEmpty(playlistId) && PlaylistIdHelper.IsExternalPlaylist(playlistId)) + { + if (_playlistSyncService == null) + { + return _responseBuilder.CreateError(format, 0, "Playlist functionality is not enabled"); + } + + _logger.LogInformation("Starring external playlist {PlaylistId}, triggering download", playlistId); + + // Trigger playlist download in background + _ = Task.Run(async () => + { + try + { + await _playlistSyncService.DownloadFullPlaylistAsync(playlistId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to download playlist {PlaylistId}", playlistId); + } + }); + + // Return success response immediately + return _responseBuilder.CreateResponse(format, "starred", new { }); + } + + // For non-playlist items, relay to real Subsonic server + try + { + var result = await _proxyService.RelayAsync("rest/star", parameters); + var contentType = result.ContentType ?? $"application/{format}"; + return File(result.Body, contentType); + } + catch (HttpRequestException ex) + { + return _responseBuilder.CreateError(format, 0, $"Error connecting to Subsonic server: {ex.Message}"); + } + } + // Generic endpoint to handle all subsonic API calls [HttpGet, HttpPost] [Route("{**endpoint}")] diff --git a/octo-fiesta/Models/Settings/SubsonicSettings.cs b/octo-fiesta/Models/Settings/SubsonicSettings.cs index 7377924..4f0d69e 100644 --- a/octo-fiesta/Models/Settings/SubsonicSettings.cs +++ b/octo-fiesta/Models/Settings/SubsonicSettings.cs @@ -113,4 +113,20 @@ public class SubsonicSettings /// Only applies when StorageMode is Cache /// public int CacheDurationHours { get; set; } = 1; + + /// + /// Enable external playlist search and streaming (default: true) + /// Environment variable: ENABLE_EXTERNAL_PLAYLISTS + /// When enabled, users can search for playlists from the configured music provider + /// Playlists appear as "albums" in search results with genre "Playlist" + /// + public bool EnableExternalPlaylists { get; set; } = true; + + /// + /// Directory name for storing playlist .m3u files (default: "playlists") + /// Environment variable: PLAYLISTS_DIRECTORY + /// Relative to the music library root directory + /// Playlist files will be stored in {MusicDirectory}/{PlaylistsDirectory}/ + /// + public string PlaylistsDirectory { get; set; } = "playlists"; } \ No newline at end of file diff --git a/octo-fiesta/Models/Subsonic/ExternalPlaylist.cs b/octo-fiesta/Models/Subsonic/ExternalPlaylist.cs new file mode 100644 index 0000000..c86c726 --- /dev/null +++ b/octo-fiesta/Models/Subsonic/ExternalPlaylist.cs @@ -0,0 +1,58 @@ +namespace octo_fiesta.Models.Subsonic; + +/// +/// Represents a playlist from an external music provider (Deezer, Qobuz). +/// +public class ExternalPlaylist +{ + /// + /// Unique identifier in the format "pl-{provider}-{externalId}" + /// Example: "pl-deezer-123456" or "pl-qobuz-789" + /// + public string Id { get; set; } = string.Empty; + + /// + /// Playlist name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Playlist description + /// + public string? Description { get; set; } + + /// + /// Name of the playlist creator/curator + /// + public string? CuratorName { get; set; } + + /// + /// Provider name ("deezer" or "qobuz") + /// + public string Provider { get; set; } = string.Empty; + + /// + /// External ID from the provider (without "pl-" prefix) + /// + public string ExternalId { get; set; } = string.Empty; + + /// + /// Number of tracks in the playlist + /// + public int TrackCount { get; set; } + + /// + /// Total duration in seconds + /// + public int Duration { get; set; } + + /// + /// Cover art URL from the provider + /// + public string? CoverUrl { get; set; } + + /// + /// Playlist creation date + /// + public DateTime? CreatedDate { get; set; } +} diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 6eaddcf..7f540aa 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -31,6 +31,7 @@ builder.Services.Configure( // Get the configured music service var musicService = builder.Configuration.GetValue("Subsonic:MusicService"); +var enableExternalPlaylists = builder.Configuration.GetValue("Subsonic:EnableExternalPlaylists", true); // Business services // Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting) @@ -43,16 +44,35 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Register music service based on configuration +// IMPORTANT: Primary service MUST be registered LAST because ASP.NET Core DI +// will use the last registered implementation when injecting IMusicMetadataService/IDownloadService if (musicService == MusicService.Qobuz) { - // Qobuz services + // If playlists enabled, register Deezer FIRST (secondary provider) + if (enableExternalPlaylists) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + + // Qobuz services (primary) - registered LAST to be injected by default builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); } else { - // Deezer services (default) + // If playlists enabled, register Qobuz FIRST (secondary provider) + if (enableExternalPlaylists) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + + // Deezer services (primary, default) - registered LAST to be injected by default builder.Services.AddSingleton(); builder.Services.AddSingleton(); } diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs index bb70f79..d96221e 100644 --- a/octo-fiesta/Services/Common/BaseDownloadService.cs +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -4,6 +4,7 @@ using octo_fiesta.Models.Download; using octo_fiesta.Models.Search; using octo_fiesta.Models.Subsonic; using octo_fiesta.Services.Local; +using octo_fiesta.Services.Subsonic; using TagLib; using IOFile = System.IO.File; @@ -21,6 +22,7 @@ public abstract class BaseDownloadService : IDownloadService protected readonly IMusicMetadataService MetadataService; protected readonly SubsonicSettings SubsonicSettings; protected readonly ILogger Logger; + protected readonly IServiceProvider ServiceProvider; protected readonly string DownloadPath; protected readonly string CachePath; @@ -38,12 +40,14 @@ public abstract class BaseDownloadService : IDownloadService ILocalLibraryService localLibraryService, IMusicMetadataService metadataService, SubsonicSettings subsonicSettings, + IServiceProvider serviceProvider, ILogger logger) { Configuration = configuration; LocalLibraryService = localLibraryService; MetadataService = metadataService; SubsonicSettings = subsonicSettings; + ServiceProvider = serviceProvider; Logger = logger; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; @@ -79,6 +83,30 @@ public abstract class BaseDownloadService : IDownloadService return info; } + public async Task GetLocalPathIfExistsAsync(string externalProvider, string externalId) + { + if (externalProvider != ProviderName) + { + return null; + } + + // Check local library + var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); + if (localPath != null && IOFile.Exists(localPath)) + { + return localPath; + } + + // Check cache directory + var cachedPath = GetCachedFilePath(externalProvider, externalId); + if (cachedPath != null && IOFile.Exists(cachedPath)) + { + return cachedPath; + } + + return null; + } + public abstract Task IsAvailableAsync(); public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId) @@ -240,6 +268,25 @@ public abstract class BaseDownloadService : IDownloadService song.LocalPath = localPath; + // Check if this track belongs to a playlist and update M3U + try + { + var playlistSyncService = ServiceProvider.GetService(typeof(PlaylistSyncService)) as PlaylistSyncService; + if (playlistSyncService != null) + { + var playlistId = playlistSyncService.GetPlaylistIdForTrack(songId); + if (playlistId != null) + { + Logger.LogInformation("Track {SongId} belongs to playlist {PlaylistId}, adding to M3U", songId, playlistId); + await playlistSyncService.AddTrackToM3UAsync(playlistId, song, localPath); + } + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to update playlist M3U for track {SongId}", songId); + } + // Only register and scan if NOT in cache mode if (!isCache) { diff --git a/octo-fiesta/Services/Common/PlaylistIdHelper.cs b/octo-fiesta/Services/Common/PlaylistIdHelper.cs new file mode 100644 index 0000000..5440826 --- /dev/null +++ b/octo-fiesta/Services/Common/PlaylistIdHelper.cs @@ -0,0 +1,76 @@ +namespace octo_fiesta.Services.Common; + +/// +/// Helper class for handling external playlist IDs. +/// Playlist IDs use the format: "pl-{provider}-{externalId}" +/// Example: "pl-deezer-123456", "pl-qobuz-789" +/// +public static class PlaylistIdHelper +{ + private const string PlaylistPrefix = "pl-"; + + /// + /// Checks if an ID represents an external playlist. + /// + /// The ID to check + /// True if the ID starts with "pl-", false otherwise + public static bool IsExternalPlaylist(string? id) + { + return !string.IsNullOrEmpty(id) && id.StartsWith(PlaylistPrefix, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Parses a playlist ID to extract provider and external ID. + /// + /// The playlist ID in format "pl-{provider}-{externalId}" + /// A tuple containing (provider, externalId) + /// Thrown if the ID format is invalid + public static (string provider, string externalId) ParsePlaylistId(string id) + { + if (!IsExternalPlaylist(id)) + { + throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id)); + } + + // Remove "pl-" prefix + var withoutPrefix = id.Substring(PlaylistPrefix.Length); + + // Split by first dash to get provider and externalId + var dashIndex = withoutPrefix.IndexOf('-'); + if (dashIndex == -1) + { + throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id)); + } + + var provider = withoutPrefix.Substring(0, dashIndex); + var externalId = withoutPrefix.Substring(dashIndex + 1); + + if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(externalId)) + { + throw new ArgumentException($"Invalid playlist ID format. Provider or external ID is empty in '{id}'", nameof(id)); + } + + return (provider, externalId); + } + + /// + /// Creates a playlist ID from provider and external ID. + /// + /// The provider name (e.g., "deezer", "qobuz") + /// The external ID from the provider + /// A playlist ID in format "pl-{provider}-{externalId}" + public static string CreatePlaylistId(string provider, string externalId) + { + if (string.IsNullOrEmpty(provider)) + { + throw new ArgumentException("Provider cannot be null or empty", nameof(provider)); + } + + if (string.IsNullOrEmpty(externalId)) + { + throw new ArgumentException("External ID cannot be null or empty", nameof(externalId)); + } + + return $"{PlaylistPrefix}{provider.ToLowerInvariant()}-{externalId}"; + } +} diff --git a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs index e813da5..0f86a38 100644 --- a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs +++ b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs @@ -50,8 +50,9 @@ public class DeezerDownloadService : BaseDownloadService IMusicMetadataService metadataService, IOptions subsonicSettings, IOptions deezerSettings, + IServiceProvider serviceProvider, ILogger logger) - : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger) + : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger) { _httpClient = httpClientFactory.CreateClient(); diff --git a/octo-fiesta/Services/Deezer/DeezerMetadataService.cs b/octo-fiesta/Services/Deezer/DeezerMetadataService.cs index 43aa38c..33b14fe 100644 --- a/octo-fiesta/Services/Deezer/DeezerMetadataService.cs +++ b/octo-fiesta/Services/Deezer/DeezerMetadataService.cs @@ -231,6 +231,12 @@ public class DeezerMetadataService : IMusicMetadataService { // Pass the album artist to ensure proper folder organization var song = ParseDeezerTrack(track, trackIndex, album.Artist); + + // Ensure album metadata is set (tracks in album response may not have full album object) + song.Album = album.Title; + song.AlbumId = album.Id; + song.AlbumArtist = album.Artist; + if (ShouldIncludeSong(song)) { album.Songs.Add(song); @@ -511,6 +517,163 @@ public class DeezerMetadataService : IMusicMetadataService }; } + public async Task> SearchPlaylistsAsync(string query, int limit = 20) + { + try + { + var url = $"{BaseUrl}/search/playlist?q={Uri.EscapeDataString(query)}&limit={limit}"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var playlists = new List(); + if (result.RootElement.TryGetProperty("data", out var data)) + { + foreach (var playlist in data.EnumerateArray()) + { + playlists.Add(ParseDeezerPlaylist(playlist)); + } + } + + return playlists; + } + catch + { + return new List(); + } + } + + public async Task GetPlaylistAsync(string externalProvider, string externalId) + { + if (externalProvider != "deezer") return null; + + try + { + var url = $"{BaseUrl}/playlist/{externalId}"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(); + var playlistElement = JsonDocument.Parse(json).RootElement; + + if (playlistElement.TryGetProperty("error", out _)) return null; + + return ParseDeezerPlaylist(playlistElement); + } + catch + { + return null; + } + } + + public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId) + { + if (externalProvider != "deezer") return new List(); + + try + { + var url = $"{BaseUrl}/playlist/{externalId}"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var playlistElement = JsonDocument.Parse(json).RootElement; + + if (playlistElement.TryGetProperty("error", out _)) return new List(); + + var songs = new List(); + + // Get playlist name for album field + var playlistName = playlistElement.TryGetProperty("title", out var titleEl) + ? titleEl.GetString() ?? "Unknown Playlist" + : "Unknown Playlist"; + + if (playlistElement.TryGetProperty("tracks", out var tracks) && + tracks.TryGetProperty("data", out var tracksData)) + { + int trackIndex = 1; + foreach (var track in tracksData.EnumerateArray()) + { + // For playlists, use the track's own artist (not a single album artist) + var song = ParseDeezerTrack(track, trackIndex); + + // Override album name to be the playlist name + song.Album = playlistName; + + if (ShouldIncludeSong(song)) + { + songs.Add(song); + } + trackIndex++; + } + } + + return songs; + } + catch + { + return new List(); + } + } + + private ExternalPlaylist ParseDeezerPlaylist(JsonElement playlist) + { + var externalId = playlist.GetProperty("id").GetInt64().ToString(); + + // Get curator/creator name + string? curatorName = null; + if (playlist.TryGetProperty("user", out var user) && + user.TryGetProperty("name", out var userName)) + { + curatorName = userName.GetString(); + } + else if (playlist.TryGetProperty("creator", out var creator) && + creator.TryGetProperty("name", out var creatorName)) + { + curatorName = creatorName.GetString(); + } + + // Get creation date + DateTime? createdDate = null; + if (playlist.TryGetProperty("creation_date", out var creationDateEl)) + { + var dateStr = creationDateEl.GetString(); + if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var date)) + { + createdDate = date; + } + } + + return new ExternalPlaylist + { + Id = Common.PlaylistIdHelper.CreatePlaylistId("deezer", externalId), + Name = playlist.GetProperty("title").GetString() ?? "", + Description = playlist.TryGetProperty("description", out var desc) + ? desc.GetString() + : null, + CuratorName = curatorName, + Provider = "deezer", + ExternalId = externalId, + TrackCount = playlist.TryGetProperty("nb_tracks", out var nbTracks) + ? nbTracks.GetInt32() + : 0, + Duration = playlist.TryGetProperty("duration", out var duration) + ? duration.GetInt32() + : 0, + CoverUrl = playlist.TryGetProperty("picture_medium", out var picture) + ? picture.GetString() + : (playlist.TryGetProperty("picture_big", out var pictureBig) + ? pictureBig.GetString() + : null), + CreatedDate = createdDate + }; + } + /// /// Determines whether a song should be included based on the explicit content filter setting /// diff --git a/octo-fiesta/Services/IDownloadService.cs b/octo-fiesta/Services/IDownloadService.cs index d53757c..9c28ff0 100644 --- a/octo-fiesta/Services/IDownloadService.cs +++ b/octo-fiesta/Services/IDownloadService.cs @@ -42,6 +42,14 @@ public interface IDownloadService /// DownloadInfo? GetDownloadStatus(string songId); + /// + /// Gets the local path for a song if it has been downloaded already + /// + /// The provider (deezer, qobuz, etc.) + /// The ID on the external provider + /// The local file path if exists, null otherwise + Task GetLocalPathIfExistsAsync(string externalProvider, string externalId); + /// /// Checks if the service is properly configured and functional /// diff --git a/octo-fiesta/Services/IMusicMetadataService.cs b/octo-fiesta/Services/IMusicMetadataService.cs index fead3f6..eabdf28 100644 --- a/octo-fiesta/Services/IMusicMetadataService.cs +++ b/octo-fiesta/Services/IMusicMetadataService.cs @@ -54,4 +54,28 @@ public interface IMusicMetadataService /// Gets an artist's albums /// Task> GetArtistAlbumsAsync(string externalProvider, string externalId); + + /// + /// Searches for playlists on external providers + /// + /// Search term + /// Maximum number of results + /// List of found playlists + Task> SearchPlaylistsAsync(string query, int limit = 20); + + /// + /// Gets details of an external playlist (metadata only, not tracks) + /// + /// Provider name (e.g., "deezer", "qobuz") + /// Playlist ID from the provider + /// Playlist details or null if not found + Task GetPlaylistAsync(string externalProvider, string externalId); + + /// + /// Gets all tracks from an external playlist + /// + /// Provider name (e.g., "deezer", "qobuz") + /// Playlist ID from the provider + /// List of songs in the playlist + Task> GetPlaylistTracksAsync(string externalProvider, string externalId); } diff --git a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs index 3de4ea2..a6f2206 100644 --- a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs @@ -43,8 +43,9 @@ public class QobuzDownloadService : BaseDownloadService QobuzBundleService bundleService, IOptions subsonicSettings, IOptions qobuzSettings, + IServiceProvider serviceProvider, ILogger logger) - : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger) + : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger) { _httpClient = httpClientFactory.CreateClient(); _bundleService = bundleService; diff --git a/octo-fiesta/Services/Qobuz/QobuzMetadataService.cs b/octo-fiesta/Services/Qobuz/QobuzMetadataService.cs index 77b56ba..df97d11 100644 --- a/octo-fiesta/Services/Qobuz/QobuzMetadataService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzMetadataService.cs @@ -212,6 +212,12 @@ public class QobuzMetadataService : IMusicMetadataService foreach (var track in tracksData.EnumerateArray()) { var song = ParseQobuzTrack(track); + + // Ensure album metadata is set (tracks in album response may not have full album object) + song.Album = album.Title; + song.AlbumId = album.Id; + song.AlbumArtist = album.Artist; + if (ShouldIncludeSong(song)) { album.Songs.Add(song); @@ -305,6 +311,180 @@ public class QobuzMetadataService : IMusicMetadataService } } + public async Task> SearchPlaylistsAsync(string query, int limit = 20) + { + try + { + var appId = await _bundleService.GetAppIdAsync(); + var url = $"{BaseUrl}playlist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}"; + + var response = await GetWithAuthAsync(url); + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var playlists = new List(); + if (result.RootElement.TryGetProperty("playlists", out var playlistsData) && + playlistsData.TryGetProperty("items", out var items)) + { + foreach (var playlist in items.EnumerateArray()) + { + playlists.Add(ParseQobuzPlaylist(playlist)); + } + } + + return playlists; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to search playlists for query: {Query}", query); + return new List(); + } + } + + public async Task GetPlaylistAsync(string externalProvider, string externalId) + { + if (externalProvider != "qobuz") return null; + + try + { + var appId = await _bundleService.GetAppIdAsync(); + var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}"; + + var response = await GetWithAuthAsync(url); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(); + var playlistElement = JsonDocument.Parse(json).RootElement; + + if (playlistElement.TryGetProperty("error", out _)) return null; + + return ParseQobuzPlaylist(playlistElement); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get playlist {ExternalId}", externalId); + return null; + } + } + + public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId) + { + if (externalProvider != "qobuz") return new List(); + + try + { + var appId = await _bundleService.GetAppIdAsync(); + var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}&extra=tracks"; + + var response = await GetWithAuthAsync(url); + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var playlistElement = JsonDocument.Parse(json).RootElement; + + if (playlistElement.TryGetProperty("error", out _)) return new List(); + + var songs = new List(); + + // Get playlist name for album field + var playlistName = playlistElement.TryGetProperty("name", out var nameEl) + ? nameEl.GetString() ?? "Unknown Playlist" + : "Unknown Playlist"; + + if (playlistElement.TryGetProperty("tracks", out var tracks) && + tracks.TryGetProperty("items", out var tracksData)) + { + int trackIndex = 1; + foreach (var track in tracksData.EnumerateArray()) + { + // For playlists, use the track's own artist (not a single album artist) + var song = ParseQobuzTrack(track); + + // Override album name to be the playlist name + song.Album = playlistName; + song.Track = trackIndex; + + if (ShouldIncludeSong(song)) + { + songs.Add(song); + } + trackIndex++; + } + } + + return songs; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get playlist tracks for {ExternalId}", externalId); + return new List(); + } + } + + private ExternalPlaylist ParseQobuzPlaylist(JsonElement playlist) + { + var externalId = GetIdAsString(playlist.GetProperty("id")); + + // Get curator/creator name + string? curatorName = null; + if (playlist.TryGetProperty("owner", out var owner) && + owner.TryGetProperty("name", out var ownerName)) + { + curatorName = ownerName.GetString(); + } + + // Get creation date + DateTime? createdDate = null; + if (playlist.TryGetProperty("created_at", out var createdAtEl)) + { + var timestamp = createdAtEl.GetInt64(); + createdDate = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; + } + + // Get cover URL from images + string? coverUrl = null; + if (playlist.TryGetProperty("images300", out var images300)) + { + var imagesArray = images300.EnumerateArray().ToList(); + if (imagesArray.Count > 0) + { + coverUrl = imagesArray[0].GetString(); + } + } + else if (playlist.TryGetProperty("image_rectangle", out var imageRect)) + { + var imagesArray = imageRect.EnumerateArray().ToList(); + if (imagesArray.Count > 0) + { + coverUrl = imagesArray[0].GetString(); + } + } + + return new ExternalPlaylist + { + Id = Common.PlaylistIdHelper.CreatePlaylistId("qobuz", externalId), + Name = playlist.TryGetProperty("name", out var name) + ? name.GetString() ?? "" + : "", + Description = playlist.TryGetProperty("description", out var desc) + ? desc.GetString() + : null, + CuratorName = curatorName, + Provider = "qobuz", + ExternalId = externalId, + TrackCount = playlist.TryGetProperty("tracks_count", out var tracksCount) + ? tracksCount.GetInt32() + : 0, + Duration = playlist.TryGetProperty("duration", out var duration) + ? duration.GetInt32() + : 0, + CoverUrl = coverUrl, + CreatedDate = createdDate + }; + } + /// /// Safely gets an ID value as a string, handling both number and string types from JSON /// diff --git a/octo-fiesta/Services/Subsonic/PlaylistSyncService.cs b/octo-fiesta/Services/Subsonic/PlaylistSyncService.cs new file mode 100644 index 0000000..7e2866b --- /dev/null +++ b/octo-fiesta/Services/Subsonic/PlaylistSyncService.cs @@ -0,0 +1,375 @@ +using System.Collections.Concurrent; +using System.Text; +using Microsoft.Extensions.Options; +using octo_fiesta.Models.Domain; +using octo_fiesta.Models.Settings; +using octo_fiesta.Models.Subsonic; +using octo_fiesta.Services.Common; +using IOFile = System.IO.File; + +namespace octo_fiesta.Services.Subsonic; + +/// +/// Service responsible for downloading playlist tracks and creating M3U files +/// +public class PlaylistSyncService +{ + private readonly IMusicMetadataService _deezerMetadataService; + private readonly IMusicMetadataService _qobuzMetadataService; + private readonly IEnumerable _downloadServices; + private readonly IConfiguration _configuration; + private readonly SubsonicSettings _subsonicSettings; + private readonly ILogger _logger; + + // In-memory cache to track which playlist a track belongs to + // Key: trackId (format: ext-{provider}-{externalId}), Value: playlistId + // TTL: 5 minutes (tracks expire automatically) + private readonly ConcurrentDictionary _trackPlaylistCache = new(); + private static readonly TimeSpan CacheTTL = TimeSpan.FromMinutes(5); + + private readonly string _musicDirectory; + private readonly string _playlistDirectory; + + public PlaylistSyncService( + IEnumerable metadataServices, + IEnumerable downloadServices, + IConfiguration configuration, + IOptions subsonicSettings, + ILogger logger) + { + // Get Deezer and Qobuz metadata services + _deezerMetadataService = metadataServices.FirstOrDefault(s => s.GetType().Name.Contains("Deezer")) + ?? throw new InvalidOperationException("Deezer metadata service not found"); + _qobuzMetadataService = metadataServices.FirstOrDefault(s => s.GetType().Name.Contains("Qobuz")) + ?? throw new InvalidOperationException("Qobuz metadata service not found"); + + _downloadServices = downloadServices; + _configuration = configuration; + _subsonicSettings = subsonicSettings.Value; + _logger = logger; + + _musicDirectory = configuration["Library:DownloadPath"] ?? "./downloads"; + _playlistDirectory = Path.Combine(_musicDirectory, _subsonicSettings.PlaylistsDirectory ?? "playlists"); + + // Ensure playlists directory exists + if (!Directory.Exists(_playlistDirectory)) + { + Directory.CreateDirectory(_playlistDirectory); + } + + // Start background cleanup task for expired cache entries + _ = Task.Run(CleanupExpiredCacheEntriesAsync); + } + + /// + /// Adds a track to the playlist context cache. + /// This allows the download service to know which playlist a track belongs to. + /// + public void AddTrackToPlaylistCache(string trackId, string playlistId) + { + var expiresAt = DateTime.UtcNow.Add(CacheTTL); + _trackPlaylistCache[trackId] = (playlistId, expiresAt); + _logger.LogDebug("Added track {TrackId} to playlist cache with playlistId {PlaylistId}", trackId, playlistId); + } + + /// + /// Gets the playlist ID for a given track ID from cache. + /// Returns null if not found or expired. + /// + public string? GetPlaylistIdForTrack(string trackId) + { + if (_trackPlaylistCache.TryGetValue(trackId, out var entry)) + { + if (entry.ExpiresAt > DateTime.UtcNow) + { + return entry.PlaylistId; + } + + // Expired, remove it + _trackPlaylistCache.TryRemove(trackId, out _); + } + + return null; + } + + /// + /// Downloads all tracks from a playlist and creates an M3U file. + /// This is triggered when a user stars a playlist. + /// + public async Task DownloadFullPlaylistAsync(string playlistId, CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("Starting download for playlist {PlaylistId}", playlistId); + + // Parse playlist ID + if (!PlaylistIdHelper.IsExternalPlaylist(playlistId)) + { + _logger.LogWarning("Invalid playlist ID format: {PlaylistId}", playlistId); + return; + } + + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + + // Get playlist metadata + var metadataService = provider.ToLower() switch + { + "deezer" => _deezerMetadataService, + "qobuz" => _qobuzMetadataService, + _ => throw new NotSupportedException($"Provider '{provider}' not supported for playlists") + }; + + var playlist = await metadataService.GetPlaylistAsync(provider, externalId); + if (playlist == null) + { + _logger.LogWarning("Playlist not found: {PlaylistId}", playlistId); + return; + } + + var tracks = await metadataService.GetPlaylistTracksAsync(provider, externalId); + if (tracks == null || tracks.Count == 0) + { + _logger.LogWarning("No tracks found in playlist {PlaylistId}", playlistId); + return; + } + + _logger.LogInformation("Found {TrackCount} tracks in playlist '{PlaylistName}'", tracks.Count, playlist.Name); + + // Get the appropriate download service for this provider + var downloadService = _downloadServices.FirstOrDefault(s => + s.GetType().Name.Contains(provider, StringComparison.OrdinalIgnoreCase)); + + if (downloadService == null) + { + _logger.LogError("No download service found for provider '{Provider}'", provider); + return; + } + + // Download all tracks + var downloadedTracks = new List<(Song Song, string LocalPath)>(); + + foreach (var track in tracks) + { + try + { + if (string.IsNullOrEmpty(track.ExternalId)) + { + _logger.LogWarning("Track has no external ID, skipping: {Title}", track.Title); + continue; + } + + // Add track to playlist cache BEFORE downloading + var trackId = $"ext-{provider}-{track.ExternalId}"; + AddTrackToPlaylistCache(trackId, playlistId); + + _logger.LogInformation("Downloading track '{Artist} - {Title}'", track.Artist, track.Title); + var localPath = await downloadService.DownloadSongAsync(provider, track.ExternalId, cancellationToken); + + downloadedTracks.Add((track, localPath)); + _logger.LogDebug("Downloaded: {Path}", localPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title); + } + } + + if (downloadedTracks.Count == 0) + { + _logger.LogWarning("No tracks were successfully downloaded for playlist '{PlaylistName}'", playlist.Name); + return; + } + + // Create M3U file + await CreateM3UPlaylistAsync(playlist.Name, downloadedTracks); + + _logger.LogInformation("Playlist download completed: {DownloadedCount}/{TotalCount} tracks for '{PlaylistName}'", + downloadedTracks.Count, tracks.Count, playlist.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to download playlist {PlaylistId}", playlistId); + throw; + } + } + + /// + /// Creates an M3U playlist file with relative paths to downloaded tracks + /// + private async Task CreateM3UPlaylistAsync(string playlistName, List<(Song Song, string LocalPath)> tracks) + { + try + { + // Sanitize playlist name for file system + var fileName = PathHelper.SanitizeFileName(playlistName) + ".m3u"; + var playlistPath = Path.Combine(_playlistDirectory, fileName); + + var m3uContent = new StringBuilder(); + m3uContent.AppendLine("#EXTM3U"); + + foreach (var (song, localPath) in tracks) + { + // Calculate relative path from playlist directory to track + var relativePath = Path.GetRelativePath(_playlistDirectory, localPath); + + // Convert backslashes to forward slashes for M3U compatibility + relativePath = relativePath.Replace('\\', '/'); + + // Add EXTINF line with duration and artist - title + var duration = song.Duration ?? 0; + m3uContent.AppendLine($"#EXTINF:{duration},{song.Artist} - {song.Title}"); + m3uContent.AppendLine(relativePath); + } + + await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString()); + _logger.LogInformation("Created M3U playlist: {Path}", playlistPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create M3U playlist for '{PlaylistName}'", playlistName); + throw; + } + } + + /// + /// Adds a track to an existing M3U playlist or creates it if it doesn't exist. + /// This is called progressively as tracks are downloaded. + /// The M3U is rebuilt in the correct playlist order each time. + /// + public async Task AddTrackToM3UAsync(string playlistId, Song track, string localPath) + { + try + { + // Get playlist metadata to get the name and track order + if (!PlaylistIdHelper.IsExternalPlaylist(playlistId)) + { + _logger.LogWarning("Invalid playlist ID format: {PlaylistId}", playlistId); + return; + } + + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + + var metadataService = provider.ToLower() switch + { + "deezer" => _deezerMetadataService, + "qobuz" => _qobuzMetadataService, + _ => null + }; + + if (metadataService == null) + { + _logger.LogWarning("No metadata service found for provider '{Provider}'", provider); + return; + } + + var playlist = await metadataService.GetPlaylistAsync(provider, externalId); + if (playlist == null) + { + _logger.LogWarning("Playlist not found: {PlaylistId}", playlistId); + return; + } + + // Get all tracks from the playlist to maintain order + var allPlaylistTracks = await metadataService.GetPlaylistTracksAsync(provider, externalId); + if (allPlaylistTracks == null || allPlaylistTracks.Count == 0) + { + _logger.LogWarning("No tracks found in playlist: {PlaylistId}", playlistId); + return; + } + + // Sanitize playlist name for file system + var fileName = PathHelper.SanitizeFileName(playlist.Name) + ".m3u"; + var playlistPath = Path.Combine(_playlistDirectory, fileName); + + // Build M3U content in the correct order + var m3uContent = new StringBuilder(); + m3uContent.AppendLine("#EXTM3U"); + + int addedCount = 0; + foreach (var playlistTrack in allPlaylistTracks) + { + // Check if this track has been downloaded locally + string? trackLocalPath = null; + + // If this is the track we just downloaded + if (playlistTrack.Id == track.Id) + { + trackLocalPath = localPath; + } + else + { + // Check if track was previously downloaded + var trackProvider = playlistTrack.ExternalProvider; + var trackExternalId = playlistTrack.ExternalId; + + if (!string.IsNullOrEmpty(trackProvider) && !string.IsNullOrEmpty(trackExternalId)) + { + // Try to find the download service for this provider + var downloadService = _downloadServices.FirstOrDefault(s => + s.GetType().Name.Contains(trackProvider, StringComparison.OrdinalIgnoreCase)); + + if (downloadService != null) + { + trackLocalPath = await downloadService.GetLocalPathIfExistsAsync(trackProvider, trackExternalId); + } + } + } + + // If track is downloaded, add it to M3U + if (!string.IsNullOrEmpty(trackLocalPath) && IOFile.Exists(trackLocalPath)) + { + var relativePath = Path.GetRelativePath(_playlistDirectory, trackLocalPath); + relativePath = relativePath.Replace('\\', '/'); + + var duration = playlistTrack.Duration ?? 0; + m3uContent.AppendLine($"#EXTINF:{duration},{playlistTrack.Artist} - {playlistTrack.Title}"); + m3uContent.AppendLine(relativePath); + addedCount++; + } + } + + // Write the M3U file (overwrites existing) + await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString()); + _logger.LogInformation("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)", + playlist.Name, addedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add track to M3U playlist"); + } + } + + /// + /// Background task to clean up expired cache entries every minute + /// + private async Task CleanupExpiredCacheEntriesAsync() + { + while (true) + { + try + { + await Task.Delay(TimeSpan.FromMinutes(1)); + + var now = DateTime.UtcNow; + var expiredKeys = _trackPlaylistCache + .Where(kvp => kvp.Value.ExpiresAt <= now) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in expiredKeys) + { + _trackPlaylistCache.TryRemove(key, out _); + } + + if (expiredKeys.Count > 0) + { + _logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error during playlist cache cleanup"); + } + } + } +} diff --git a/octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs b/octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs index 79cc7f3..85cbe35 100644 --- a/octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs +++ b/octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using System.Xml.Linq; using octo_fiesta.Models.Search; +using octo_fiesta.Models.Subsonic; namespace octo_fiesta.Services.Subsonic; @@ -97,22 +98,23 @@ public class SubsonicModelMapper } /// - /// Merges local search results with external search results, deduplicating by name. + /// Merges local and external search results (songs, albums, artists, playlists). /// public (List MergedSongs, List MergedAlbums, List MergedArtists) MergeSearchResults( List localSongs, List localAlbums, List localArtists, SearchResult externalResult, + List externalPlaylists, bool isJson) { if (isJson) { - return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult); + return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult, externalPlaylists); } else { - return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult); + return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult, externalPlaylists); } } @@ -120,14 +122,17 @@ public class SubsonicModelMapper List localSongs, List localAlbums, List localArtists, - SearchResult externalResult) + SearchResult externalResult, + List externalPlaylists) { var mergedSongs = localSongs .Concat(externalResult.Songs.Select(s => _responseBuilder.ConvertSongToJson(s))) .ToList(); + // Merge albums with playlists (playlists appear as albums with genre "Playlist") var mergedAlbums = localAlbums .Concat(externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJson(a))) + .Concat(externalPlaylists.Select(p => ConvertPlaylistToAlbumJson(p))) .ToList(); // Deduplicate artists by name - prefer local artists over external ones @@ -157,7 +162,8 @@ public class SubsonicModelMapper List localSongs, List localAlbums, List localArtists, - SearchResult externalResult) + SearchResult externalResult, + List externalPlaylists) { var ns = XNamespace.Get("http://subsonic.org/restapi"); @@ -196,6 +202,11 @@ public class SubsonicModelMapper { mergedAlbums.Add(_responseBuilder.ConvertAlbumToXml(album, ns)); } + // Add playlists as albums + foreach (var playlist in externalPlaylists) + { + mergedAlbums.Add(ConvertPlaylistToAlbumXml(playlist, ns)); + } // Songs var mergedSongs = new List(); @@ -211,4 +222,81 @@ public class SubsonicModelMapper return (mergedSongs, mergedAlbums, mergedArtists); } + + /// + /// Converts an ExternalPlaylist to a JSON object representing an album. + /// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}". + /// + private Dictionary ConvertPlaylistToAlbumJson(ExternalPlaylist playlist) + { + var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}"; + if (!string.IsNullOrEmpty(playlist.CuratorName)) + { + artistName += $" {playlist.CuratorName}"; + } + + var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}"; + + var album = new Dictionary + { + ["id"] = playlist.Id, + ["name"] = playlist.Name, + ["artist"] = artistName, + ["artistId"] = artistId, + ["genre"] = "Playlist", + ["songCount"] = playlist.TrackCount, + ["duration"] = playlist.Duration + }; + + if (playlist.CreatedDate.HasValue) + { + album["year"] = playlist.CreatedDate.Value.Year; + album["created"] = playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss"); + } + + if (!string.IsNullOrEmpty(playlist.CoverUrl)) + { + album["coverArt"] = playlist.Id; + } + + return album; + } + + /// + /// Converts an ExternalPlaylist to an XML element representing an album. + /// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}". + /// + private XElement ConvertPlaylistToAlbumXml(ExternalPlaylist playlist, XNamespace ns) + { + var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}"; + if (!string.IsNullOrEmpty(playlist.CuratorName)) + { + artistName += $" {playlist.CuratorName}"; + } + + var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}"; + + var album = new XElement(ns + "album", + new XAttribute("id", playlist.Id), + new XAttribute("name", playlist.Name), + new XAttribute("artist", artistName), + new XAttribute("artistId", artistId), + new XAttribute("genre", "Playlist"), + new XAttribute("songCount", playlist.TrackCount), + new XAttribute("duration", playlist.Duration) + ); + + if (playlist.CreatedDate.HasValue) + { + album.Add(new XAttribute("year", playlist.CreatedDate.Value.Year)); + album.Add(new XAttribute("created", playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss"))); + } + + if (!string.IsNullOrEmpty(playlist.CoverUrl)) + { + album.Add(new XAttribute("coverArt", playlist.Id)); + } + + return album; + } } diff --git a/octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs b/octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs index 0ad7cbd..b2fd810 100644 --- a/octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs +++ b/octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using System.Xml.Linq; using System.Text.Json; using octo_fiesta.Models.Domain; +using octo_fiesta.Models.Subsonic; namespace octo_fiesta.Services.Subsonic; @@ -137,6 +138,81 @@ public class SubsonicResponseBuilder ); return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" }; } + + /// + /// Creates a Subsonic response for a playlist represented as an album. + /// Playlists appear as albums with genre "Playlist". + /// + public IActionResult CreatePlaylistAsAlbumResponse(string format, ExternalPlaylist playlist, List tracks) + { + var totalDuration = tracks.Sum(s => s.Duration ?? 0); + + // Build artist name with emoji and curator + var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}"; + if (!string.IsNullOrEmpty(playlist.CuratorName)) + { + artistName += $" {playlist.CuratorName}"; + } + + var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}"; + + if (format == "json") + { + return CreateJsonResponse(new + { + status = "ok", + version = SubsonicVersion, + album = new + { + id = playlist.Id, + name = playlist.Name, + artist = artistName, + artistId = artistId, + coverArt = playlist.Id, + songCount = tracks.Count, + duration = totalDuration, + year = playlist.CreatedDate?.Year ?? 0, + genre = "Playlist", + isCompilation = false, + created = playlist.CreatedDate?.ToString("yyyy-MM-ddTHH:mm:ss"), + song = tracks.Select(s => ConvertSongToJson(s)).ToList() + } + }); + } + + var ns = XNamespace.Get(SubsonicNamespace); + var albumElement = new XElement(ns + "album", + new XAttribute("id", playlist.Id), + new XAttribute("name", playlist.Name), + new XAttribute("artist", artistName), + new XAttribute("artistId", artistId), + new XAttribute("songCount", tracks.Count), + new XAttribute("duration", totalDuration), + new XAttribute("genre", "Playlist"), + new XAttribute("coverArt", playlist.Id) + ); + + if (playlist.CreatedDate.HasValue) + { + albumElement.Add(new XAttribute("year", playlist.CreatedDate.Value.Year)); + albumElement.Add(new XAttribute("created", playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss"))); + } + + // Add songs + foreach (var song in tracks) + { + albumElement.Add(ConvertSongToXml(song, ns)); + } + + var doc = new XDocument( + new XElement(ns + "subsonic-response", + new XAttribute("status", "ok"), + new XAttribute("version", SubsonicVersion), + albumElement + ) + ); + return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" }; + } /// /// Creates a Subsonic response containing an artist with albums. From e8e385b770ea3875edc5c86fd23fc833ac09c577 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Thu, 15 Jan 2026 23:10:04 +0100 Subject: [PATCH 13/14] fix: resolve circular dependency and fix failing tests --- .../DeezerDownloadServiceTests.cs | 8 +- .../DeezerMetadataServiceTests.cs | 2 +- .../QobuzDownloadServiceTests.cs | 8 +- .../QobuzMetadataServiceTests.cs | 9 ++- .../Services/Common/BaseDownloadService.cs | 37 ++++++--- .../Services/Deezer/DeezerDownloadService.cs | 1 + .../Services/Qobuz/QobuzBundleService.cs | 6 +- .../Services/Qobuz/QobuzDownloadService.cs | 1 + .../Services/Subsonic/PlaylistSyncService.cs | 78 ++++++++++++++----- 9 files changed, 105 insertions(+), 45 deletions(-) diff --git a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs index 11f7f2e..d66cc92 100644 --- a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs +++ b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs @@ -24,7 +24,6 @@ public class DeezerDownloadServiceTests : IDisposable private readonly Mock _localLibraryServiceMock; private readonly Mock _metadataServiceMock; private readonly Mock> _loggerMock; - private readonly Mock _serviceProviderMock; private readonly IConfiguration _configuration; private readonly string _testDownloadPath; @@ -42,7 +41,6 @@ public class DeezerDownloadServiceTests : IDisposable _localLibraryServiceMock = new Mock(); _metadataServiceMock = new Mock(); _loggerMock = new Mock>(); - _serviceProviderMock = new Mock(); _configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -85,6 +83,10 @@ public class DeezerDownloadServiceTests : IDisposable Quality = null }); + var serviceProviderMock = new Mock(); + serviceProviderMock.Setup(sp => sp.GetService(typeof(octo_fiesta.Services.Subsonic.PlaylistSyncService))) + .Returns(null); + return new DeezerDownloadService( _httpClientFactoryMock.Object, config, @@ -92,7 +94,7 @@ public class DeezerDownloadServiceTests : IDisposable _metadataServiceMock.Object, subsonicSettings, deezerSettings, - _serviceProviderMock.Object, + serviceProviderMock.Object, _loggerMock.Object); } diff --git a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs index b09042b..1b55a56 100644 --- a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs +++ b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs @@ -767,7 +767,7 @@ public class DeezerMetadataServiceTests Assert.Equal(2, result.Count); Assert.Equal("Track 1", result[0].Title); 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] diff --git a/octo-fiesta.Tests/QobuzDownloadServiceTests.cs b/octo-fiesta.Tests/QobuzDownloadServiceTests.cs index 0027d03..f57438a 100644 --- a/octo-fiesta.Tests/QobuzDownloadServiceTests.cs +++ b/octo-fiesta.Tests/QobuzDownloadServiceTests.cs @@ -22,7 +22,6 @@ public class QobuzDownloadServiceTests : IDisposable private readonly Mock _metadataServiceMock; private readonly Mock> _bundleServiceLoggerMock; private readonly Mock> _loggerMock; - private readonly Mock _serviceProviderMock; private readonly IConfiguration _configuration; private readonly string _testDownloadPath; private QobuzBundleService _bundleService; @@ -42,7 +41,6 @@ public class QobuzDownloadServiceTests : IDisposable _metadataServiceMock = new Mock(); _bundleServiceLoggerMock = new Mock>(); _loggerMock = new Mock>(); - _serviceProviderMock = new Mock(); // Create a real QobuzBundleService for testing (it will use the mocked HttpClient) _bundleService = new QobuzBundleService(_httpClientFactoryMock.Object, _bundleServiceLoggerMock.Object); @@ -88,6 +86,10 @@ public class QobuzDownloadServiceTests : IDisposable Quality = quality }); + var serviceProviderMock = new Mock(); + serviceProviderMock.Setup(sp => sp.GetService(typeof(octo_fiesta.Services.Subsonic.PlaylistSyncService))) + .Returns(null); + return new QobuzDownloadService( _httpClientFactoryMock.Object, config, @@ -96,7 +98,7 @@ public class QobuzDownloadServiceTests : IDisposable _bundleService, subsonicSettings, qobuzSettings, - _serviceProviderMock.Object, + serviceProviderMock.Object, _loggerMock.Object); } diff --git a/octo-fiesta.Tests/QobuzMetadataServiceTests.cs b/octo-fiesta.Tests/QobuzMetadataServiceTests.cs index 5789942..32cf4ab 100644 --- a/octo-fiesta.Tests/QobuzMetadataServiceTests.cs +++ b/octo-fiesta.Tests/QobuzMetadataServiceTests.cs @@ -26,11 +26,14 @@ public class QobuzMetadataServiceTests _httpClientFactoryMock = new Mock(); _httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); - var httpClientFactory = Mock.Of(); + // Mock QobuzBundleService (methods are now virtual so can be mocked) + var bundleHttpClientFactoryMock = new Mock(); + bundleHttpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var bundleLogger = Mock.Of>(); - - _bundleServiceMock = new Mock(httpClientFactory, bundleLogger); + _bundleServiceMock = new Mock(bundleHttpClientFactoryMock.Object, bundleLogger) { CallBase = false }; _bundleServiceMock.Setup(b => b.GetAppIdAsync()).ReturnsAsync("fake-app-id-12345"); + _bundleServiceMock.Setup(b => b.GetSecretsAsync()).ReturnsAsync(new List { "fake-secret" }); + _bundleServiceMock.Setup(b => b.GetSecretAsync(It.IsAny())).ReturnsAsync("fake-secret"); _loggerMock = new Mock>(); diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs index d96221e..b707d4e 100644 --- a/octo-fiesta/Services/Common/BaseDownloadService.cs +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -22,7 +22,7 @@ public abstract class BaseDownloadService : IDownloadService protected readonly IMusicMetadataService MetadataService; protected readonly SubsonicSettings SubsonicSettings; protected readonly ILogger Logger; - protected readonly IServiceProvider ServiceProvider; + private readonly IServiceProvider _serviceProvider; protected readonly string DownloadPath; protected readonly string CachePath; @@ -30,6 +30,22 @@ public abstract class BaseDownloadService : IDownloadService protected readonly Dictionary ActiveDownloads = new(); protected readonly SemaphoreSlim DownloadLock = new(1, 1); + /// + /// Lazy-loaded PlaylistSyncService to avoid circular dependency + /// + private PlaylistSyncService? _playlistSyncService; + protected PlaylistSyncService? PlaylistSyncService + { + get + { + if (_playlistSyncService == null) + { + _playlistSyncService = _serviceProvider.GetService(); + } + return _playlistSyncService; + } + } + /// /// Provider name (e.g., "deezer", "qobuz") /// @@ -47,7 +63,7 @@ public abstract class BaseDownloadService : IDownloadService LocalLibraryService = localLibraryService; MetadataService = metadataService; SubsonicSettings = subsonicSettings; - ServiceProvider = serviceProvider; + _serviceProvider = serviceProvider; Logger = logger; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; @@ -269,22 +285,21 @@ public abstract class BaseDownloadService : IDownloadService song.LocalPath = localPath; // Check if this track belongs to a playlist and update M3U - try + if (PlaylistSyncService != null) { - var playlistSyncService = ServiceProvider.GetService(typeof(PlaylistSyncService)) as PlaylistSyncService; - if (playlistSyncService != null) + try { - var playlistId = playlistSyncService.GetPlaylistIdForTrack(songId); + var playlistId = PlaylistSyncService.GetPlaylistIdForTrack(songId); if (playlistId != null) { 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) - { - Logger.LogWarning(ex, "Failed to update playlist M3U for track {SongId}", songId); + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to update playlist M3U for track {SongId}", songId); + } } // Only register and scan if NOT in cache mode diff --git a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs index 0f86a38..53be347 100644 --- a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs +++ b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs @@ -11,6 +11,7 @@ using octo_fiesta.Models.Search; using octo_fiesta.Models.Subsonic; using octo_fiesta.Services.Local; using octo_fiesta.Services.Common; +using octo_fiesta.Services.Subsonic; using Microsoft.Extensions.Options; using IOFile = System.IO.File; diff --git a/octo-fiesta/Services/Qobuz/QobuzBundleService.cs b/octo-fiesta/Services/Qobuz/QobuzBundleService.cs index b42fdd3..d5385ac 100644 --- a/octo-fiesta/Services/Qobuz/QobuzBundleService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzBundleService.cs @@ -40,7 +40,7 @@ public class QobuzBundleService /// /// Gets the Qobuz App ID, extracting it from the bundle if not cached /// - public async Task GetAppIdAsync() + public virtual async Task GetAppIdAsync() { await EnsureInitializedAsync(); return _cachedAppId!; @@ -49,7 +49,7 @@ public class QobuzBundleService /// /// Gets the Qobuz secrets list, extracting them from the bundle if not cached /// - public async Task> GetSecretsAsync() + public virtual async Task> GetSecretsAsync() { await EnsureInitializedAsync(); return _cachedSecrets!; @@ -58,7 +58,7 @@ public class QobuzBundleService /// /// Gets a specific secret by index (used for signing requests) /// - public async Task GetSecretAsync(int index = 0) + public virtual async Task GetSecretAsync(int index = 0) { var secrets = await GetSecretsAsync(); if (index < 0 || index >= secrets.Count) diff --git a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs index a6f2206..483f5d6 100644 --- a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs @@ -8,6 +8,7 @@ using octo_fiesta.Models.Search; using octo_fiesta.Models.Subsonic; using octo_fiesta.Services.Local; using octo_fiesta.Services.Common; +using octo_fiesta.Services.Subsonic; using Microsoft.Extensions.Options; using IOFile = System.IO.File; diff --git a/octo-fiesta/Services/Subsonic/PlaylistSyncService.cs b/octo-fiesta/Services/Subsonic/PlaylistSyncService.cs index 7e2866b..c3818f4 100644 --- a/octo-fiesta/Services/Subsonic/PlaylistSyncService.cs +++ b/octo-fiesta/Services/Subsonic/PlaylistSyncService.cs @@ -30,6 +30,10 @@ public class PlaylistSyncService private readonly string _musicDirectory; private readonly string _playlistDirectory; + // Cancellation token for background cleanup task + private readonly CancellationTokenSource _cleanupCancellationTokenSource = new(); + private readonly Task _cleanupTask; + public PlaylistSyncService( IEnumerable metadataServices, IEnumerable downloadServices, @@ -58,7 +62,20 @@ public class PlaylistSyncService } // Start background cleanup task for expired cache entries - _ = Task.Run(CleanupExpiredCacheEntriesAsync); + _cleanupTask = Task.Run(() => CleanupExpiredCacheEntriesAsync(_cleanupCancellationTokenSource.Token)); + } + + /// + /// Gets the metadata service for the specified provider + /// + private IMusicMetadataService? GetMetadataServiceForProvider(string provider) + { + return provider.ToLower() switch + { + "deezer" => _deezerMetadataService, + "qobuz" => _qobuzMetadataService, + _ => null + }; } /// @@ -69,7 +86,7 @@ public class PlaylistSyncService { var expiresAt = DateTime.UtcNow.Add(CacheTTL); _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); } /// @@ -112,12 +129,11 @@ public class PlaylistSyncService var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); // Get playlist metadata - var metadataService = provider.ToLower() switch + var metadataService = GetMetadataServiceForProvider(provider); + if (metadataService == null) { - "deezer" => _deezerMetadataService, - "qobuz" => _qobuzMetadataService, - _ => throw new NotSupportedException($"Provider '{provider}' not supported for playlists") - }; + throw new NotSupportedException($"Provider '{provider}' not supported for playlists"); + } var playlist = await metadataService.GetPlaylistAsync(provider, externalId); if (playlist == null) @@ -145,7 +161,7 @@ public class PlaylistSyncService return; } - // Download all tracks + // Download all tracks (M3U will be created once at the end) var downloadedTracks = new List<(Song Song, string LocalPath)>(); foreach (var track in tracks) @@ -159,6 +175,7 @@ public class PlaylistSyncService } // 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}"; AddTrackToPlaylistCache(trackId, playlistId); @@ -180,7 +197,7 @@ public class PlaylistSyncService return; } - // Create M3U file + // Create M3U file ONCE at the end with all downloaded tracks await CreateM3UPlaylistAsync(playlist.Name, downloadedTracks); _logger.LogInformation("Playlist download completed: {DownloadedCount}/{TotalCount} tracks for '{PlaylistName}'", @@ -233,11 +250,19 @@ public class PlaylistSyncService /// /// 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. /// - public async Task AddTrackToM3UAsync(string playlistId, Song track, string localPath) + /// If true, skips M3U update (will be done at the end by DownloadFullPlaylistAsync) + 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 { // Get playlist metadata to get the name and track order @@ -249,13 +274,7 @@ public class PlaylistSyncService var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); - var metadataService = provider.ToLower() switch - { - "deezer" => _deezerMetadataService, - "qobuz" => _qobuzMetadataService, - _ => null - }; - + var metadataService = GetMetadataServiceForProvider(provider); if (metadataService == null) { _logger.LogWarning("No metadata service found for provider '{Provider}'", provider); @@ -342,13 +361,13 @@ public class PlaylistSyncService /// /// Background task to clean up expired cache entries every minute /// - private async Task CleanupExpiredCacheEntriesAsync() + private async Task CleanupExpiredCacheEntriesAsync(CancellationToken cancellationToken) { - while (true) + while (!cancellationToken.IsCancellationRequested) { try { - await Task.Delay(TimeSpan.FromMinutes(1)); + await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); var now = DateTime.UtcNow; var expiredKeys = _trackPlaylistCache @@ -366,10 +385,27 @@ public class PlaylistSyncService _logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count); } } + catch (OperationCanceledException) + { + // Expected when cancellation is requested + break; + } catch (Exception ex) { _logger.LogWarning(ex, "Error during playlist cache cleanup"); } } + + _logger.LogInformation("Playlist cache cleanup task stopped"); + } + + /// + /// Stops the background cleanup task + /// + public async Task StopCleanupAsync() + { + _cleanupCancellationTokenSource.Cancel(); + await _cleanupTask; + _cleanupCancellationTokenSource.Dispose(); } } From 1bf3e17d78a03990a360387dc621fb5351c0174b Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Thu, 15 Jan 2026 23:27:11 +0100 Subject: [PATCH 14/14] docs: add external playlists feature documentation --- .env.example | 9 +++++++++ README.md | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 6b2dd89..2f4d90a 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,15 @@ QOBUZ_USER_ID= QOBUZ_QUALITY= # ===== GENERAL SETTINGS ===== +# External playlists support (optional, default: true) +# When enabled, allows searching and downloading playlists from Deezer/Qobuz +# Starring a playlist triggers automatic download of all tracks and creates an M3U file +ENABLE_EXTERNAL_PLAYLISTS=true + +# Playlists directory name (optional, default: playlists) +# M3U playlist files will be created in {DOWNLOAD_PATH}/{PLAYLISTS_DIRECTORY}/ +PLAYLISTS_DIRECTORY=playlists + # Explicit content filter (optional, default: All) # - All: Show all tracks (no filtering) # - ExplicitOnly: Exclude clean/edited versions, keep original explicit content diff --git a/README.md b/README.md index 70e5b31..0038683 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The name was randomly generated by GitHub when creating the repository. We found - **Transparent Proxy**: Acts as a middleware between Subsonic clients (like Aonsoku, Sublime Music, etc.) and your Navidrome server - **Seamless Integration**: Automatically searches and streams music from your configured provider when not available locally - **Automatic Downloads**: Songs are downloaded on-the-fly and cached for future use +- **External Playlist Support**: Search, discover, and download playlists from Deezer and Qobuz with automatic M3U generation - **Hi-Res Audio Support**: Qobuz provider supports up to 24-bit/192kHz FLAC quality - **Full Metadata Embedding**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and embedded cover art - **Organized Library**: Downloads are saved in a clean `Artist/Album/Track` folder structure @@ -80,6 +81,10 @@ The easiest way to run Octo-Fiesta is with Docker Compose. # Music service provider (Deezer or Qobuz) MUSIC_SERVICE=Qobuz + # === External Playlists (optional) === + ENABLE_EXTERNAL_PLAYLISTS=true # Enable/disable playlist support (default: true) + PLAYLISTS_DIRECTORY=playlists # Directory name for M3U files (default: playlists) + # === Qobuz Configuration (if using Qobuz) === QOBUZ_USER_AUTH_TOKEN=your-qobuz-token QOBUZ_USER_ID=your-qobuz-user-id @@ -129,6 +134,29 @@ The easiest way to run Octo-Fiesta is with Docker Compose. | `Qobuz:UserId` | Your Qobuz User ID (required if using Qobuz) | | `Qobuz:Quality` | Preferred audio quality: `FLAC`, `FLAC_24_HIGH`, `FLAC_24_LOW`, `FLAC_16`, `MP3_320`. If not specified, the highest available quality will be used | +### External Playlists + +Octo-Fiesta supports discovering and downloading playlists from your streaming providers (Deezer and Qobuz). + +| Setting | Description | +|---------|-------------| +| `Subsonic:EnableExternalPlaylists` | Enable/disable external playlist support (default: `true`) | +| `Subsonic:PlaylistsDirectory` | Directory name where M3U playlist files are created (default: `playlists`) | + +**How it works:** +1. Search for playlists from Deezer or Qobuz using the global search in your Subsonic client +2. When you "star" (favorite) a playlist, Octo-Fiesta automatically downloads all tracks +3. An M3U playlist file is created in `{DownloadPath}/playlists/` with relative paths to downloaded tracks +4. Individual tracks are added to the M3U as they are played or downloaded + +**Environment variable:** +```bash +# To disable playlists +Subsonic__EnableExternalPlaylists=false +``` + +> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results. + ### Getting Credentials #### Deezer ARL Token @@ -221,12 +249,13 @@ The proxy implements the Subsonic API and adds transparent streaming provider in | Endpoint | Description | |----------|-------------| -| `GET /rest/search3` | Merged search results from Navidrome + streaming provider | +| `GET /rest/search3` | Merged search results from Navidrome + streaming provider (including playlists) | | `GET /rest/stream` | Streams audio, downloading from provider if needed | | `GET /rest/getSong` | Returns song details (local or from provider) | | `GET /rest/getAlbum` | Returns album with tracks from both sources | | `GET /rest/getArtist` | Returns artist with albums from both sources | | `GET /rest/getCoverArt` | Proxies cover art for external content | +| `GET /rest/star` | Stars items; triggers automatic playlist download for external playlists | All other Subsonic API endpoints are passed through to Navidrome unchanged. @@ -254,10 +283,16 @@ downloads/ │ │ └── ... │ └── Another Album/ │ └── ... -└── Another Artist/ +├── Another Artist/ +│ └── ... +└── playlists/ + ├── My Favorite Songs.m3u + ├── Chill Vibes.m3u └── ... ``` +Playlists are stored as M3U files with relative paths to downloaded tracks, making them portable and compatible with most music players. + ## Metadata Embedding Downloaded files include: