From 0b854babab6a31295440cb037390e6b9f95e0277 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Thu, 16 Jul 2026 12:51:01 -0500 Subject: [PATCH 1/3] fix(aqua): deduplicate album tracks --- ...4a1edce78892e26b82f3b295c3120ca0411d.json} | 4 +- apps/aqua/src/repos/music.rs | 59 ++++++++++++++++--- todo.md | 1 + 3 files changed, 53 insertions(+), 11 deletions(-) rename .sqlx/{query-50705a996b6048f8d9d145e27b34c62d8ead70b840deb29957ec60d2988b4ac9.json => query-ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d.json} (72%) diff --git a/.sqlx/query-50705a996b6048f8d9d145e27b34c62d8ead70b840deb29957ec60d2988b4ac9.json b/.sqlx/query-ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d.json similarity index 72% rename from .sqlx/query-50705a996b6048f8d9d145e27b34c62d8ead70b840deb29957ec60d2988b4ac9.json rename to .sqlx/query-ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d.json index 218d1dec..4b7ced19 100644 --- a/.sqlx/query-50705a996b6048f8d9d145e27b34c62d8ead70b840deb29957ec60d2988b4ac9.json +++ b/.sqlx/query-ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n WITH track_plays AS (\n SELECT\n p.uri,\n p.recording_mbid,\n p.track_name,\n p.processed_time,\n COALESCE(\n STRING_AGG(DISTINCT ptae.artist_name, ', ' ORDER BY ptae.artist_name),\n 'Unknown artist'\n ) AS artist_name\n FROM plays p\n LEFT JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n WHERE p.release_mbid = $1\n GROUP BY p.uri, p.recording_mbid, p.track_name, p.processed_time\n )\n SELECT DISTINCT ON (COALESCE(recording_mbid::text, LOWER(track_name)))\n uri,\n recording_mbid,\n track_name,\n artist_name,\n COUNT(*) OVER (\n PARTITION BY COALESCE(recording_mbid::text, LOWER(track_name))\n ) AS \"play_count!\"\n FROM track_plays\n ORDER BY COALESCE(recording_mbid::text, LOWER(track_name)), processed_time DESC, uri\n ", + "query": "\n WITH track_plays AS (\n SELECT\n p.uri,\n p.recording_mbid,\n p.track_name,\n p.processed_time,\n COALESCE(\n STRING_AGG(DISTINCT ptae.artist_name, ', ' ORDER BY ptae.artist_name),\n 'Unknown artist'\n ) AS artist_name\n FROM plays p\n LEFT JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n WHERE p.release_mbid = $1\n GROUP BY p.uri, p.recording_mbid, p.track_name, p.processed_time\n )\n SELECT DISTINCT ON (LOWER(track_name))\n uri,\n recording_mbid,\n track_name,\n artist_name,\n COUNT(*) OVER (\n PARTITION BY LOWER(track_name)\n ) AS \"play_count!\"\n FROM track_plays\n ORDER BY LOWER(track_name), processed_time DESC, uri\n ", "describe": { "columns": [ { @@ -42,5 +42,5 @@ null ] }, - "hash": "50705a996b6048f8d9d145e27b34c62d8ead70b840deb29957ec60d2988b4ac9" + "hash": "ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d" } diff --git a/apps/aqua/src/repos/music.rs b/apps/aqua/src/repos/music.rs index 7bf08b71..24ac7a11 100644 --- a/apps/aqua/src/repos/music.rs +++ b/apps/aqua/src/repos/music.rs @@ -85,6 +85,7 @@ fn parse_mbid(mbid: &str) -> anyhow::Result { struct MusicBrainzTrackOrder { by_recording_mbid: HashMap, by_title: HashMap, + canonical_recording_by_title: HashMap, } impl MusicBrainzTrackOrder { @@ -97,10 +98,23 @@ impl MusicBrainzTrackOrder { #[derive(Debug, Deserialize)] struct MusicBrainzRelease { + #[serde(rename = "artist-credit", default)] + artist_credit: Vec, #[serde(default)] media: Vec, } +#[derive(Debug, Deserialize)] +struct MusicBrainzArtistCredit { + artist: MusicBrainzArtist, +} + +#[derive(Debug, Deserialize, Clone)] +struct MusicBrainzArtist { + id: Uuid, + name: String, +} + #[derive(Debug, Deserialize)] struct MusicBrainzMedium { position: Option, @@ -135,9 +149,11 @@ fn normalize_track_title(title: &str) -> String { async fn fetch_musicbrainz_track_order( release_mbid: Uuid, -) -> anyhow::Result { +) -> anyhow::Result<(MusicBrainzTrackOrder, Option<(Uuid, String)>)> { let url = - format!("https://musicbrainz.org/ws/2/release/{release_mbid}?inc=recordings&fmt=json"); + format!( + "https://musicbrainz.org/ws/2/release/{release_mbid}?inc=artist-credits+recordings&fmt=json" + ); let release = reqwest::Client::builder() .timeout(StdDuration::from_secs(3)) .user_agent("teal-aqua/0.1 (https://teal.fm)") @@ -159,6 +175,12 @@ async fn fetch_musicbrainz_track_order( .by_recording_mbid .entry(recording_mbid) .or_insert((medium_position, track_position)); + if let Some(title) = track.title.as_deref() { + order + .canonical_recording_by_title + .entry(normalize_track_title(title)) + .or_insert(recording_mbid); + } } if let Some(title) = track.title { order @@ -169,7 +191,12 @@ async fn fetch_musicbrainz_track_order( } } - Ok(order) + let artist = release + .artist_credit + .first() + .map(|credit| (credit.artist.id, credit.artist.name.clone())); + + Ok((order, artist)) } fn sort_tracks_by_release_order(tracks: &mut [ObservedAlbumTrack], order: &MusicBrainzTrackOrder) { @@ -411,16 +438,16 @@ impl MusicRepo for PgDataSource { WHERE p.release_mbid = $1 GROUP BY p.uri, p.recording_mbid, p.track_name, p.processed_time ) - SELECT DISTINCT ON (COALESCE(recording_mbid::text, LOWER(track_name))) + SELECT DISTINCT ON (LOWER(track_name)) uri, recording_mbid, track_name, artist_name, COUNT(*) OVER ( - PARTITION BY COALESCE(recording_mbid::text, LOWER(track_name)) + PARTITION BY LOWER(track_name) ) AS "play_count!" FROM track_plays - ORDER BY COALESCE(recording_mbid::text, LOWER(track_name)), processed_time DESC, uri + ORDER BY LOWER(track_name), processed_time DESC, uri "#, mbid ) @@ -439,9 +466,17 @@ impl MusicRepo for PgDataSource { }) }) .collect::>(); - let track_order = fetch_musicbrainz_track_order(mbid) + let (track_order, musicbrainz_artist_name) = fetch_musicbrainz_track_order(mbid) .await .unwrap_or_default(); + for track in &mut observed_tracks { + if let Some(recording_mbid) = track_order + .canonical_recording_by_title + .get(&normalize_track_title(&track.name)) + { + track.recording_mbid = Some(*recording_mbid); + } + } sort_tracks_by_release_order(&mut observed_tracks, &track_order); let tracks = observed_tracks .into_iter() @@ -547,8 +582,14 @@ impl MusicRepo for PgDataSource { Ok(AlbumPage { album: AlbumView { - artist_mbid: album_row.artist_mbid.map(mbid_uri), - artist_name: album_row.artist_name.into(), + artist_mbid: musicbrainz_artist_name + .as_ref() + .map(|(artist_mbid, _)| mbid_uri(*artist_mbid)) + .or_else(|| album_row.artist_mbid.map(mbid_uri)), + artist_name: musicbrainz_artist_name + .map(|(_, artist_name)| artist_name) + .unwrap_or(album_row.artist_name) + .into(), mbid: mbid_uri(album_row.mbid), name: album_row.name.into(), play_count: album_row.play_count, diff --git a/todo.md b/todo.md index 318bdbc0..ac781781 100644 --- a/todo.md +++ b/todo.md @@ -17,6 +17,7 @@ Last synced with GitHub and Linear issues: 2026-06-14. - Dependency refresh (2026-07-10): updated the Rust lockfile, root and standalone lexicon CLI pnpm locks, Expo SDK 57/RN 0.86, AT Protocol clients and lexicon generator, plus current compatible workspace tooling. Regenerated lexicons now normalize TypeScript relative imports for Metro; Amethyst record creation supplies required `$type` fields. Verified with offline Rust tests/checks, TypeScript, Jest, and full workspace builds. - Cadet live-ingestion recovery (2026-07-11): the preview consumer was repeatedly stalling because every incoming play synchronously refreshed four materialized views. The Compose Cadet service now enables its existing deferred-refresh mode so Jetstream events can drain without blocking for several seconds per play. Follow-up: add a periodic materialized-view refresh path before relying on live aggregate counts. - Music detail social-link fix (2026-07-11): social-post track links now carry the source post URI instead of an empty play URI, track pages match listens by recording identity with a metadata fallback, and the originating post renders on the track page. +- Music album metadata/dedup fix (2026-07-16): album pages use MusicBrainz release artist metadata, collapse case/recording-ID variants into one track row, preserve canonical release recording IDs, and sum merged listen counts. ## Local Open Work From c74a000890d5d281f7fba717f46385605142cc37 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Thu, 16 Jul 2026 13:02:33 -0500 Subject: [PATCH 2/3] fix(aqua): merge duplicate artist albums --- ...9fa484c12e0fa5693d5f37a0f169189c3c4e7.json | 34 +++++++++++++ ...adb8946f3df1bf85b8a7dc3b7c6f2b5733e6d.json | 34 ------------- apps/aqua/src/repos/music.rs | 48 ++++++++++++++----- todo.md | 2 +- 4 files changed, 72 insertions(+), 46 deletions(-) create mode 100644 .sqlx/query-8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7.json delete mode 100644 .sqlx/query-abe60b617d4cd5861a4a8f80755adb8946f3df1bf85b8a7dc3b7c6f2b5733e6d.json diff --git a/.sqlx/query-8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7.json b/.sqlx/query-8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7.json new file mode 100644 index 00000000..fd38ae7a --- /dev/null +++ b/.sqlx/query-8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH release_variants AS (\n SELECT\n LOWER(p.release_name) AS normalized_name,\n p.release_mbid AS mbid,\n MAX(p.release_name) AS name,\n COUNT(DISTINCT p.uri) AS play_count,\n MAX(p.played_time) AS last_played\n FROM plays p\n INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n WHERE ptae.artist_id = $1\n AND p.release_mbid IS NOT NULL\n AND p.release_name IS NOT NULL\n GROUP BY LOWER(p.release_name), p.release_mbid\n )\n ,release_usage AS (\n SELECT\n release_variants.*,\n COUNT(*) OVER (PARTITION BY mbid) AS release_name_count\n FROM release_variants\n )\n ,selected_releases AS (\n SELECT DISTINCT ON (normalized_name)\n normalized_name,\n mbid,\n name,\n SUM(play_count) OVER (PARTITION BY normalized_name)::bigint AS play_count,\n MAX(last_played) OVER (PARTITION BY normalized_name) AS last_played\n FROM release_usage\n ORDER BY normalized_name,\n (release_name_count = 1) DESC,\n release_usage.play_count DESC,\n last_played DESC NULLS LAST,\n mbid\n )\n SELECT mbid AS \"mbid!\", name AS \"name!\", play_count AS \"play_count!\"\n FROM selected_releases\n ORDER BY last_played DESC NULLS LAST, name\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true, + null, + null + ] + }, + "hash": "8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7" +} diff --git a/.sqlx/query-abe60b617d4cd5861a4a8f80755adb8946f3df1bf85b8a7dc3b7c6f2b5733e6d.json b/.sqlx/query-abe60b617d4cd5861a4a8f80755adb8946f3df1bf85b8a7dc3b7c6f2b5733e6d.json deleted file mode 100644 index ba32f18f..00000000 --- a/.sqlx/query-abe60b617d4cd5861a4a8f80755adb8946f3df1bf85b8a7dc3b7c6f2b5733e6d.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n p.release_mbid AS \"mbid!\",\n MAX(p.release_name) AS \"name!\",\n COUNT(DISTINCT p.uri) AS \"play_count!\"\n FROM plays p\n INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n WHERE ptae.artist_id = $1\n AND p.release_mbid IS NOT NULL\n AND p.release_name IS NOT NULL\n GROUP BY p.release_mbid\n ORDER BY MAX(p.played_time) DESC NULLS LAST, MAX(p.release_name)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mbid!", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name!", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "play_count!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int4" - ] - }, - "nullable": [ - true, - null, - null - ] - }, - "hash": "abe60b617d4cd5861a4a8f80755adb8946f3df1bf85b8a7dc3b7c6f2b5733e6d" -} diff --git a/apps/aqua/src/repos/music.rs b/apps/aqua/src/repos/music.rs index 24ac7a11..e5f62241 100644 --- a/apps/aqua/src/repos/music.rs +++ b/apps/aqua/src/repos/music.rs @@ -247,17 +247,43 @@ impl MusicRepo for PgDataSource { let rows = sqlx::query!( r#" - SELECT - p.release_mbid AS "mbid!", - MAX(p.release_name) AS "name!", - COUNT(DISTINCT p.uri) AS "play_count!" - FROM plays p - INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri - WHERE ptae.artist_id = $1 - AND p.release_mbid IS NOT NULL - AND p.release_name IS NOT NULL - GROUP BY p.release_mbid - ORDER BY MAX(p.played_time) DESC NULLS LAST, MAX(p.release_name) + WITH release_variants AS ( + SELECT + LOWER(p.release_name) AS normalized_name, + p.release_mbid AS mbid, + MAX(p.release_name) AS name, + COUNT(DISTINCT p.uri) AS play_count, + MAX(p.played_time) AS last_played + FROM plays p + INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri + WHERE ptae.artist_id = $1 + AND p.release_mbid IS NOT NULL + AND p.release_name IS NOT NULL + GROUP BY LOWER(p.release_name), p.release_mbid + ) + ,release_usage AS ( + SELECT + release_variants.*, + COUNT(*) OVER (PARTITION BY mbid) AS release_name_count + FROM release_variants + ) + ,selected_releases AS ( + SELECT DISTINCT ON (normalized_name) + normalized_name, + mbid, + name, + SUM(play_count) OVER (PARTITION BY normalized_name)::bigint AS play_count, + MAX(last_played) OVER (PARTITION BY normalized_name) AS last_played + FROM release_usage + ORDER BY normalized_name, + (release_name_count = 1) DESC, + release_usage.play_count DESC, + last_played DESC NULLS LAST, + mbid + ) + SELECT mbid AS "mbid!", name AS "name!", play_count AS "play_count!" + FROM selected_releases + ORDER BY last_played DESC NULLS LAST, name "#, artist.id ) diff --git a/todo.md b/todo.md index ac781781..6aff8468 100644 --- a/todo.md +++ b/todo.md @@ -17,7 +17,7 @@ Last synced with GitHub and Linear issues: 2026-06-14. - Dependency refresh (2026-07-10): updated the Rust lockfile, root and standalone lexicon CLI pnpm locks, Expo SDK 57/RN 0.86, AT Protocol clients and lexicon generator, plus current compatible workspace tooling. Regenerated lexicons now normalize TypeScript relative imports for Metro; Amethyst record creation supplies required `$type` fields. Verified with offline Rust tests/checks, TypeScript, Jest, and full workspace builds. - Cadet live-ingestion recovery (2026-07-11): the preview consumer was repeatedly stalling because every incoming play synchronously refreshed four materialized views. The Compose Cadet service now enables its existing deferred-refresh mode so Jetstream events can drain without blocking for several seconds per play. Follow-up: add a periodic materialized-view refresh path before relying on live aggregate counts. - Music detail social-link fix (2026-07-11): social-post track links now carry the source post URI instead of an empty play URI, track pages match listens by recording identity with a metadata fallback, and the originating post renders on the track page. -- Music album metadata/dedup fix (2026-07-16): album pages use MusicBrainz release artist metadata, collapse case/recording-ID variants into one track row, preserve canonical release recording IDs, and sum merged listen counts. +- Music album metadata/dedup fix (2026-07-16): album pages use MusicBrainz release artist metadata, collapse case/recording-ID variants into one track row, preserve canonical release recording IDs, and sum merged listen counts; artist pages now merge duplicate release titles and avoid cross-title MBID collisions. ## Local Open Work From ef09bde55c82cd813060761e344f1ab90736fa34 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Thu, 16 Jul 2026 13:14:08 -0500 Subject: [PATCH 3/3] feat(music): automate catalog normalization --- .../app/(tabs)/:o/music/[artist]/index.tsx | 120 +++++++++++------- apps/aqua/src/repos/music.rs | 75 ++++++++++- lexicons/fm.teal.alpha/music/defs.json | 4 + .../src/types/fm/teal/alpha/music/defs.ts | 1 + services/cadet/src/main.rs | 23 ++++ todo.md | 1 + 6 files changed, 173 insertions(+), 51 deletions(-) diff --git a/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx b/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx index d0ce7517..bf89d38d 100644 --- a/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx +++ b/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx @@ -108,6 +108,66 @@ export default function ArtistDetail() { } }; + const renderRelease = (album: ArtistView["albums"][number]) => { + const art = coverArtUrl(album.mbid); + return ( + + + + {art ? ( + + ) : ( + + )} + + + + {album.name} + + + {album.playCount} listens + + + + + + ); + }; + + const discographyGroups: Array<{ + title: string; + releases: ArtistView["albums"]; + }> = artist + ? [ + { + title: "Albums", + releases: artist.albums.filter( + (release) => release.releaseType !== "single", + ), + }, + { + title: "Singles", + releases: artist.albums.filter( + (release) => release.releaseType === "single", + ), + }, + ] + : []; + return ( }> - - {artist.albums.map((album) => { - const art = coverArtUrl(album.mbid); - return ( - - - - {art ? ( - - ) : ( - - )} - - - - {album.name} - - - {album.playCount} listens - - - - - - ); - })} - + {discographyGroups.map(({ title, releases }) => + releases.length > 0 ? ( + + + {title} + + + {releases.map(renderRelease)} + + + ) : null, + )} )} diff --git a/apps/aqua/src/repos/music.rs b/apps/aqua/src/repos/music.rs index e5f62241..5b2e06ec 100644 --- a/apps/aqua/src/repos/music.rs +++ b/apps/aqua/src/repos/music.rs @@ -1,9 +1,11 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::time::Duration as StdDuration; use async_trait::async_trait; +use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::from_json_value; -use jacquard_common::types::string::{AtUri, Did}; +use jacquard_common::types::string::{AtprotoStr, AtUri, Did}; +use jacquard_common::types::value::Data; use serde::Deserialize; use types::fm_teal::alpha::feed::PlayView; use types::fm_teal::alpha::music::{ @@ -115,6 +117,25 @@ struct MusicBrainzArtist { name: String, } +#[derive(Debug, Deserialize)] +struct MusicBrainzArtistReleases { + #[serde(default)] + releases: Vec, +} + +#[derive(Debug, Deserialize)] +struct MusicBrainzArtistRelease { + id: Uuid, + #[serde(rename = "release-group")] + release_group: Option, +} + +#[derive(Debug, Deserialize)] +struct MusicBrainzReleaseGroup { + #[serde(rename = "primary-type")] + primary_type: Option, +} + #[derive(Debug, Deserialize)] struct MusicBrainzMedium { position: Option, @@ -199,6 +220,40 @@ async fn fetch_musicbrainz_track_order( Ok((order, artist)) } +async fn fetch_artist_release_types(artist_mbid: Uuid) -> anyhow::Result> { + let url = format!( + "https://musicbrainz.org/ws/2/release?artist={artist_mbid}&inc=release-groups&fmt=json&limit=100" + ); + let releases = reqwest::Client::builder() + .timeout(StdDuration::from_secs(3)) + .user_agent("teal-aqua/0.1 (https://teal.fm)") + .build()? + .get(url) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + Ok(releases + .releases + .into_iter() + .map(|release| { + let release_type = match release + .release_group + .and_then(|group| group.primary_type) + .as_deref() + { + Some("Album") => "album", + Some("Single") => "single", + Some("EP") => "ep", + _ => "other", + }; + (release.id, release_type.to_string()) + }) + .collect()) +} + fn sort_tracks_by_release_order(tracks: &mut [ObservedAlbumTrack], order: &MusicBrainzTrackOrder) { tracks.sort_by(|a, b| { match ( @@ -291,6 +346,12 @@ impl MusicRepo for PgDataSource { .await?; let artist_name = artist.name; + let release_types = match artist.mbid { + Some(artist_mbid) => fetch_artist_release_types(artist_mbid) + .await + .unwrap_or_default(), + None => HashMap::new(), + }; let artist_mbid = artist.mbid.map(mbid_uri); let albums = rows .into_iter() @@ -300,7 +361,15 @@ impl MusicRepo for PgDataSource { mbid: mbid_uri(row.mbid), name: row.name.into(), play_count: row.play_count, - extra_data: Default::default(), + extra_data: Some(BTreeMap::from([( + SmolStr::new_static("releaseType"), + Data::String(AtprotoStr::new(SmolStr::new( + release_types + .get(&row.mbid) + .map(String::as_str) + .unwrap_or("other"), + ))), + )])), }) .collect(); diff --git a/lexicons/fm.teal.alpha/music/defs.json b/lexicons/fm.teal.alpha/music/defs.json index 55a233d6..18368ebc 100644 --- a/lexicons/fm.teal.alpha/music/defs.json +++ b/lexicons/fm.teal.alpha/music/defs.json @@ -98,6 +98,10 @@ }, "playCount": { "type": "integer" + }, + "releaseType": { + "type": "string", + "knownValues": ["album", "single", "ep", "other"] } } }, diff --git a/packages/lexicons/src/types/fm/teal/alpha/music/defs.ts b/packages/lexicons/src/types/fm/teal/alpha/music/defs.ts index 8adeab1a..238735fb 100644 --- a/packages/lexicons/src/types/fm/teal/alpha/music/defs.ts +++ b/packages/lexicons/src/types/fm/teal/alpha/music/defs.ts @@ -85,6 +85,7 @@ export interface AlbumSummary { artistMbid?: string artistName: string playCount: number + releaseType?: 'album' | 'single' | 'ep' | 'other' | (string & {}) } const hashAlbumSummary = 'albumSummary' diff --git a/services/cadet/src/main.rs b/services/cadet/src/main.rs index 604ed4d4..756c6248 100644 --- a/services/cadet/src/main.rs +++ b/services/cadet/src/main.rs @@ -86,6 +86,29 @@ async fn main() { let ingestors = teal_ingestors::build_ingestors(pool.clone()); + // Keep the indexed catalog normalized as new clients introduce alternate + // release/recording IDs for the same artist and title. + let consolidation_interval_secs = std::env::var("CADET_CONSOLIDATION_INTERVAL_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(6 * 60 * 60); + let consolidation_pool = pool.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs( + consolidation_interval_secs, + )); + interval.tick().await; + + loop { + let ingestor = ingestors::teal::feed_play::PlayIngestor::new(consolidation_pool.clone()); + if let Err(error) = ingestor.run_full_consolidation().await { + error!("Automatic catalog consolidation failed: {}", error); + } + interval.tick().await; + } + }); + // CAR import job worker let car_ingestor = ingestors::car::CarImportIngestor::new(pool.clone()); let redis_url = diff --git a/todo.md b/todo.md index 6aff8468..acd4b6e2 100644 --- a/todo.md +++ b/todo.md @@ -18,6 +18,7 @@ Last synced with GitHub and Linear issues: 2026-06-14. - Cadet live-ingestion recovery (2026-07-11): the preview consumer was repeatedly stalling because every incoming play synchronously refreshed four materialized views. The Compose Cadet service now enables its existing deferred-refresh mode so Jetstream events can drain without blocking for several seconds per play. Follow-up: add a periodic materialized-view refresh path before relying on live aggregate counts. - Music detail social-link fix (2026-07-11): social-post track links now carry the source post URI instead of an empty play URI, track pages match listens by recording identity with a metadata fallback, and the originating post renders on the track page. - Music album metadata/dedup fix (2026-07-16): album pages use MusicBrainz release artist metadata, collapse case/recording-ID variants into one track row, preserve canonical release recording IDs, and sum merged listen counts; artist pages now merge duplicate release titles and avoid cross-title MBID collisions. +- Automatic catalog cleanup/discography split (2026-07-16): Cadet now runs catalog consolidation on a six-hour interval, artist responses expose MusicBrainz release-group types, and Amethyst separates Albums from Singles. Audited the top 12 artists in the preview; none had duplicate album titles after normalization. ## Local Open Work