mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: add explicit content filter for Deezer tracks (#22)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Subsonic": {
|
||||
"Url": "http://localhost:4533"
|
||||
"Url": "http://localhost:4533",
|
||||
"ExplicitFilter": "All"
|
||||
},
|
||||
"Library": {
|
||||
"DownloadPath": "./downloads"
|
||||
|
||||
Reference in New Issue
Block a user