feat: playlist implementation

This commit is contained in:
V1ck3s
2026-01-14 23:18:27 +01:00
committed by Vickes
parent 2c5daeefed
commit ebe6e90f39
21 changed files with 2561 additions and 54 deletions

View File

@@ -24,6 +24,7 @@ public class DeezerDownloadServiceTests : IDisposable
private readonly Mock<ILocalLibraryService> _localLibraryServiceMock;
private readonly Mock<IMusicMetadataService> _metadataServiceMock;
private readonly Mock<ILogger<DeezerDownloadService>> _loggerMock;
private readonly Mock<IServiceProvider> _serviceProviderMock;
private readonly IConfiguration _configuration;
private readonly string _testDownloadPath;
@@ -41,6 +42,7 @@ public class DeezerDownloadServiceTests : IDisposable
_localLibraryServiceMock = new Mock<ILocalLibraryService>();
_metadataServiceMock = new Mock<IMusicMetadataService>();
_loggerMock = new Mock<ILogger<DeezerDownloadService>>();
_serviceProviderMock = new Mock<IServiceProvider>();
_configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -90,6 +92,7 @@ public class DeezerDownloadServiceTests : IDisposable
_metadataServiceMock.Object,
subsonicSettings,
deezerSettings,
_serviceProviderMock.Object,
_loggerMock.Object);
}

View File

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

View File

@@ -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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id!));
}
[Fact]
public void ParsePlaylistId_WithEmptyString_ThrowsArgumentException()
{
// Arrange
var id = "";
// Act & Assert
Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
}
[Fact]
public void ParsePlaylistId_WithOnlyPrefix_ThrowsArgumentException()
{
// Arrange
var id = "pl-";
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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
}

View File

@@ -22,6 +22,7 @@ public class QobuzDownloadServiceTests : IDisposable
private readonly Mock<IMusicMetadataService> _metadataServiceMock;
private readonly Mock<ILogger<QobuzBundleService>> _bundleServiceLoggerMock;
private readonly Mock<ILogger<QobuzDownloadService>> _loggerMock;
private readonly Mock<IServiceProvider> _serviceProviderMock;
private readonly IConfiguration _configuration;
private readonly string _testDownloadPath;
private QobuzBundleService _bundleService;
@@ -41,6 +42,7 @@ public class QobuzDownloadServiceTests : IDisposable
_metadataServiceMock = new Mock<IMusicMetadataService>();
_bundleServiceLoggerMock = new Mock<ILogger<QobuzBundleService>>();
_loggerMock = new Mock<ILogger<QobuzDownloadService>>();
_serviceProviderMock = new Mock<IServiceProvider>();
// 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);
}

View File

@@ -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<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly Mock<QobuzBundleService> _bundleServiceMock;
private readonly Mock<ILogger<QobuzMetadataService>> _loggerMock;
private readonly QobuzMetadataService _service;
public QobuzMetadataServiceTests()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var httpClientFactory = Mock.Of<IHttpClientFactory>();
var bundleLogger = Mock.Of<ILogger<QobuzBundleService>>();
_bundleServiceMock = new Mock<QobuzBundleService>(httpClientFactory, bundleLogger);
_bundleServiceMock.Setup(b => b.GetAppIdAsync()).ReturnsAsync("fake-app-id-12345");
_loggerMock = new Mock<ILogger<QobuzMetadataService>>();
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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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
}

View File

@@ -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<object>(), new List<object>(), externalResult, true);
localSongs, new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), true);
// Assert
Assert.Equal(2, mergedSongs.Count);
}
[Fact]
public void MergeSearchResults_Json_DeduplicatesArtists()
{
// Arrange
var localArtists = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["name"] = "Test Artist" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
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<object>(), new List<object>(), 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<object>(), new List<object>(), localArtists, externalResult, true);
new List<object>(), new List<object>(), localArtists, externalResult, new List<ExternalPlaylist>(), 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<object>(), new List<object>(), externalResult, false);
localSongs, new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), false);
// Assert
Assert.Equal(2, mergedSongs.Count);
@@ -294,7 +268,7 @@ public class SubsonicModelMapperTests
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, false);
new List<object>(), new List<object>(), localArtists, externalResult, new List<ExternalPlaylist>(), 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<object>(), new List<object>(), new List<object>(), externalResult, true);
new List<object>(), new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), 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<ExternalPlaylist>(), true);
// Assert
Assert.Single(mergedSongs);

View File

@@ -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<SubsonicController> _logger;
public SubsonicController(
@@ -37,7 +39,8 @@ public class SubsonicController : ControllerBase
SubsonicResponseBuilder responseBuilder,
SubsonicModelMapper modelMapper,
SubsonicProxyService proxyService,
ILogger<SubsonicController> logger)
ILogger<SubsonicController> 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))
@@ -97,12 +101,18 @@ public class SubsonicController : ControllerBase
int.TryParse(parameters.GetValueOrDefault("artistCount", "20"), out var arc) ? arc : 20
);
await Task.WhenAll(subsonicTask, externalTask);
// Search playlists if enabled
Task<List<ExternalPlaylist>> playlistTask = _subsonicSettings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, ac) // Use same limit as albums
: Task.FromResult(new List<ExternalPlaylist>());
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);
}
/// <summary>
@@ -340,11 +350,53 @@ public class SubsonicController : ControllerBase
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
// 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, albumProvider, albumExternalId) = _localLibraryService.ParseSongId(id);
if (isExternal)
{
var album = await _metadataService.GetAlbumAsync(provider!, externalId!);
var album = await _metadataService.GetAlbumAsync(albumProvider!, albumExternalId!);
if (album == null)
{
@@ -492,7 +544,38 @@ public class SubsonicController : ControllerBase
return NotFound();
}
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(id);
// 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, 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<ExternalPlaylist> playlistResult,
string format)
{
var (localSongs, localAlbums, localArtists) = subsonicResult.Success && subsonicResult.Body != null
@@ -581,6 +665,7 @@ public class SubsonicController : ControllerBase
localAlbums,
localArtists,
externalResult,
playlistResult,
isJson);
if (isJson)
@@ -644,6 +729,59 @@ public class SubsonicController : ControllerBase
#endregion
/// <summary>
/// Stars (favorites) an item. For playlists, this triggers a full download.
/// </summary>
[HttpGet, HttpPost]
[Route("rest/star")]
[Route("rest/star.view")]
public async Task<IActionResult> 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}")]

View File

@@ -113,4 +113,20 @@ public class SubsonicSettings
/// Only applies when StorageMode is Cache
/// </summary>
public int CacheDurationHours { get; set; } = 1;
/// <summary>
/// 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"
/// </summary>
public bool EnableExternalPlaylists { get; set; } = true;
/// <summary>
/// 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}/
/// </summary>
public string PlaylistsDirectory { get; set; } = "playlists";
}

View File

@@ -0,0 +1,58 @@
namespace octo_fiesta.Models.Subsonic;
/// <summary>
/// Represents a playlist from an external music provider (Deezer, Qobuz).
/// </summary>
public class ExternalPlaylist
{
/// <summary>
/// Unique identifier in the format "pl-{provider}-{externalId}"
/// Example: "pl-deezer-123456" or "pl-qobuz-789"
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Playlist name
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Playlist description
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Name of the playlist creator/curator
/// </summary>
public string? CuratorName { get; set; }
/// <summary>
/// Provider name ("deezer" or "qobuz")
/// </summary>
public string Provider { get; set; } = string.Empty;
/// <summary>
/// External ID from the provider (without "pl-" prefix)
/// </summary>
public string ExternalId { get; set; } = string.Empty;
/// <summary>
/// Number of tracks in the playlist
/// </summary>
public int TrackCount { get; set; }
/// <summary>
/// Total duration in seconds
/// </summary>
public int Duration { get; set; }
/// <summary>
/// Cover art URL from the provider
/// </summary>
public string? CoverUrl { get; set; }
/// <summary>
/// Playlist creation date
/// </summary>
public DateTime? CreatedDate { get; set; }
}

View File

@@ -31,6 +31,7 @@ builder.Services.Configure<QobuzSettings>(
// Get the configured music service
var musicService = builder.Configuration.GetValue<MusicService>("Subsonic:MusicService");
var enableExternalPlaylists = builder.Configuration.GetValue<bool>("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<SubsonicModelMapper>();
builder.Services.AddSingleton<SubsonicProxyService>();
// 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<IMusicMetadataService, DeezerMetadataService>();
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
builder.Services.AddSingleton<PlaylistSyncService>();
}
// Qobuz services (primary) - registered LAST to be injected by default
builder.Services.AddSingleton<QobuzBundleService>();
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
}
else
{
// Deezer services (default)
// If playlists enabled, register Qobuz FIRST (secondary provider)
if (enableExternalPlaylists)
{
builder.Services.AddSingleton<QobuzBundleService>();
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
builder.Services.AddSingleton<PlaylistSyncService>();
}
// Deezer services (primary, default) - registered LAST to be injected by default
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
}

View File

@@ -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<string?> 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<bool> 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)
{

View File

@@ -0,0 +1,76 @@
namespace octo_fiesta.Services.Common;
/// <summary>
/// Helper class for handling external playlist IDs.
/// Playlist IDs use the format: "pl-{provider}-{externalId}"
/// Example: "pl-deezer-123456", "pl-qobuz-789"
/// </summary>
public static class PlaylistIdHelper
{
private const string PlaylistPrefix = "pl-";
/// <summary>
/// Checks if an ID represents an external playlist.
/// </summary>
/// <param name="id">The ID to check</param>
/// <returns>True if the ID starts with "pl-", false otherwise</returns>
public static bool IsExternalPlaylist(string? id)
{
return !string.IsNullOrEmpty(id) && id.StartsWith(PlaylistPrefix, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Parses a playlist ID to extract provider and external ID.
/// </summary>
/// <param name="id">The playlist ID in format "pl-{provider}-{externalId}"</param>
/// <returns>A tuple containing (provider, externalId)</returns>
/// <exception cref="ArgumentException">Thrown if the ID format is invalid</exception>
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);
}
/// <summary>
/// Creates a playlist ID from provider and external ID.
/// </summary>
/// <param name="provider">The provider name (e.g., "deezer", "qobuz")</param>
/// <param name="externalId">The external ID from the provider</param>
/// <returns>A playlist ID in format "pl-{provider}-{externalId}"</returns>
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}";
}
}

View File

@@ -50,8 +50,9 @@ public class DeezerDownloadService : BaseDownloadService
IMusicMetadataService metadataService,
IOptions<SubsonicSettings> subsonicSettings,
IOptions<DeezerSettings> deezerSettings,
IServiceProvider serviceProvider,
ILogger<DeezerDownloadService> logger)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{
_httpClient = httpClientFactory.CreateClient();

View File

@@ -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<List<ExternalPlaylist>> 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<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var playlist in data.EnumerateArray())
{
playlists.Add(ParseDeezerPlaylist(playlist));
}
}
return playlists;
}
catch
{
return new List<ExternalPlaylist>();
}
}
public async Task<ExternalPlaylist?> 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<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return new List<Song>();
try
{
var url = $"{BaseUrl}/playlist/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
var songs = new List<Song>();
// 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<Song>();
}
}
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
};
}
/// <summary>
/// Determines whether a song should be included based on the explicit content filter setting
/// </summary>

View File

@@ -42,6 +42,14 @@ public interface IDownloadService
/// </summary>
DownloadInfo? GetDownloadStatus(string songId);
/// <summary>
/// Gets the local path for a song if it has been downloaded already
/// </summary>
/// <param name="externalProvider">The provider (deezer, qobuz, etc.)</param>
/// <param name="externalId">The ID on the external provider</param>
/// <returns>The local file path if exists, null otherwise</returns>
Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId);
/// <summary>
/// Checks if the service is properly configured and functional
/// </summary>

View File

@@ -54,4 +54,28 @@ public interface IMusicMetadataService
/// Gets an artist's albums
/// </summary>
Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId);
/// <summary>
/// Searches for playlists on external providers
/// </summary>
/// <param name="query">Search term</param>
/// <param name="limit">Maximum number of results</param>
/// <returns>List of found playlists</returns>
Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20);
/// <summary>
/// Gets details of an external playlist (metadata only, not tracks)
/// </summary>
/// <param name="externalProvider">Provider name (e.g., "deezer", "qobuz")</param>
/// <param name="externalId">Playlist ID from the provider</param>
/// <returns>Playlist details or null if not found</returns>
Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId);
/// <summary>
/// Gets all tracks from an external playlist
/// </summary>
/// <param name="externalProvider">Provider name (e.g., "deezer", "qobuz")</param>
/// <param name="externalId">Playlist ID from the provider</param>
/// <returns>List of songs in the playlist</returns>
Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId);
}

View File

@@ -43,8 +43,9 @@ public class QobuzDownloadService : BaseDownloadService
QobuzBundleService bundleService,
IOptions<SubsonicSettings> subsonicSettings,
IOptions<QobuzSettings> qobuzSettings,
IServiceProvider serviceProvider,
ILogger<QobuzDownloadService> logger)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{
_httpClient = httpClientFactory.CreateClient();
_bundleService = bundleService;

View File

@@ -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<List<ExternalPlaylist>> 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<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
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<ExternalPlaylist>();
}
}
public async Task<ExternalPlaylist?> 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<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
{
if (externalProvider != "qobuz") return new List<Song>();
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<Song>();
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
var songs = new List<Song>();
// 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<Song>();
}
}
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
};
}
/// <summary>
/// Safely gets an ID value as a string, handling both number and string types from JSON
/// </summary>

View File

@@ -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;
/// <summary>
/// Service responsible for downloading playlist tracks and creating M3U files
/// </summary>
public class PlaylistSyncService
{
private readonly IMusicMetadataService _deezerMetadataService;
private readonly IMusicMetadataService _qobuzMetadataService;
private readonly IEnumerable<IDownloadService> _downloadServices;
private readonly IConfiguration _configuration;
private readonly SubsonicSettings _subsonicSettings;
private readonly ILogger<PlaylistSyncService> _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<string, (string PlaylistId, DateTime ExpiresAt)> _trackPlaylistCache = new();
private static readonly TimeSpan CacheTTL = TimeSpan.FromMinutes(5);
private readonly string _musicDirectory;
private readonly string _playlistDirectory;
public PlaylistSyncService(
IEnumerable<IMusicMetadataService> metadataServices,
IEnumerable<IDownloadService> downloadServices,
IConfiguration configuration,
IOptions<SubsonicSettings> subsonicSettings,
ILogger<PlaylistSyncService> 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);
}
/// <summary>
/// Adds a track to the playlist context cache.
/// This allows the download service to know which playlist a track belongs to.
/// </summary>
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);
}
/// <summary>
/// Gets the playlist ID for a given track ID from cache.
/// Returns null if not found or expired.
/// </summary>
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;
}
/// <summary>
/// Downloads all tracks from a playlist and creates an M3U file.
/// This is triggered when a user stars a playlist.
/// </summary>
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;
}
}
/// <summary>
/// Creates an M3U playlist file with relative paths to downloaded tracks
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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");
}
}
/// <summary>
/// Background task to clean up expired cache entries every minute
/// </summary>
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");
}
}
}
}

View File

@@ -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
}
/// <summary>
/// Merges local search results with external search results, deduplicating by name.
/// Merges local and external search results (songs, albums, artists, playlists).
/// </summary>
public (List<object> MergedSongs, List<object> MergedAlbums, List<object> MergedArtists) MergeSearchResults(
List<object> localSongs,
List<object> localAlbums,
List<object> localArtists,
SearchResult externalResult,
List<ExternalPlaylist> 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<object> localSongs,
List<object> localAlbums,
List<object> localArtists,
SearchResult externalResult)
SearchResult externalResult,
List<ExternalPlaylist> 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<object> localSongs,
List<object> localAlbums,
List<object> localArtists,
SearchResult externalResult)
SearchResult externalResult,
List<ExternalPlaylist> 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<object>();
@@ -211,4 +222,81 @@ public class SubsonicModelMapper
return (mergedSongs, mergedAlbums, mergedArtists);
}
/// <summary>
/// Converts an ExternalPlaylist to a JSON object representing an album.
/// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}".
/// </summary>
private Dictionary<string, object> 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<string, object>
{
["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;
}
/// <summary>
/// Converts an ExternalPlaylist to an XML element representing an album.
/// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}".
/// </summary>
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;
}
}

View File

@@ -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;
@@ -138,6 +139,81 @@ public class SubsonicResponseBuilder
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
}
/// <summary>
/// Creates a Subsonic response for a playlist represented as an album.
/// Playlists appear as albums with genre "Playlist".
/// </summary>
public IActionResult CreatePlaylistAsAlbumResponse(string format, ExternalPlaylist playlist, List<Song> 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" };
}
/// <summary>
/// Creates a Subsonic response containing an artist with albums.
/// </summary>