Skip to content

Commit fd3bf87

Browse files
dprodgerclaude
andcommitted
Side-by-side row-aligned tracklist comparison on duration-mismatches
Adds a click-to-expand panel on /admin/duration-mismatches/<song> that shows the MB release tracklist and the Spotify album tracklist side-by-side, sharing a single table so each row is one (disc, position) pair. Quick visual check for "is the same track in the same slot on both sides?" — the original use case being live-album-shipped-as-edited- cut situations where the durations diverge but the album lineup is identical. The earlier two-table layout had row-height drift between sides (Spotify rows carry a per-track artist line that MB rows don't) so positions didn't visually line up. New layout: two release headers above, then a shared 5-column table — Pos | MB Title | MB Dur | Spotify Title | Spotify Dur — ordered by (disc, position) over the union of both sides' keys. Missing cells render as em-dashes, target track gets highlighted via background. Per-track artist hints on the Spotify side are inlined ("Title — Artist") and only shown when they meaningfully differ from the album-level credit — so single-artist albums stay clean, and Various Artists compilations keep the disambiguating info. Backend: new GET /admin/duration-mismatches/links/<id>/tracklists endpoint that returns the paired tracklists as JSON. Pulls Spotify album header from get_album_details, the full tracklist from get_album_tracks (already paginated), and the MB tracks from MusicBrainzSearcher.get_release_details. Both upstreams have multi-day on-disk caches, so first expand per release pays the network round-trip and subsequent expands are instant. Tests: 4 new in TestTracklistsEndpoint covering the happy path (with mocked MB + Spotify), the empty-IDs degrade path, the unknown-link 404, and the unauth gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6e5d441 commit fd3bf87

4 files changed

Lines changed: 672 additions & 5 deletions

File tree

.gitignore

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ backend/scripts/log
2121
backend/scripts/cache
2222

2323
# Matcher rejection audit logs (written from working directory at runtime,
24-
# not source — accidentally committed once, see 4f06d18)
25-
backend/spotify_duration_rejections.csv
26-
backend/spotify_album_context_audit.csv
27-
backend/spotify_orphaned_tracks.csv
24+
# not source — get accidentally committed when matcher runs from any
25+
# subdirectory of the repo, so we glob on the filename anywhere).
26+
spotify_duration_rejections.csv
27+
spotify_album_context_audit.csv
28+
spotify_orphaned_tracks.csv
2829

2930
# Claude Code local state (agent memory, worktrees, per-user settings)
3031
.claude/

backend/routes/admin.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3151,6 +3151,141 @@ def duration_mismatches_reject_link(link_id):
31513151
})
31523152

31533153

3154+
@admin_bp.route('/duration-mismatches/links/<link_id>/tracklists', methods=['GET'])
3155+
def duration_mismatches_link_tracklists(link_id):
3156+
"""Return the MB release tracklist + Spotify album tracklist paired
3157+
up for side-by-side comparison in the admin UI.
3158+
3159+
Click-to-expand panel on /admin/duration-mismatches/<song> calls this
3160+
so the admin can see exactly which MB track lines up to which
3161+
Spotify track without leaving the page. The data underneath powers
3162+
the existing Album Fit column too — same MB and Spotify API hits,
3163+
same on-disk caches.
3164+
3165+
Both upstream calls are cached on disk (multi-day TTL on the
3166+
SpotifyClient cache, MB cache via MusicBrainzSearcher), so first
3167+
expand per release pays the network round-trip and subsequent
3168+
expands are instant.
3169+
"""
3170+
from integrations.spotify.client import SpotifyClient
3171+
from integrations.musicbrainz.utils import MusicBrainzSearcher
3172+
3173+
try:
3174+
with get_db_connection() as db:
3175+
with db.cursor() as cur:
3176+
cur.execute(
3177+
"""
3178+
SELECT
3179+
rel.id AS release_id,
3180+
rel.title AS release_title,
3181+
rel.artist_credit,
3182+
rel.musicbrainz_release_id,
3183+
rel.spotify_album_id
3184+
FROM recording_release_streaming_links rrsl
3185+
JOIN recording_releases rr ON rr.id = rrsl.recording_release_id
3186+
JOIN releases rel ON rel.id = rr.release_id
3187+
WHERE rrsl.id = %s
3188+
AND rrsl.service = 'spotify'
3189+
""",
3190+
(link_id,),
3191+
)
3192+
row = cur.fetchone()
3193+
except Exception as e:
3194+
logger.error(f"Error loading tracklists for link {link_id}: {e}")
3195+
return jsonify({'error': str(e)}), 500
3196+
3197+
if not row:
3198+
return jsonify({'error': 'Streaming link not found'}), 404
3199+
3200+
mb_release_id = row['musicbrainz_release_id']
3201+
spotify_album_id = row['spotify_album_id']
3202+
release_title = row['release_title']
3203+
release_artist_credit = row['artist_credit']
3204+
3205+
# MB side. Build a flat list of {disc, position, title, duration_ms}
3206+
# ordered by (disc, position). MB's `length` field is already in ms.
3207+
mb_tracks = []
3208+
if mb_release_id:
3209+
try:
3210+
mb_searcher = MusicBrainzSearcher()
3211+
mb_release = mb_searcher.get_release_details(mb_release_id)
3212+
except Exception:
3213+
logger.exception(
3214+
"tracklists: MB get_release_details failed for %s", mb_release_id,
3215+
)
3216+
mb_release = None
3217+
3218+
if mb_release:
3219+
for medium in mb_release.get('media', []):
3220+
disc_number = medium.get('position', 1)
3221+
for track in medium.get('tracks', []):
3222+
raw_length = track.get('length')
3223+
try:
3224+
duration_ms = int(raw_length) if raw_length is not None else None
3225+
except (TypeError, ValueError):
3226+
duration_ms = None
3227+
mb_tracks.append({
3228+
'disc_number': disc_number,
3229+
'position': track.get('position'),
3230+
'title': track.get('title'),
3231+
'duration_ms': duration_ms,
3232+
'duration_display': _format_duration(duration_ms),
3233+
})
3234+
3235+
# Spotify side. Album header (name + artists) + the full paginated
3236+
# tracklist (which already includes per-track artists, durations, and
3237+
# disc/track numbers thanks to the artists field we started capturing
3238+
# in 4f06d18).
3239+
spotify_album_title = None
3240+
spotify_album_artists = []
3241+
spotify_tracks = []
3242+
if spotify_album_id:
3243+
try:
3244+
spotify_client = SpotifyClient(logger=logger)
3245+
details = spotify_client.get_album_details(spotify_album_id)
3246+
tracks = spotify_client.get_album_tracks(spotify_album_id)
3247+
except Exception:
3248+
logger.exception(
3249+
"tracklists: Spotify fetch failed for album %s", spotify_album_id,
3250+
)
3251+
details = None
3252+
tracks = None
3253+
3254+
if details:
3255+
spotify_album_title = details.get('name')
3256+
spotify_album_artists = [
3257+
a.get('name') for a in (details.get('artists') or []) if a
3258+
]
3259+
for track in tracks or []:
3260+
duration_ms = track.get('duration_ms')
3261+
spotify_tracks.append({
3262+
'disc_number': track.get('disc_number', 1),
3263+
'position': track.get('track_number'),
3264+
'name': track.get('name'),
3265+
'artists': track.get('artists') or [],
3266+
'duration_ms': duration_ms,
3267+
'duration_display': _format_duration(duration_ms),
3268+
'spotify_track_id': track.get('id'),
3269+
})
3270+
3271+
return jsonify({
3272+
'success': True,
3273+
'link_id': link_id,
3274+
'mb_release': {
3275+
'title': release_title,
3276+
'artist_credit': release_artist_credit,
3277+
'mb_release_id': mb_release_id,
3278+
'tracks': mb_tracks,
3279+
},
3280+
'spotify_album': {
3281+
'title': spotify_album_title,
3282+
'artists': spotify_album_artists,
3283+
'spotify_album_id': spotify_album_id,
3284+
'tracks': spotify_tracks,
3285+
},
3286+
})
3287+
3288+
31543289
@admin_bp.route('/users')
31553290
def users_list():
31563291
"""List user accounts with email search and pagination."""

0 commit comments

Comments
 (0)