feat: full discography, explicit badges, smart selection, and sorting
Pagination: raise discography limit from 50 to 500 across all three API call sites (artist-detail page, discography download modal, source-detail view). Deezer/iTunes/Spotify already paginated internally; Discogs updated to paginate all pages instead of a single capped request. Explicit flag: add explicit field to the Album dataclass in every metadata source (Deezer, Spotify, iTunes, Discogs, MusicBrainz, Hydrabase). Deezer extracts explicit_lyrics, iTunes extracts collectionExplicitness, Hydrabase forwards the field from the server response; Discogs/MusicBrainz/Spotify stay None. The field threads through _build_discography_release_dict and _build_artist_detail_release_card so it reaches the frontend on every release object. A small "E" badge renders next to explicit album titles in both the discography download modal and artist-detail page cards. Smart selection: "Select Best" button in the discography download modal groups visible cards by normalised title (edition/variant suffixes stripped) and within each group checks only the highest-scoring version. Scoring mirrors the existing Python _variant_score logic: explicit > non-clean > no variant suffix > more tracks > newer release date. Sort controls: Date desc/asc and Name A-Z/Z-A dropdown added to the discography modal filter bar. Sorting reorders DOM nodes in place so checked state is preserved across sort changes. UI: discography card titles now wrap to full text instead of truncating with ellipsis; card artwork and checkbox pin to the top when titles wrap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6db2cbe2a7
commit
1afc79a64b
11 changed files with 194 additions and 18 deletions
|
|
@ -126,7 +126,7 @@ def build_source_only_artist_detail(
|
|||
allow_fallback=True,
|
||||
skip_cache=False,
|
||||
max_pages=0,
|
||||
limit=50,
|
||||
limit=500,
|
||||
artist_source_ids={source: artist_id},
|
||||
dedup_variants=False,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -168,6 +168,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':
|
||||
|
|
@ -199,7 +200,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)),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -209,6 +209,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_discogs_release(cls, release_data: Dict[str, Any]) -> 'Album':
|
||||
|
|
@ -543,10 +544,26 @@ class DiscogsClient:
|
|||
artist_data = self._api_get(f'/artists/{artist_id}')
|
||||
artist_name = artist_data.get('name', '').lower() if artist_data else ''
|
||||
|
||||
data = self._api_get(f'/artists/{artist_id}/releases', {
|
||||
'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200),
|
||||
})
|
||||
if not data or not data.get('releases'):
|
||||
# Paginate through all releases (Discogs max per_page=100).
|
||||
# We collect raw items across pages before filtering so the limit cap
|
||||
# applies to qualified albums, not raw API rows.
|
||||
PAGE_SIZE = 100
|
||||
all_items = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._api_get(f'/artists/{artist_id}/releases', {
|
||||
'sort': 'year', 'sort_order': 'desc',
|
||||
'per_page': PAGE_SIZE, 'page': page,
|
||||
})
|
||||
if not data or not data.get('releases'):
|
||||
break
|
||||
all_items.extend(data['releases'])
|
||||
pagination = data.get('pagination', {})
|
||||
if page >= pagination.get('pages', 1):
|
||||
break
|
||||
page += 1
|
||||
|
||||
if not all_items:
|
||||
return []
|
||||
|
||||
# Separate masters from individual releases — prefer masters (canonical versions)
|
||||
|
|
@ -554,7 +571,7 @@ class DiscogsClient:
|
|||
releases_no_master = []
|
||||
master_titles = set()
|
||||
|
||||
for item in data['releases']:
|
||||
for item in all_items:
|
||||
# Skip non-main roles
|
||||
role = item.get('role', 'Main').lower()
|
||||
if role not in ('main', ''):
|
||||
|
|
@ -595,7 +612,8 @@ class DiscogsClient:
|
|||
album = Album(id=album.id, name=album.name, artists=album.artists,
|
||||
release_date=album.release_date, total_tracks=album.total_tracks,
|
||||
album_type=album.album_type, image_url=thumb,
|
||||
external_urls=album.external_urls)
|
||||
external_urls=album.external_urls,
|
||||
explicit=album.explicit)
|
||||
|
||||
# Deduplicate by normalized title (but keep deluxe/special editions as separate)
|
||||
dedup_key = album.name.lower().strip()
|
||||
|
|
@ -607,7 +625,7 @@ class DiscogsClient:
|
|||
if album.album_type in allowed_types:
|
||||
albums.append(album)
|
||||
|
||||
if len(albums) >= limit:
|
||||
if limit and len(albums) >= limit:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing Discogs artist release: {e}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ def _build_discography_release_dict(release: Any, artist_id: str) -> Optional[Di
|
|||
album_type = _extract_lookup_value(release, 'album_type', default='album') or 'album'
|
||||
release_date = _extract_lookup_value(release, 'release_date')
|
||||
|
||||
raw_explicit = _extract_lookup_value(release, 'explicit')
|
||||
explicit: Optional[bool] = bool(raw_explicit) if raw_explicit is not None else None
|
||||
|
||||
return {
|
||||
'id': release_id,
|
||||
'name': _extract_lookup_value(release, 'name', 'title', default=release_id),
|
||||
|
|
@ -106,6 +109,7 @@ def _build_discography_release_dict(release: Any, artist_id: str) -> Optional[Di
|
|||
'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': explicit,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -359,6 +363,9 @@ def _build_artist_detail_release_card(release: Dict[str, Any]) -> Optional[Dict[
|
|||
if release_year is not None:
|
||||
release_year = str(release_year)
|
||||
|
||||
raw_explicit = _extract_lookup_value(release, 'explicit')
|
||||
card_explicit: Optional[bool] = bool(raw_explicit) if raw_explicit is not None else None
|
||||
|
||||
card = {
|
||||
'id': release_id,
|
||||
'name': _extract_lookup_value(release, 'name', 'title', default=release_id),
|
||||
|
|
@ -369,6 +376,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any]) -> Optional[Dict[
|
|||
'track_count': _extract_lookup_value(release, 'track_count', 'total_tracks', default=0) or 0,
|
||||
'owned': None,
|
||||
'track_completion': 'checking',
|
||||
'explicit': card_explicit,
|
||||
}
|
||||
|
||||
if release_date:
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class Album:
|
|||
album_type: str
|
||||
image_url: Optional[str] = None
|
||||
external_urls: Optional[Dict[str, str]] = None
|
||||
explicit: Optional[bool] = None
|
||||
|
||||
|
||||
def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:
|
||||
|
|
|
|||
|
|
@ -429,6 +429,7 @@ class Album:
|
|||
image_url: Optional[str] = None
|
||||
external_urls: Optional[Dict[str, str]] = None
|
||||
artist_ids: Optional[List[str]] = None
|
||||
explicit: Optional[bool] = None
|
||||
|
||||
@classmethod
|
||||
def from_spotify_album(cls, album_data: Dict[str, Any]) -> 'Album':
|
||||
|
|
|
|||
|
|
@ -8553,7 +8553,7 @@ def get_artist_detail(artist_id):
|
|||
allow_fallback=True,
|
||||
skip_cache=False,
|
||||
max_pages=0,
|
||||
limit=50,
|
||||
limit=500,
|
||||
artist_source_ids=artist_source_ids,
|
||||
),
|
||||
)
|
||||
|
|
@ -8783,7 +8783,7 @@ def get_artist_discography(artist_id):
|
|||
allow_fallback=True,
|
||||
skip_cache=False,
|
||||
max_pages=0,
|
||||
limit=50,
|
||||
limit=500,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1611,8 +1611,9 @@ function createReleaseCard(release) {
|
|||
const content = document.createElement("div");
|
||||
content.className = "album-card-content";
|
||||
const _esc = (s) => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
const explicitTag = release.explicit === true ? '<span class="album-card-explicit">E</span>' : '';
|
||||
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)}${explicitTag}</div>
|
||||
${yearText ? `<div class="album-card-year">${_esc(yearText)}</div>` : ''}
|
||||
`;
|
||||
card.appendChild(content);
|
||||
|
|
@ -2204,9 +2205,16 @@ async function openDiscographyModal() {
|
|||
<button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button>
|
||||
<button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button>
|
||||
</div>
|
||||
<select class="discog-sort-select" id="discog-sort-select" onchange="discogApplySort(this.value)" title="Sort releases">
|
||||
<option value="date-desc">Date ↓</option>
|
||||
<option value="date-asc">Date ↑</option>
|
||||
<option value="name-asc">Name A–Z</option>
|
||||
<option value="name-desc">Name Z–A</option>
|
||||
</select>
|
||||
<div class="discog-select-actions">
|
||||
<button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button>
|
||||
<button class="discog-select-btn" onclick="discogSelectAll(false)">Deselect All</button>
|
||||
<button class="discog-select-btn discog-select-best-btn" onclick="discogSelectBest()" title="Select one best version of each album (prefers explicit, expanded, more tracks)">Select Best</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discog-grid" id="discog-grid">
|
||||
|
|
@ -2250,17 +2258,18 @@ function _renderDiscogCard(release, index, completionData) {
|
|||
const checked = !isOwned;
|
||||
const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : '';
|
||||
const statusIcon = isOwned ? '✓' : isPartial ? '◐' : '';
|
||||
const explicitBadge = release.explicit === true ? '<span class="explicit-badge">E</span>' : '';
|
||||
|
||||
const albumName = release.name || release.title || '';
|
||||
return `
|
||||
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s">
|
||||
<label class="discog-card ${statusClass}" data-type="${release._type}" data-explicit="${release.explicit === true ? '1' : '0'}" data-tracks-count="${tracks}" data-release-date="${release.release_date || ''}" data-title="${_esc(albumName)}" style="animation-delay:${index * 0.03}s">
|
||||
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
|
||||
<div class="discog-card-art">
|
||||
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
|
||||
${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)}${explicitBadge}</div>
|
||||
<div class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div>
|
||||
</div>
|
||||
<div class="discog-card-check"></div>
|
||||
|
|
@ -2286,6 +2295,87 @@ function discogSelectAll(select) {
|
|||
_updateDiscogFooterCount();
|
||||
}
|
||||
|
||||
// Strip edition/variant suffixes to get a normalized base title for grouping.
|
||||
// Mirrors the Python _clean_title logic in metadata_service.py.
|
||||
function _discogNormalizeTitle(raw) {
|
||||
let t = (raw || '').trim().toLowerCase();
|
||||
const variantSuffix = /\s*[\(\[][^\(\)\[\]]*\b(?:edition|editions|deluxe|remaster|remastered|explicit|clean|version|anniversary|collector|expanded|redux)\b[^\(\)\[\]]*[\)\]]\s*$/i;
|
||||
const legacySuffix = /\s*-\s*(explicit|clean|deluxe edition|single)\s*$/i;
|
||||
let prev;
|
||||
do {
|
||||
prev = t;
|
||||
t = t.replace(variantSuffix, '').replace(legacySuffix, '').trim();
|
||||
} while (t !== prev);
|
||||
return t.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// Score a discog-card element — higher is better.
|
||||
// Returns a comparable array: [notCompilation, noVariantSuffix, explicitScore, trackCount, releaseDate]
|
||||
function _discogCardScore(card) {
|
||||
const title = (card.dataset.title || '').toLowerCase();
|
||||
const isExplicit = card.dataset.explicit === '1';
|
||||
const isClean = !isExplicit && /\bclean\b/.test(title);
|
||||
const trackCount = parseInt(card.dataset.tracksCount) || 0;
|
||||
const releaseDate = card.dataset.releaseDate || '';
|
||||
const variantKeyword = /\b(?:edition|editions|deluxe|remaster|remastered|explicit|clean|version|anniversary|collector|expanded|redux)\b/i;
|
||||
const hasVariantSuffix = /[\(\[][^\)\]]*/.test(title) && variantKeyword.test(title);
|
||||
const isCompilation = /\b(greatest hits|best of|collection|anthology|essential)\b/i.test(title);
|
||||
return [
|
||||
isCompilation ? 0 : 1,
|
||||
hasVariantSuffix ? 0 : 1,
|
||||
isExplicit ? 2 : (isClean ? 0 : 1),
|
||||
trackCount,
|
||||
releaseDate,
|
||||
];
|
||||
}
|
||||
|
||||
function _scoreGt(a, b) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] > b[i]) return true;
|
||||
if (a[i] < b[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discogSelectBest() {
|
||||
const cards = Array.from(document.querySelectorAll('.discog-card')).filter(
|
||||
c => c.style.display !== 'none'
|
||||
);
|
||||
|
||||
// Group cards by (type, normalizedTitle)
|
||||
const groups = new Map();
|
||||
const groupOrder = [];
|
||||
cards.forEach(card => {
|
||||
const type = card.dataset.type || 'album';
|
||||
const rawTitle = card.dataset.title || '';
|
||||
const key = type + '\x00' + _discogNormalizeTitle(rawTitle);
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
groupOrder.push(key);
|
||||
}
|
||||
groups.get(key).push(card);
|
||||
});
|
||||
|
||||
// Within each group, find the best card; uncheck all others
|
||||
const bestCards = new Set();
|
||||
groupOrder.forEach(key => {
|
||||
const group = groups.get(key);
|
||||
let best = group[0];
|
||||
let bestScore = _discogCardScore(best);
|
||||
for (let i = 1; i < group.length; i++) {
|
||||
const s = _discogCardScore(group[i]);
|
||||
if (_scoreGt(s, bestScore)) { best = group[i]; bestScore = s; }
|
||||
}
|
||||
bestCards.add(best);
|
||||
});
|
||||
|
||||
cards.forEach(card => {
|
||||
const cb = card.querySelector('.discog-card-cb');
|
||||
if (cb) cb.checked = bestCards.has(card);
|
||||
});
|
||||
_updateDiscogFooterCount();
|
||||
}
|
||||
|
||||
function _updateDiscogFooterCount() {
|
||||
const checked = document.querySelectorAll('.discog-card-cb:checked');
|
||||
let releases = 0, tracks = 0;
|
||||
|
|
@ -2303,6 +2393,23 @@ function _updateDiscogFooterCount() {
|
|||
if (submitBtn) submitBtn.disabled = releases === 0;
|
||||
}
|
||||
|
||||
function discogApplySort(order) {
|
||||
const grid = document.getElementById('discog-grid');
|
||||
if (!grid) return;
|
||||
const cards = Array.from(grid.querySelectorAll('.discog-card'));
|
||||
cards.sort((a, b) => {
|
||||
if (order === 'date-desc' || order === 'date-asc') {
|
||||
const da = a.dataset.releaseDate || '';
|
||||
const db = b.dataset.releaseDate || '';
|
||||
return order === 'date-desc' ? db.localeCompare(da) : da.localeCompare(db);
|
||||
}
|
||||
const ta = (a.dataset.title || '').toLowerCase();
|
||||
const tb = (b.dataset.title || '').toLowerCase();
|
||||
return order === 'name-asc' ? ta.localeCompare(tb) : tb.localeCompare(ta);
|
||||
});
|
||||
cards.forEach(c => grid.appendChild(c));
|
||||
}
|
||||
|
||||
async function startDiscographyDownload() {
|
||||
let artist = artistsPageState.selectedArtist;
|
||||
// Fallback to library page state
|
||||
|
|
|
|||
|
|
@ -45082,6 +45082,16 @@ textarea.enhanced-meta-field-input {
|
|||
color: rgb(var(--accent-light-rgb));
|
||||
}
|
||||
|
||||
.discog-sort-select {
|
||||
padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 600;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||
color: rgba(255,255,255,0.4); cursor: pointer; transition: all 0.15s ease;
|
||||
font-family: inherit; appearance: none; -webkit-appearance: none;
|
||||
outline: none;
|
||||
}
|
||||
.discog-sort-select:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.15); }
|
||||
.discog-sort-select option { background: #1a1a2e; color: #fff; }
|
||||
|
||||
.discog-select-actions { display: flex; gap: 6px; }
|
||||
|
||||
.discog-select-btn {
|
||||
|
|
@ -45093,6 +45103,16 @@ textarea.enhanced-meta-field-input {
|
|||
|
||||
.discog-select-btn:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.15); }
|
||||
|
||||
.discog-select-best-btn {
|
||||
border-color: rgba(var(--accent-rgb), 0.25);
|
||||
color: rgba(var(--accent-light-rgb), 0.7);
|
||||
}
|
||||
.discog-select-best-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
border-color: rgba(var(--accent-rgb), 0.4);
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
}
|
||||
|
||||
/* Album grid */
|
||||
.discog-section-header {
|
||||
grid-column: 1 / -1;
|
||||
|
|
@ -45115,7 +45135,7 @@ textarea.enhanced-meta-field-input {
|
|||
}
|
||||
|
||||
.discog-card {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04);
|
||||
cursor: pointer; transition: all 0.15s ease;
|
||||
|
|
@ -45152,11 +45172,27 @@ textarea.enhanced-meta-field-input {
|
|||
|
||||
.discog-card-title {
|
||||
font-size: 13px; font-weight: 600; color: #fff;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: normal; word-break: break-word;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue