feat(metadata): surface explicit content flag with [E] badge

Port of the explicit-content indicator from SpotiFLAC-Web (issue #456):

- Go: add explicit to ExtTrackMetadata (parsed generically from any
  extension via explicit/is_explicit/isExplicit), TrackMetadata, and
  AlbumTrackMetadata; built-in Deezer maps explicit_lyrics and
  explicit_content_lyrics == 1
- Dart: add explicit to the Track model with a tolerant
  parseExplicitFlag helper wired into all backend track parsers
- UI: new ExplicitBadge rendered through buildQualityBadges on album,
  playlist, search, and home track rows
This commit is contained in:
zarzet
2026-07-09 18:49:05 +07:00
parent 8abb9d119b
commit 4c83aeafb5
14 changed files with 137 additions and 11 deletions
+23 -11
View File
@@ -163,17 +163,26 @@ func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
}
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"`
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"`
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"`
ExplicitLyrics bool `json:"explicit_lyrics"`
ExplicitContentLyrics int `json:"explicit_content_lyrics"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
}
// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean:
// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean
// explicit (0 = clean, 2 = unknown).
func deezerTrackIsExplicit(track deezerTrack) bool {
return track.ExplicitLyrics || track.ExplicitContentLyrics == 1
}
type deezerArtist struct {
@@ -245,6 +254,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
ISRC: track.ISRC,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
Explicit: deezerTrackIsExplicit(track),
}
}
@@ -670,6 +680,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
AlbumType: albumType,
Explicit: deezerTrackIsExplicit(track),
})
}
@@ -979,6 +990,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
Explicit: deezerTrackIsExplicit(track),
})
}
+1
View File
@@ -734,6 +734,7 @@ func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrac
DeezerID: deezerID,
SpotifyID: track.SpotifyID,
Composer: track.Composer,
Explicit: track.Explicit,
}
}
+2
View File
@@ -40,6 +40,7 @@ type ExtTrackMetadata struct {
ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"`
AlbumType string `json:"album_type,omitempty"`
Explicit bool `json:"explicit,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
@@ -809,6 +810,7 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
ItemType: gojaObjectString(obj, "item_type", "itemType"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"),
TidalID: gojaObjectString(obj, "tidal_id", "tidalId"),
QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"),
DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"),
@@ -178,6 +178,49 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
}
}
func TestParseExtensionTrackValueExplicit(t *testing.T) {
vm := goja.New()
value, err := vm.RunString(`({name: "Song", explicit: true})`)
if err != nil {
t.Fatalf("RunString: %v", err)
}
if track := parseExtensionTrackValue(vm, value); !track.Explicit {
t.Fatalf("expected explicit track, got %#v", track)
}
value, err = vm.RunString(`({name: "Song", isExplicit: true})`)
if err != nil {
t.Fatalf("RunString: %v", err)
}
if track := parseExtensionTrackValue(vm, value); !track.Explicit {
t.Fatalf("expected explicit track via isExplicit, got %#v", track)
}
value, err = vm.RunString(`({name: "Song"})`)
if err != nil {
t.Fatalf("RunString: %v", err)
}
if track := parseExtensionTrackValue(vm, value); track.Explicit {
t.Fatalf("expected clean track, got %#v", track)
}
}
func TestDeezerTrackIsExplicit(t *testing.T) {
if deezerTrackIsExplicit(deezerTrack{}) {
t.Fatal("expected clean track by default")
}
if !deezerTrackIsExplicit(deezerTrack{ExplicitLyrics: true}) {
t.Fatal("expected explicit via explicit_lyrics")
}
if !deezerTrackIsExplicit(deezerTrack{ExplicitContentLyrics: 1}) {
t.Fatal("expected explicit via explicit_content_lyrics == 1")
}
if deezerTrackIsExplicit(deezerTrack{ExplicitContentLyrics: 2}) {
t.Fatal("expected unknown (2) to be treated as clean")
}
}
func TestExtensionStoreDiskCacheSurvivesRestart(t *testing.T) {
dir := t.TempDir()
registryURL := "https://registry.example.com/registry.json"
+2
View File
@@ -30,6 +30,7 @@ type TrackMetadata struct {
ArtistID string `json:"artist_id,omitempty"`
AlbumType string `json:"album_type,omitempty"`
Composer string `json:"composer,omitempty"`
Explicit bool `json:"explicit,omitempty"`
}
type AlbumTrackMetadata struct {
@@ -51,6 +52,7 @@ type AlbumTrackMetadata struct {
AlbumURL string `json:"album_url,omitempty"`
AlbumType string `json:"album_type,omitempty"`
Composer string `json:"composer,omitempty"`
Explicit bool `json:"explicit,omitempty"`
}
type AlbumInfoMetadata struct {
+4
View File
@@ -28,6 +28,7 @@ class Track {
final String? itemType;
final String? audioQuality;
final String? audioModes;
final bool? explicit;
const Track({
required this.id,
@@ -54,6 +55,7 @@ class Track {
this.itemType,
this.audioQuality,
this.audioModes,
this.explicit,
});
bool get isSingle {
@@ -82,6 +84,8 @@ class Track {
bool get isDolbyAtmos =>
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
bool get isExplicit => explicit == true;
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
bool get hasPreview => previewUrl != null && previewUrl!.isNotEmpty;
+2
View File
@@ -35,6 +35,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
itemType: json['itemType'] as String?,
audioQuality: json['audioQuality'] as String?,
audioModes: json['audioModes'] as String?,
explicit: json['explicit'] as bool?,
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
@@ -62,6 +63,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'itemType': instance.itemType,
'audioQuality': instance.audioQuality,
'audioModes': instance.audioModes,
'explicit': instance.explicit,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
+2
View File
@@ -750,6 +750,7 @@ class TrackNotifier extends Notifier<TrackState> {
itemType: track.itemType,
audioQuality: track.audioQuality,
audioModes: track.audioModes,
explicit: track.explicit,
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
qobuz: availability['qobuz'] as bool? ?? false,
@@ -846,6 +847,7 @@ class TrackNotifier extends Notifier<TrackState> {
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
+2
View File
@@ -471,6 +471,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
@@ -1294,6 +1295,7 @@ class _AlbumTrackItem extends ConsumerWidget {
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
+3
View File
@@ -338,6 +338,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
if (isInLocalLibrary) ...[
const SizedBox(width: 6),
@@ -1126,6 +1127,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
@@ -1296,6 +1298,7 @@ class _ExtensionPlaylistScreenState
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
+2
View File
@@ -226,6 +226,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
@@ -1064,6 +1065,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
+1
View File
@@ -231,6 +231,7 @@ class _SearchTrackTile extends ConsumerWidget {
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
],
),
+14
View File
@@ -6,6 +6,20 @@ String? normalizeOptionalString(String? value) {
return trimmed;
}
/// Parses the parental-advisory flag from backend/extension payloads, which
/// may arrive as a bool, a 0/1 number, or a "true"/"1" string.
bool? parseExplicitFlag(dynamic value) {
if (value == null) return null;
if (value is bool) return value;
if (value is num) return value == 1;
if (value is String) {
final normalized = value.trim().toLowerCase();
if (normalized.isEmpty || normalized == 'null') return null;
return normalized == 'true' || normalized == '1';
}
return null;
}
final RegExp _windowsAbsolutePathPattern = RegExp(r'^[A-Za-z]:[\\/]');
bool _looksLikeLocalReference(String value) {
+36
View File
@@ -31,6 +31,37 @@ class AudioQualityBadge extends StatelessWidget {
}
}
class ExplicitBadge extends StatelessWidget {
final ColorScheme colorScheme;
const ExplicitBadge({super.key, required this.colorScheme});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Explicit',
child: Container(
width: 14,
height: 14,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'E',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: colorScheme.surface,
height: 1.0,
),
),
),
);
}
}
class DolbyAtmosBadge extends StatelessWidget {
final ColorScheme colorScheme;
@@ -118,8 +149,13 @@ List<Widget> buildQualityBadges({
required String? audioQuality,
required String? audioModes,
required ColorScheme colorScheme,
bool explicit = false,
}) {
final badges = <Widget>[];
if (explicit) {
badges.add(const SizedBox(width: 6));
badges.add(ExplicitBadge(colorScheme: colorScheme));
}
if (audioQuality != null && audioQuality.isNotEmpty) {
badges.add(const SizedBox(width: 6));
badges.add(