feat: add explicit content filter for Deezer tracks (#22)

This commit is contained in:
V1ck3s
2026-01-06 21:55:40 +01:00
committed by Vickes
parent 06f33b8e89
commit 3fd98ea3de
7 changed files with 400 additions and 13 deletions

View File

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

View File

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

View File

@@ -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<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly DeezerMetadataService _service;
private readonly SubsonicSettings _settings;
private DeezerMetadataService _service;
public DeezerMetadataServiceTests()
{
@@ -21,7 +23,14 @@ public class DeezerMetadataServiceTests
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).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
}

View File

@@ -88,6 +88,12 @@ public class Song
/// Local file path (if available)
/// </summary>
public string? LocalPath { get; set; }
/// <summary>
/// Deezer explicit content lyrics value
/// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown
/// </summary>
public int? ExplicitContentLyrics { get; set; }
}
/// <summary>

View File

@@ -1,6 +1,36 @@
namespace octo_fiesta.Models;
namespace octo_fiesta.Models;
/// <summary>
/// Explicit content filter mode for Deezer tracks
/// </summary>
public enum ExplicitFilter
{
/// <summary>
/// Show all tracks (no filtering)
/// </summary>
All,
/// <summary>
/// Exclude clean/edited versions (explicit_content_lyrics == 3)
/// Shows original explicit content and naturally clean content
/// </summary>
ExplicitOnly,
/// <summary>
/// Only show clean content (explicit_content_lyrics == 0 or 3)
/// Excludes tracks with explicit_content_lyrics == 1
/// </summary>
CleanOnly
}
public class SubsonicSettings
{
public string? Url { get; set; }
/// <summary>
/// Explicit content filter mode (default: All)
/// Environment variable: EXPLICIT_FILTER
/// Values: "All", "ExplicitOnly", "CleanOnly"
/// </summary>
public ExplicitFilter ExplicitFilter { get; set; } = ExplicitFilter.All;
}

View File

@@ -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<SubsonicSettings> settings)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
}
public async Task<List<Song>> 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
};
}
/// <summary>
/// Determines whether a song should be included based on the explicit content filter setting
/// </summary>
/// <param name="song">The song to check</param>
/// <returns>True if the song should be included, false otherwise</returns>
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
};
}
}

View File

@@ -7,7 +7,8 @@
},
"AllowedHosts": "*",
"Subsonic": {
"Url": "http://localhost:4533"
"Url": "http://localhost:4533",
"ExplicitFilter": "All"
},
"Library": {
"DownloadPath": "./downloads"