Skip to content

Commit 5a494ee

Browse files
committed
Add MusicBrainz release selection
Add per-user release preferences, normalized release browsing, and HTMX picker support. Move the picker into the album tracking modal metadata panel.\n\nRefs #907
1 parent 34a11de commit 5a494ee

17 files changed

Lines changed: 1044 additions & 84 deletions

docs/agents/music_integration.md

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,26 @@ class AlbumTracker(models.Model):
122122

123123
Trackers are unique per user+artist/album (single tracker per target), default to `In Progress`, and mirror Media fields (status, score, start/end dates, notes).
124124

125+
#### MusicReleasePreference
126+
Per-user display preference for a concrete MusicBrainz release/pressing. This is intentionally separate from `AlbumTracker`: it does not change the shared album identity or move play history to another tracklist.
127+
128+
```python
129+
class MusicReleasePreference(models.Model):
130+
user = models.ForeignKey(User, related_name="music_release_preferences")
131+
album = models.ForeignKey(Album, related_name="music_release_preferences")
132+
release_id = models.CharField(max_length=36)
133+
created_at = models.DateTimeField(auto_now_add=True)
134+
updated_at = models.DateTimeField(auto_now=True)
135+
```
136+
137+
Preferences are unique per user+album. The selected release overrides only the detail-page cover and release metadata; the canonical `Album`, `Track`, `Music`, collection, and history rows remain unchanged.
138+
139+
On the album detail page and album tracking modal, a saved preference is hydrated from
140+
MusicBrainz only when the selected release still belongs to the album's release group.
141+
If the release is missing, no longer belongs to that group, or MusicBrainz is
142+
unavailable, the UI falls back to the canonical album metadata and cover without
143+
deleting the preference or changing any tracking rows.
144+
125145
### Enums
126146

127147
```python
@@ -205,9 +225,16 @@ def get_release_for_group(release_group_id):
205225

206226
def get_release(release_id, skip_cover_art=False):
207227
"""Get detailed release metadata (album-level)."""
208-
# Fetches /release/{id} with inc=recordings+artists
209-
# Returns: title, artist, release_date, release_type, image, tracklist (optional)
228+
# Fetches /release/{id} with recordings, labels, release-groups, and media
229+
# Returns: title, artist, release metadata, cover, and tracklist (optional)
210230
# skip_cover_art avoids cover art calls when hydrating many releases
231+
232+
def get_release_group_releases(release_group_id):
233+
"""Get all releases/pressings in a MusicBrainz release group."""
234+
# Browses /release with release-group filtering and pagination
235+
# Returns normalized release_id, date, country, status, format, packaging,
236+
# labels, catalog numbers, barcode, track count, and cover URL.
237+
# Results are cached and official releases sort before other statuses.
211238
```
212239

213240
#### Cover Art Functions
@@ -383,6 +410,14 @@ def album_delete(request):
383410
@require_POST
384411
def song_save(request):
385412
"""Add a listen for a track (like episode_save)."""
413+
414+
@require_GET
415+
def list_music_releases(request, album_id):
416+
"""Return the HTMX release/pressing picker for an album."""
417+
418+
@require_POST
419+
def set_music_release(request, album_id):
420+
"""Save a validated per-user release/pressing preference."""
386421
```
387422

388423
### Creation Views
@@ -437,6 +472,10 @@ path("music/album/create/<str:release_mbid>/", views.create_album_from_search, n
437472
# Metadata sync
438473
path("music/artist/<int:artist_id>/sync/", views.sync_artist_discography_view, name="sync_artist_discography"),
439474
path("music/album/<int:album_id>/sync/", views.sync_album_metadata_view, name="sync_album_metadata"),
475+
476+
# Release/pressing picker
477+
path("details/music/album/<int:album_id>/releases", views.list_music_releases, name="list_music_releases"),
478+
path("details/music/album/<int:album_id>/release", views.set_music_release, name="set_music_release"),
440479
```
441480

442481
## Templates
@@ -455,8 +494,19 @@ Layout matches TV show detail:
455494
Layout matches Season detail:
456495
- **Hero section**: Album cover, title, artist link, release type/year chips, stats
457496
- **Action button**: Album tracking modal
458-
- **Left column**: Your History, Actions, Details
459-
- **Right column**: Track list with "Track Song" buttons for each track
497+
- **Left column**: Your History, Actions, and Details
498+
- **Right column**: Track list with "Track Song" buttons for each track
499+
500+
The release picker uses the shared Hardcover edition-picker interaction pattern in the
501+
album tracking modal's Metadata tab, but validates MusicBrainz release IDs against the
502+
album's release group and keeps release selection separate from tracking state.
503+
504+
The picker is available to authenticated users even when an album has no
505+
`AlbumTracker`. It sends a safe `return_url` through the HTMX form and uses the
506+
following internal endpoints:
507+
508+
- `GET details/music/album/<album_id>/releases`: browse and filter release-group pressings.
509+
- `POST details/music/album/<album_id>/release`: validate membership in the release group and save the per-user preference.
460510

461511
### Album Grid (`src/templates/app/components/album_grid.html`)
462512

@@ -511,6 +561,7 @@ Custom admin classes for music models:
511561
- `TrackAdmin`: Inline editing, filterable by album
512562
- `ArtistTrackerAdmin`: User/artist/status filters
513563
- `AlbumTrackerAdmin`: User/album/status filters
564+
- `MusicReleasePreferenceAdmin`: User/album/release ID filters
514565

515566
## Statistics
516567

src/app/admin.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ class TrackAdmin(admin.ModelAdmin):
114114
"ItemProviderLink",
115115
"MetadataProviderPreference",
116116
"HardcoverEditionPreference",
117+
"MusicReleasePreference",
117118
"CollectionEntry",
118119
"Tag",
119120
"ItemTag",
@@ -142,6 +143,7 @@ class TrackAdmin(admin.ModelAdmin):
142143
ArtistTracker,
143144
BackfillReconcileState,
144145
MetadataBackfillState,
146+
MusicReleasePreference,
145147
Track,
146148
)
147149

@@ -171,6 +173,18 @@ class AlbumTrackerAdmin(admin.ModelAdmin):
171173
admin.site.register(AlbumTracker, AlbumTrackerAdmin)
172174

173175

176+
class MusicReleasePreferenceAdmin(admin.ModelAdmin):
177+
"""Admin for per-user MusicBrainz release choices."""
178+
179+
list_display = ["user", "album", "release_id", "updated_at"]
180+
list_filter = ["updated_at"]
181+
search_fields = ["user__username", "album__title", "release_id"]
182+
raw_id_fields = ["user", "album"]
183+
184+
185+
admin.site.register(MusicReleasePreference, MusicReleasePreferenceAdmin)
186+
187+
174188
class MetadataBackfillStateAdmin(admin.ModelAdmin):
175189
"""Admin for metadata backfill tracking."""
176190

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Generated by Django 5.2.16 on 2026-08-22
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("app", "0165_item_mal_rating_item_mal_rating_count"),
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="MusicReleasePreference",
17+
fields=[
18+
(
19+
"id",
20+
models.BigAutoField(
21+
auto_created=True,
22+
primary_key=True,
23+
serialize=False,
24+
verbose_name="ID",
25+
),
26+
),
27+
(
28+
"release_id",
29+
models.CharField(
30+
help_text="MusicBrainz Release ID (UUID) selected for display",
31+
max_length=36,
32+
),
33+
),
34+
("created_at", models.DateTimeField(auto_now_add=True)),
35+
("updated_at", models.DateTimeField(auto_now=True)),
36+
(
37+
"album",
38+
models.ForeignKey(
39+
on_delete=django.db.models.deletion.CASCADE,
40+
related_name="music_release_preferences",
41+
to="app.album",
42+
),
43+
),
44+
(
45+
"user",
46+
models.ForeignKey(
47+
on_delete=django.db.models.deletion.CASCADE,
48+
related_name="music_release_preferences",
49+
to=settings.AUTH_USER_MODEL,
50+
),
51+
),
52+
],
53+
options={
54+
"constraints": [
55+
models.UniqueConstraint(
56+
fields=("user", "album"),
57+
name="unique_music_release_preference_per_user_album",
58+
),
59+
],
60+
},
61+
),
62+
]

src/app/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
ArtistMember,
6060
ArtistTracker,
6161
Music,
62+
MusicReleasePreference,
6263
Track,
6364
)
6465
from app.models.playback import PlaybackProgress
@@ -123,6 +124,7 @@
123124
"Movie",
124125
"MoviePlay",
125126
"Music",
127+
"MusicReleasePreference",
126128
"Person",
127129
"PersonGender",
128130
"PlaybackProgress",

src/app/models/music.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,41 @@ def __str__(self):
140140
return self.title
141141

142142

143+
class MusicReleasePreference(models.Model):
144+
"""Per-user MusicBrainz release choice for an album."""
145+
146+
user = models.ForeignKey(
147+
settings.AUTH_USER_MODEL,
148+
on_delete=models.CASCADE,
149+
related_name="music_release_preferences",
150+
)
151+
album = models.ForeignKey(
152+
Album,
153+
on_delete=models.CASCADE,
154+
related_name="music_release_preferences",
155+
)
156+
release_id = models.CharField(
157+
max_length=36,
158+
help_text="MusicBrainz Release ID (UUID) selected for display",
159+
)
160+
created_at = models.DateTimeField(auto_now_add=True)
161+
updated_at = models.DateTimeField(auto_now=True)
162+
163+
class Meta:
164+
"""Model and field configuration."""
165+
166+
constraints = [
167+
models.UniqueConstraint(
168+
fields=["user", "album"],
169+
name="unique_music_release_preference_per_user_album",
170+
),
171+
]
172+
173+
def __str__(self):
174+
"""Return the selected release label."""
175+
return f"{self.user_id}:{self.album_id}->release/{self.release_id}"
176+
177+
143178
class AlbumArtist(models.Model):
144179
"""Album artist."""
145180

0 commit comments

Comments
 (0)