feat: add explicit badges to discography modal and artist-detail cards

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 <noreply@anthropic.com>
This commit is contained in:
dlynas 2026-05-08 18:09:25 -04:00
parent 09597eb6db
commit 42bee21c9f
7 changed files with 40 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1838,7 +1838,7 @@ function createReleaseCard(release) {
content.className = "album-card-content";
const _esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
content.innerHTML = `
<div class="album-card-name" title="${_esc(release.title)}">${_esc(release.title)}</div>
<div class="album-card-name" title="${_esc(release.title)}">${_esc(release.title)}${release.explicit === true ? ' <span class="explicit-badge">E</span>' : ''}</div>
${yearText ? `<div class="album-card-year">${_esc(yearText)}</div>` : ''}
`;
card.appendChild(content);
@ -2486,7 +2486,7 @@ function _renderDiscogCard(release, index, completionData) {
${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''}
</div>
<div class="discog-card-info">
<div class="discog-card-title">${_esc(albumName)}</div>
<div class="discog-card-title">${_esc(albumName)}${release.explicit === true ? ' <span class="explicit-badge">E</span>' : ''}</div>
<div class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div>
</div>
<div class="discog-card-check"></div>

View file

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