From 42bee21c9f1f471ef3bedd1e54d20e6c5c4bbdde Mon Sep 17 00:00:00 2001 From: dlynas <118506937+dlynas@users.noreply.github.com> Date: Fri, 8 May 2026 18:09:25 -0400 Subject: [PATCH] feat: add explicit badges to discography modal and artist-detail cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit field to the Album dataclass in core/metadata/types.py and the client-level Album dataclasses in deezer_client.py, itunes_client.py, and hydrabase_client.py (the legacy discography path reads from client objects, not typed dicts). Deezer extracts explicit_lyrics (int→bool), iTunes extracts collectionExplicitness ('explicit' string), Hydrabase forwards the explicit field from the server response. Spotify, Discogs, MusicBrainz, Qobuz, and Tidal have no explicit signal and stay None. The flag threads through both builder functions in discography.py and renders as a small "E" badge next to explicit titles in the discography download modal and artist-detail page cards. Co-Authored-By: Claude Sonnet 4.6 --- core/deezer_client.py | 4 +++- core/hydrabase_client.py | 1 + core/itunes_client.py | 6 ++++-- core/metadata/discography.py | 4 ++++ core/metadata/types.py | 11 +++++++++++ webui/static/library.js | 4 ++-- webui/static/style.css | 15 +++++++++++++++ 7 files changed, 40 insertions(+), 5 deletions(-) diff --git a/core/deezer_client.py b/core/deezer_client.py index 22c1a746..e564663c 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -212,6 +212,7 @@ class Album: album_type: str image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None + explicit: Optional[bool] = None @classmethod def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album': @@ -243,7 +244,8 @@ class Album: total_tracks=album_data.get('nb_tracks', 0), album_type=album_type, image_url=image_url, - external_urls=external_urls if external_urls else None + external_urls=external_urls if external_urls else None, + explicit=bool(album_data.get('explicit_lyrics', False)), ) diff --git a/core/hydrabase_client.py b/core/hydrabase_client.py index df204d38..7c73dd73 100644 --- a/core/hydrabase_client.py +++ b/core/hydrabase_client.py @@ -565,6 +565,7 @@ class HydrabaseClient: album_type=item_type, image_url=item.get('image_url'), external_urls=ext_urls, + explicit=item.get('explicit'), )) except Exception as e: logger.debug(f"Skipping malformed Hydrabase artist album: {e}") diff --git a/core/itunes_client.py b/core/itunes_client.py index 95d64669..6f08dba3 100644 --- a/core/itunes_client.py +++ b/core/itunes_client.py @@ -167,7 +167,8 @@ class Album: album_type: str image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None - + explicit: Optional[bool] = None + @classmethod def from_itunes_album(cls, album_data: Dict[str, Any]) -> 'Album': # Get highest quality artwork @@ -209,7 +210,8 @@ class Album: total_tracks=track_count, album_type=album_type, image_url=image_url, - external_urls=external_urls if external_urls else None + external_urls=external_urls if external_urls else None, + explicit=album_data.get('collectionExplicitness') == 'explicit', ) @dataclass diff --git a/core/metadata/discography.py b/core/metadata/discography.py index 35f6df77..6ee88fa2 100644 --- a/core/metadata/discography.py +++ b/core/metadata/discography.py @@ -150,6 +150,7 @@ def _build_discography_release_dict(release: Any, artist_id: str, 'image_url': typed_album.image_url, 'total_tracks': typed_album.total_tracks or 0, 'external_urls': typed_album.external_urls or {}, + 'explicit': typed_album.explicit, } release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id') @@ -168,6 +169,7 @@ def _build_discography_release_dict(release: Any, artist_id: str, 'image_url': _extract_lookup_value(release, 'image_url', 'thumb_url', 'cover_image'), 'total_tracks': _extract_lookup_value(release, 'total_tracks', default=0) or 0, 'external_urls': _extract_lookup_value(release, 'external_urls', default={}) or {}, + 'explicit': _extract_lookup_value(release, 'explicit'), } @@ -436,6 +438,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any], 'track_count': typed_album.total_tracks or 0, 'owned': None, 'track_completion': 'checking', + 'explicit': typed_album.explicit, } if typed_album.release_date: card['release_date'] = typed_album.release_date @@ -470,6 +473,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any], 'track_count': _extract_lookup_value(release, 'track_count', 'total_tracks', default=0) or 0, 'owned': None, 'track_completion': 'checking', + 'explicit': _extract_lookup_value(release, 'explicit'), } if release_date: diff --git a/core/metadata/types.py b/core/metadata/types.py index 4ed01ff4..00c9749b 100644 --- a/core/metadata/types.py +++ b/core/metadata/types.py @@ -100,6 +100,8 @@ class Album: label: Optional[str] = None # Record label / publisher barcode: Optional[str] = None # UPC/EAN — Discogs/MusicBrainz only + explicit: Optional[bool] = None # True=explicit, False=clean, None=unknown + # Source provenance source: str = '' # 'spotify' / 'itunes' / etc — set by converter external_ids: Dict[str, str] = field(default_factory=dict) @@ -193,6 +195,8 @@ class Album: release_date = release_date.split('T', 1)[0] primary_genre = _str(raw.get('primaryGenreName')) + ce = _str(raw.get('collectionExplicitness')) + explicit = True if ce == 'explicit' else (False if ce in ('notExplicit', 'cleaned') else None) return cls( id=_str(raw.get('collectionId')), name=name, @@ -203,6 +207,7 @@ class Album: image_url=_itunes_artwork(raw.get('artworkUrl100')), artist_id=artist_id, genres=[primary_genre] if primary_genre else [], + explicit=explicit, source='itunes', external_ids=external_ids, external_urls=external_urls, @@ -238,6 +243,8 @@ class Album: if raw.get('link'): external_urls['deezer'] = _str(raw['link']) + _el = raw.get('explicit_lyrics') + explicit = bool(_el) if _el is not None else None return cls( id=_str(raw.get('id')), name=_str(raw.get('title')), @@ -251,6 +258,7 @@ class Album: if isinstance(g, dict) and g.get('name')], label=_str(raw.get('label')) or None, barcode=external_ids.get('upc'), + explicit=explicit, source='deezer', external_ids=external_ids, external_urls=external_urls, @@ -513,6 +521,8 @@ class Album: if raw.get('soul_id'): external_ids['soul'] = _str(raw['soul_id']) + _he = raw.get('explicit') + explicit = bool(_he) if _he is not None else None return cls( id=_str(raw.get('id')), name=_str(raw.get('name') or raw.get('title')), @@ -522,6 +532,7 @@ class Album: album_type=_str(raw.get('album_type'), default='album'), image_url=_str(raw.get('image_url') or raw.get('thumb_url')) or None, artist_id=_str(raw.get('artist_id')) or None, + explicit=explicit, source='hydrabase', external_ids=external_ids, ) diff --git a/webui/static/library.js b/webui/static/library.js index 783c16ad..7fa66bf9 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -1838,7 +1838,7 @@ function createReleaseCard(release) { content.className = "album-card-content"; const _esc = (s) => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); content.innerHTML = ` -
${_esc(release.title)}
+
${_esc(release.title)}${release.explicit === true ? ' E' : ''}
${yearText ? `
${_esc(yearText)}
` : ''} `; card.appendChild(content); @@ -2486,7 +2486,7 @@ function _renderDiscogCard(release, index, completionData) { ${statusIcon ? `${statusIcon}` : ''}
-
${_esc(albumName)}
+
${_esc(albumName)}${release.explicit === true ? ' E' : ''}
${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}
diff --git a/webui/static/style.css b/webui/static/style.css index 49bbbe00..109cca54 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -45469,6 +45469,21 @@ textarea.enhanced-meta-field-input { .discog-card-meta { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 2px; } +.explicit-badge { + display: inline-block; font-size: 9px; font-weight: 800; letter-spacing: 0.04em; + padding: 1px 4px; border-radius: 3px; vertical-align: middle; margin-left: 5px; + background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.55); + border: 1px solid rgba(255,255,255,0.15); line-height: 1.4; flex-shrink: 0; + font-family: inherit; +} +.album-card-explicit { + display: inline-block; font-size: 9px; font-weight: 800; letter-spacing: 0.04em; + padding: 1px 4px; border-radius: 3px; vertical-align: middle; margin-left: 4px; + background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.55); + border: 1px solid rgba(255,255,255,0.2); line-height: 1.4; + font-family: inherit; text-shadow: none; +} + .discog-card-check { width: 20px; height: 20px; border-radius: 6px; flex-shrink: 0; border: 2px solid rgba(255,255,255,0.15); transition: all 0.15s ease;