diff --git a/.env.example b/.env.example index 67dd47a..5eb8312 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,9 @@ DEEZER_ARL_FALLBACK= # Preferred audio quality: FLAC, MP3_320, MP3_128 (optional) # If not specified, the highest available quality for your account will be used DEEZER_QUALITY= + +# Explicit content filter (optional, default: All) +# - All: Show all tracks (no filtering) +# - ExplicitOnly: Exclude clean/edited versions, keep original explicit content +# - CleanOnly: Only show clean content (naturally clean or edited versions) +EXPLICIT_FILTER=All diff --git a/docker-compose.yml b/docker-compose.yml index d8e1afd..2750629 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - ASPNETCORE_ENVIRONMENT=Production # Navidrome/Subsonic server URL - Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533} + # Explicit content filter: All, ExplicitOnly, CleanOnly (default: ExplicitOnly) + - Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly} # Download path inside container - Library__DownloadPath=/app/downloads # Deezer ARL token (required) diff --git a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs index 778d423..5d3a684 100644 --- a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs +++ b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs @@ -2,6 +2,7 @@ using octo_fiesta.Services; using octo_fiesta.Models; using Moq; using Moq.Protected; +using Microsoft.Extensions.Options; using System.Net; using System.Text.Json; @@ -11,7 +12,8 @@ public class DeezerMetadataServiceTests { private readonly Mock _httpClientFactoryMock; private readonly Mock _httpMessageHandlerMock; - private readonly DeezerMetadataService _service; + private readonly SubsonicSettings _settings; + private DeezerMetadataService _service; public DeezerMetadataServiceTests() { @@ -21,7 +23,14 @@ public class DeezerMetadataServiceTests _httpClientFactoryMock = new Mock(); _httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); - _service = new DeezerMetadataService(_httpClientFactoryMock.Object); + _settings = new SubsonicSettings { ExplicitFilter = ExplicitFilter.ExplicitOnly }; + _service = CreateService(_settings); + } + + private DeezerMetadataService CreateService(SubsonicSettings settings) + { + var options = Options.Create(settings); + return new DeezerMetadataService(_httpClientFactoryMock.Object, options); } [Fact] @@ -286,4 +295,285 @@ public class DeezerMetadataServiceTests Content = new StringContent(content) }); } + + #region Explicit Filter Tests + + [Fact] + public async Task SearchSongsAsync_ExplicitOnlyFilter_ExcludesCleanVersions() + { + // Arrange + _service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.ExplicitOnly }); + + var deezerResponse = new + { + data = new object[] + { + new + { + id = 1, + title = "Explicit Original", + duration = 180, + explicit_content_lyrics = 1, // Explicit + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + }, + new + { + id = 2, + title = "Clean Version", + duration = 180, + explicit_content_lyrics = 3, // Clean/edited - should be excluded + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + }, + new + { + id = 3, + title = "Naturally Clean", + duration = 180, + explicit_content_lyrics = 0, // Naturally clean - should be included + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.SearchSongsAsync("test", 20); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(result, s => s.Title == "Explicit Original"); + Assert.Contains(result, s => s.Title == "Naturally Clean"); + Assert.DoesNotContain(result, s => s.Title == "Clean Version"); + } + + [Fact] + public async Task SearchSongsAsync_CleanOnlyFilter_ExcludesExplicitContent() + { + // Arrange + _service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.CleanOnly }); + + var deezerResponse = new + { + data = new object[] + { + new + { + id = 1, + title = "Explicit Original", + duration = 180, + explicit_content_lyrics = 1, // Explicit - should be excluded + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + }, + new + { + id = 2, + title = "Clean Version", + duration = 180, + explicit_content_lyrics = 3, // Clean/edited - should be included + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + }, + new + { + id = 3, + title = "Naturally Clean", + duration = 180, + explicit_content_lyrics = 0, // Naturally clean - should be included + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.SearchSongsAsync("test", 20); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(result, s => s.Title == "Clean Version"); + Assert.Contains(result, s => s.Title == "Naturally Clean"); + Assert.DoesNotContain(result, s => s.Title == "Explicit Original"); + } + + [Fact] + public async Task SearchSongsAsync_AllFilter_IncludesEverything() + { + // Arrange + _service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.All }); + + var deezerResponse = new + { + data = new object[] + { + new + { + id = 1, + title = "Explicit Original", + duration = 180, + explicit_content_lyrics = 1, + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + }, + new + { + id = 2, + title = "Clean Version", + duration = 180, + explicit_content_lyrics = 3, + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + }, + new + { + id = 3, + title = "Naturally Clean", + duration = 180, + explicit_content_lyrics = 0, + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.SearchSongsAsync("test", 20); + + // Assert + Assert.Equal(3, result.Count); + } + + [Fact] + public async Task SearchSongsAsync_ExplicitOnlyFilter_IncludesTracksWithNoExplicitInfo() + { + // Arrange + _service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.ExplicitOnly }); + + var deezerResponse = new + { + data = new object[] + { + new + { + id = 1, + title = "No Explicit Info", + duration = 180, + // No explicit_content_lyrics field + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.SearchSongsAsync("test", 20); + + // Assert + Assert.Single(result); + Assert.Equal("No Explicit Info", result[0].Title); + } + + [Fact] + public async Task GetAlbumAsync_ExplicitOnlyFilter_FiltersAlbumTracks() + { + // Arrange + _service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.ExplicitOnly }); + + var deezerResponse = new + { + id = 456789, + title = "Test Album", + nb_tracks = 3, + release_date = "2023-05-20", + cover_medium = "https://example.com/album.jpg", + artist = new { id = 123, name = "Test Artist" }, + tracks = new + { + data = new object[] + { + new + { + id = 111, + title = "Explicit Track", + duration = 180, + explicit_content_lyrics = 1, + artist = new { id = 123, name = "Test Artist" }, + album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" } + }, + new + { + id = 222, + title = "Clean Version Track", + duration = 200, + explicit_content_lyrics = 3, // Should be excluded + artist = new { id = 123, name = "Test Artist" }, + album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" } + }, + new + { + id = 333, + title = "Naturally Clean Track", + duration = 220, + explicit_content_lyrics = 0, + artist = new { id = 123, name = "Test Artist" }, + album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" } + } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.GetAlbumAsync("deezer", "456789"); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Songs.Count); + Assert.Contains(result.Songs, s => s.Title == "Explicit Track"); + Assert.Contains(result.Songs, s => s.Title == "Naturally Clean Track"); + Assert.DoesNotContain(result.Songs, s => s.Title == "Clean Version Track"); + } + + [Fact] + public async Task SearchSongsAsync_ParsesExplicitContentLyrics() + { + // Arrange + var deezerResponse = new + { + data = new object[] + { + new + { + id = 1, + title = "Test Track", + duration = 180, + explicit_content_lyrics = 1, + artist = new { id = 100, name = "Artist" }, + album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.SearchSongsAsync("test", 20); + + // Assert + Assert.Single(result); + Assert.Equal(1, result[0].ExplicitContentLyrics); + } + + #endregion } diff --git a/octo-fiesta/Models/MusicModels.cs b/octo-fiesta/Models/MusicModels.cs index 0a0c48f..35d1f18 100644 --- a/octo-fiesta/Models/MusicModels.cs +++ b/octo-fiesta/Models/MusicModels.cs @@ -88,6 +88,12 @@ public class Song /// Local file path (if available) /// public string? LocalPath { get; set; } + + /// + /// Deezer explicit content lyrics value + /// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown + /// + public int? ExplicitContentLyrics { get; set; } } /// diff --git a/octo-fiesta/Models/SubsonicSettings.cs b/octo-fiesta/Models/SubsonicSettings.cs index a8ec96f..8e3e495 100644 --- a/octo-fiesta/Models/SubsonicSettings.cs +++ b/octo-fiesta/Models/SubsonicSettings.cs @@ -1,6 +1,36 @@ -namespace octo_fiesta.Models; - -public class SubsonicSettings -{ - public string? Url { get; set; } +namespace octo_fiesta.Models; + +/// +/// Explicit content filter mode for Deezer tracks +/// +public enum ExplicitFilter +{ + /// + /// Show all tracks (no filtering) + /// + All, + + /// + /// Exclude clean/edited versions (explicit_content_lyrics == 3) + /// Shows original explicit content and naturally clean content + /// + ExplicitOnly, + + /// + /// Only show clean content (explicit_content_lyrics == 0 or 3) + /// Excludes tracks with explicit_content_lyrics == 1 + /// + CleanOnly +} + +public class SubsonicSettings +{ + public string? Url { get; set; } + + /// + /// Explicit content filter mode (default: All) + /// Environment variable: EXPLICIT_FILTER + /// Values: "All", "ExplicitOnly", "CleanOnly" + /// + public ExplicitFilter ExplicitFilter { get; set; } = ExplicitFilter.All; } \ No newline at end of file diff --git a/octo-fiesta/Services/DeezerMetadataService.cs b/octo-fiesta/Services/DeezerMetadataService.cs index 9cca74e..93f2954 100644 --- a/octo-fiesta/Services/DeezerMetadataService.cs +++ b/octo-fiesta/Services/DeezerMetadataService.cs @@ -1,5 +1,6 @@ using octo_fiesta.Models; using System.Text.Json; +using Microsoft.Extensions.Options; namespace octo_fiesta.Services; @@ -9,11 +10,13 @@ namespace octo_fiesta.Services; public class DeezerMetadataService : IMusicMetadataService { private readonly HttpClient _httpClient; + private readonly SubsonicSettings _settings; private const string BaseUrl = "https://api.deezer.com"; - public DeezerMetadataService(IHttpClientFactory httpClientFactory) + public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions settings) { _httpClient = httpClientFactory.CreateClient(); + _settings = settings.Value; } public async Task> SearchSongsAsync(string query, int limit = 20) @@ -33,7 +36,11 @@ public class DeezerMetadataService : IMusicMetadataService { foreach (var track in data.EnumerateArray()) { - songs.Add(ParseDeezerTrack(track)); + var song = ParseDeezerTrack(track); + if (ShouldIncludeSong(song)) + { + songs.Add(song); + } } } @@ -219,7 +226,11 @@ public class DeezerMetadataService : IMusicMetadataService foreach (var track in tracksData.EnumerateArray()) { // Pass the index as fallback for track_position (Deezer doesn't include it in album tracks) - album.Songs.Add(ParseDeezerTrack(track, trackIndex)); + var song = ParseDeezerTrack(track, trackIndex); + if (ShouldIncludeSong(song)) + { + album.Songs.Add(song); + } trackIndex++; } } @@ -277,6 +288,11 @@ public class DeezerMetadataService : IMusicMetadataService ? trackPos.GetInt32() : fallbackTrackNumber; + // Explicit content lyrics value + int? explicitContentLyrics = track.TryGetProperty("explicit_content_lyrics", out var ecl) + ? ecl.GetInt32() + : null; + return new Song { Id = $"ext-deezer-song-{externalId}", @@ -303,7 +319,8 @@ public class DeezerMetadataService : IMusicMetadataService : null, IsLocal = false, ExternalProvider = "deezer", - ExternalId = externalId + ExternalId = externalId, + ExplicitContentLyrics = explicitContentLyrics }; } @@ -394,6 +411,11 @@ public class DeezerMetadataService : IMusicMetadataService : (albumForCover.TryGetProperty("cover_big", out var cb) ? cb.GetString() : null); } + // Explicit content lyrics value + int? explicitContentLyrics = track.TryGetProperty("explicit_content_lyrics", out var ecl) + ? ecl.GetInt32() + : null; + return new Song { Id = $"ext-deezer-song-{externalId}", @@ -425,7 +447,8 @@ public class DeezerMetadataService : IMusicMetadataService CoverArtUrlLarge = coverLarge, IsLocal = false, ExternalProvider = "deezer", - ExternalId = externalId + ExternalId = externalId, + ExplicitContentLyrics = explicitContentLyrics }; } @@ -482,4 +505,33 @@ public class DeezerMetadataService : IMusicMetadataService ExternalId = externalId }; } + + /// + /// Determines whether a song should be included based on the explicit content filter setting + /// + /// The song to check + /// True if the song should be included, false otherwise + private bool ShouldIncludeSong(Song song) + { + // If no explicit content info, include the song + if (song.ExplicitContentLyrics == null) + return true; + + return _settings.ExplicitFilter switch + { + // All: No filtering, include everything + ExplicitFilter.All => true, + + // ExplicitOnly: Exclude clean/edited versions (value 3) + // Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown) + ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3, + + // CleanOnly: Only show clean content + // Include: 0 (naturally clean), 3 (clean/edited version) + // Exclude: 1 (explicit) + ExplicitFilter.CleanOnly => song.ExplicitContentLyrics != 1, + + _ => true + }; + } } diff --git a/octo-fiesta/appsettings.json b/octo-fiesta/appsettings.json index f2ff391..8b92d28 100644 --- a/octo-fiesta/appsettings.json +++ b/octo-fiesta/appsettings.json @@ -7,7 +7,8 @@ }, "AllowedHosts": "*", "Subsonic": { - "Url": "http://localhost:4533" + "Url": "http://localhost:4533", + "ExplicitFilter": "All" }, "Library": { "DownloadPath": "./downloads"