From 4c83aeafb54ab0c4e393a0d574570745a4883b56 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 9 Jul 2026 18:49:05 +0700 Subject: [PATCH] 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 --- go_backend/deezer.go | 34 ++++++++++----- go_backend/exports.go | 1 + go_backend/extension_providers.go | 2 + .../extension_runtime_supplement_test.go | 43 +++++++++++++++++++ go_backend/metadata_types.go | 2 + lib/models/track.dart | 4 ++ lib/models/track.g.dart | 2 + lib/providers/track_provider.dart | 2 + lib/screens/album_screen.dart | 2 + lib/screens/home_tab_widgets.dart | 3 ++ lib/screens/playlist_screen.dart | 2 + lib/screens/search_screen.dart | 1 + lib/utils/string_utils.dart | 14 ++++++ lib/widgets/audio_quality_badges.dart | 36 ++++++++++++++++ 14 files changed, 137 insertions(+), 11 deletions(-) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 7fce759e..8506afd8 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -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), }) } diff --git a/go_backend/exports.go b/go_backend/exports.go index a04c8c87..bffb0251 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -734,6 +734,7 @@ func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrac DeezerID: deezerID, SpotifyID: track.SpotifyID, Composer: track.Composer, + Explicit: track.Explicit, } } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index d10169b5..27092d2e 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -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"), diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index b009ebdc..d626d39a 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -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" diff --git a/go_backend/metadata_types.go b/go_backend/metadata_types.go index caac6e22..4404221c 100644 --- a/go_backend/metadata_types.go +++ b/go_backend/metadata_types.go @@ -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 { diff --git a/lib/models/track.dart b/lib/models/track.dart index ba08120c..d40605fb 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -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; diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index e71bce48..77555870 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -35,6 +35,7 @@ Track _$TrackFromJson(Map json) => Track( itemType: json['itemType'] as String?, audioQuality: json['audioQuality'] as String?, audioModes: json['audioModes'] as String?, + explicit: json['explicit'] as bool?, ); Map _$TrackToJson(Track instance) => { @@ -62,6 +63,7 @@ Map _$TrackToJson(Track instance) => { 'itemType': instance.itemType, 'audioQuality': instance.audioQuality, 'audioModes': instance.audioModes, + 'explicit': instance.explicit, }; ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index bb2af2cd..9e4a03dc 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -750,6 +750,7 @@ class TrackNotifier extends Notifier { 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 { audioQuality: data['audio_quality']?.toString(), audioModes: data['audio_modes']?.toString(), previewUrl: data['preview_url']?.toString(), + explicit: parseExplicitFlag(data['explicit']), ); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index c915da0d..04468a39 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -471,6 +471,7 @@ class _AlbumScreenState extends ConsumerState { 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), diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index f6dcfbed..e67786f3 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -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 { 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']), ); } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 4fe5e5be..733b66b2 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -226,6 +226,7 @@ class _PlaylistScreenState extends ConsumerState { 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), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index f7db367c..71e7d467 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -231,6 +231,7 @@ class _SearchTrackTile extends ConsumerWidget { audioQuality: track.audioQuality, audioModes: track.audioModes, colorScheme: colorScheme, + explicit: track.isExplicit, ), ], ), diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 2e2b704c..8d8c1023 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -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) { diff --git a/lib/widgets/audio_quality_badges.dart b/lib/widgets/audio_quality_badges.dart index d8669584..9d8135ed 100644 --- a/lib/widgets/audio_quality_badges.dart +++ b/lib/widgets/audio_quality_badges.dart @@ -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 buildQualityBadges({ required String? audioQuality, required String? audioModes, required ColorScheme colorScheme, + bool explicit = false, }) { final badges = []; + 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(