diff --git a/crates/qbz-external-reco/src/carousels.rs b/crates/qbz-external-reco/src/carousels.rs index 00882b05a..284752128 100644 --- a/crates/qbz-external-reco/src/carousels.rs +++ b/crates/qbz-external-reco/src/carousels.rs @@ -22,6 +22,9 @@ use crate::validate::{ use crate::{RecoCatalog, RecoInputs}; const DISPLAY_CAP: usize = 20; +/// Public alias for the paint layer (it composes the visible rows after +/// filtering/dedup, so it needs the canonical per-rail visible cap). +pub const ARTIST_DISPLAY_CAP: usize = DISPLAY_CAP; const PLAYLIST_CAP: usize = 30; const VALIDATE_CONCURRENCY: usize = 6; const ARTIST_SEEDS: usize = 6; @@ -239,7 +242,13 @@ async fn similar_artist_row( } let candidates: Vec = round_robin(groups).into_iter().take(45).collect(); let pool = validate_artist_pool(inputs.catalog, inputs.cache, candidates).await; - rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP) + // Rotate for daily variety but do NOT truncate at DISPLAY_CAP: the paint + // layer composes the visible rows AFTER followed/blacklisted/dismissed + // filtering + cross-rail dedup (compose_artist_rails) and keeps the + // remaining validated candidates as backfill overflow — they ride the + // results blob, so replacements need no extra network. + let take = pool.len(); + rotate_take(pool, inputs.rotation_seed, take) } /// "More like the artists you love" — your COMMON taste (overall top). @@ -258,6 +267,59 @@ pub async fn build_rec_artists_recent( similar_artist_row(inputs, history, "1month").await } +// ── Display composition (filter + cross-rail dedup + backfill split) ──────── + +/// One artist rail after display composition: the visible rows (the first +/// `cap` survivors) plus the retained overflow (the remaining survivors, in +/// pool order), which the paint layer keeps for live backfill after a +/// "not interested" dismissal. +#[derive(Debug, Clone, Default)] +pub struct ArtistRailComposition { + pub visible: Vec, + pub overflow: Vec, +} + +/// Compose the two Recommended-Artist rails for display: +/// +/// - drop every candidate whose Qobuz id is in `excluded` (the frontend folds +/// followed / blacklisted / dismissed ids into that set; fail-soft = pass +/// an empty set), +/// - dedup WITHIN and ACROSS rails: an id shows in at most one rail — the +/// COMMON rail is composed first and wins — and overflow ids are claimed +/// too, so a common-overflow id cannot resurface in recent's visible, +/// - the first `cap` survivors per rail are visible; the rest is overflow. +pub fn compose_artist_rails( + common_pool: Vec, + recent_pool: Vec, + excluded: &HashSet, + cap: usize, +) -> (ArtistRailComposition, ArtistRailComposition) { + let mut seen: HashSet = HashSet::new(); + let common = compose_one_rail(common_pool, excluded, &mut seen, cap); + let recent = compose_one_rail(recent_pool, excluded, &mut seen, cap); + (common, recent) +} + +fn compose_one_rail( + pool: Vec, + excluded: &HashSet, + seen: &mut HashSet, + cap: usize, +) -> ArtistRailComposition { + let mut out = ArtistRailComposition::default(); + for reco in pool { + if excluded.contains(&reco.qobuz_artist_id) || !seen.insert(reco.qobuz_artist_id) { + continue; + } + if out.visible.len() < cap { + out.visible.push(reco); + } else { + out.overflow.push(reco); + } + } + out +} + // ── Recommended Albums (Last.fm: your artists' top albums, not scrobbled) ─── pub async fn build_rec_albums(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec { @@ -637,3 +699,77 @@ pub async fn build_editorial(inputs: &RecoInputs<'_>) -> (Vec, Vec ArtistReco { + ArtistReco { + qobuz_artist_id: id, + name: format!("Artist {id}"), + image_url: String::new(), + subtitle: String::new(), + source: RecoSource::LastFm, + } + } + + fn ids(recos: &[ArtistReco]) -> Vec { + recos.iter().map(|r| r.qobuz_artist_id).collect() + } + + #[test] + fn compose_excludes_ids_from_visible_and_overflow() { + let common = vec![reco(1), reco(2), reco(3), reco(4)]; + let recent = vec![reco(5), reco(6)]; + let excluded = HashSet::from([2, 4, 5]); + let (c, r) = compose_artist_rails(common, recent, &excluded, 2); + assert_eq!(ids(&c.visible), vec![1, 3]); + assert!(c.overflow.is_empty(), "excluded ids leave no overflow"); + assert_eq!(ids(&r.visible), vec![6]); + } + + #[test] + fn compose_dedups_cross_rail_common_wins() { + let common = vec![reco(1), reco(2)]; + let recent = vec![reco(2), reco(3)]; + let (c, r) = compose_artist_rails(common, recent, &HashSet::new(), 20); + assert_eq!(ids(&c.visible), vec![1, 2]); + assert_eq!(ids(&r.visible), vec![3], "id 2 appears only in common"); + } + + #[test] + fn compose_dedups_within_rail() { + let common = vec![reco(1), reco(1), reco(2)]; + let (c, _r) = compose_artist_rails(common, Vec::new(), &HashSet::new(), 20); + assert_eq!(ids(&c.visible), vec![1, 2]); + } + + #[test] + fn compose_splits_visible_and_overflow_in_order() { + let common: Vec = (1..=5).map(reco).collect(); + let excluded = HashSet::from([1]); + let (c, _r) = compose_artist_rails(common, Vec::new(), &excluded, 3); + // The exclusion punches a hole in the first window; the next + // candidates move up into the visible rows, the rest is overflow — + // both in pool order (the backfill contract). + assert_eq!(ids(&c.visible), vec![2, 3, 4]); + assert_eq!(ids(&c.overflow), vec![5]); + } + + #[test] + fn compose_cross_rail_dedup_also_covers_overflow() { + // An id sitting in common's OVERFLOW must not resurface in recent's + // visible rows (it is still claimed for potential backfill). + let common: Vec = (1..=3).map(reco).collect(); + let recent = vec![reco(3), reco(4)]; + let (c, r) = compose_artist_rails(common, recent, &HashSet::new(), 2); + assert_eq!(ids(&c.visible), vec![1, 2]); + assert_eq!(ids(&c.overflow), vec![3]); + assert_eq!(ids(&r.visible), vec![4], "id 3 is claimed by common"); + assert!(r.overflow.is_empty()); + } +} diff --git a/crates/qbz-external-reco/src/lib.rs b/crates/qbz-external-reco/src/lib.rs index bd47ba77f..f179d1643 100644 --- a/crates/qbz-external-reco/src/lib.rs +++ b/crates/qbz-external-reco/src/lib.rs @@ -27,6 +27,7 @@ use qbz_integrations::{LastFmClient, ListenBrainzClient, MusicBrainzClient}; use qbz_models::{Album, Artist, Track}; pub use cache::RecoCache; +pub use carousels::{compose_artist_rails, ArtistRailComposition, ARTIST_DISPLAY_CAP}; pub use types::{ AlbumReco, ArtistReco, ExternalCarousels, ExtHistory, LocalHistory, RecoSource, TrackReco, }; diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index d83cdcd3a..22af0b6d3 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4794,3 +4794,22 @@ msgstr "Automatisch (Kontosprache)" msgid "Translation is not available right now. Please try again later." msgstr "Die Übersetzung ist gerade nicht verfügbar. Bitte versuche es später erneut." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Abgelehnte Künstler verschwinden nur aus deinen Empfehlungen — sie erscheinen weiterhin in der Suche und überall sonst." + +msgid "No dismissed artists" +msgstr "Keine abgelehnten Künstler" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Um einen Künstler nicht mehr in Empfehlungen zu sehen, öffne das Menü seiner Karte und wähle Nicht interessiert." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} erscheint nicht mehr in Empfehlungen" + +msgid "Artist dismissed from Recommendations" +msgstr "Künstler aus Empfehlungen abgelehnt" + +msgid "{} restored to Recommendations" +msgstr "{} wieder in Empfehlungen aufgenommen" diff --git a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po index 41617e4f9..149dea5e9 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -587,3 +587,25 @@ msgstr "Auto (account language)" msgid "Translation is not available right now. Please try again later." msgstr "Translation is not available right now. Please try again later." +# Discover Recommendations — Not interested (reco-scoped dismissals) + +msgid "Not interested" +msgstr "Not interested" + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." + +msgid "No dismissed artists" +msgstr "No dismissed artists" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} won't appear in Recommendations anymore" + +msgid "Artist dismissed from Recommendations" +msgstr "Artist dismissed from Recommendations" + +msgid "{} restored to Recommendations" +msgstr "{} restored to Recommendations" diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index ce05d188b..fd8c3ed2c 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -4824,3 +4824,22 @@ msgstr "Automático (idioma de la cuenta)" msgid "Translation is not available right now. Please try again later." msgstr "La traducción no está disponible ahora mismo. Inténtalo de nuevo más tarde." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Los artistas descartados solo desaparecen de tus Recomendaciones — siguen apareciendo en la búsqueda y en el resto de la app." + +msgid "No dismissed artists" +msgstr "No hay artistas descartados" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Para dejar de ver un artista en Recomendaciones, abre el menú de su tarjeta y elige No me interesa." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} ya no aparecerá en Recomendaciones" + +msgid "Artist dismissed from Recommendations" +msgstr "Artista descartado de Recomendaciones" + +msgid "{} restored to Recommendations" +msgstr "{} restaurado en Recomendaciones" diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index b68b01e3f..2735d39c8 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4794,3 +4794,22 @@ msgstr "Automatique (langue du compte)" msgid "Translation is not available right now. Please try again later." msgstr "La traduction n'est pas disponible pour le moment. Veuillez réessayer plus tard." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Les artistes écartés disparaissent uniquement de vos Recommandations — ils restent visibles dans la recherche et partout ailleurs." + +msgid "No dismissed artists" +msgstr "Aucun artiste écarté" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Pour ne plus voir un artiste dans Recommandations, ouvrez le menu de sa carte et choisissez Pas intéressé." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} n'apparaîtra plus dans Recommandations" + +msgid "Artist dismissed from Recommendations" +msgstr "Artiste écarté des Recommandations" + +msgid "{} restored to Recommendations" +msgstr "{} rétabli dans Recommandations" diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index 311c64527..e978c7ea2 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4782,3 +4782,22 @@ msgstr "自動(アカウントの言語)" msgid "Translation is not available right now. Please try again later." msgstr "翻訳は現在利用できません。後でもう一度お試しください。" +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "除外したアーティストは「おすすめ」からのみ消えます — 検索やその他の場所には引き続き表示されます。" + +msgid "No dismissed artists" +msgstr "除外したアーティストはいません" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "おすすめにアーティストを表示しないようにするには、そのカードのメニューを開いて「興味なし」を選んでください。" + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} はおすすめに表示されなくなります" + +msgid "Artist dismissed from Recommendations" +msgstr "アーティストをおすすめから除外しました" + +msgid "{} restored to Recommendations" +msgstr "{} をおすすめに復元しました" diff --git a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po index ed6dca658..0660b179a 100644 --- a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po @@ -4806,3 +4806,22 @@ msgstr "Automatisch (accounttaal)" msgid "Translation is not available right now. Please try again later." msgstr "Vertaling is nu niet beschikbaar. Probeer het later opnieuw." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Afgewezen artiesten verdwijnen alleen uit je Aanbevelingen — ze blijven zichtbaar in zoekresultaten en overal elders." + +msgid "No dismissed artists" +msgstr "Geen afgewezen artiesten" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Om een artiest niet meer in Aanbevelingen te zien, open je het kaartmenu en kies je Niet geïnteresseerd." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} verschijnt niet meer in Aanbevelingen" + +msgid "Artist dismissed from Recommendations" +msgstr "Artiest afgewezen voor Aanbevelingen" + +msgid "{} restored to Recommendations" +msgstr "{} hersteld in Aanbevelingen" diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index e048615e4..a19c9de5c 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4794,3 +4794,22 @@ msgstr "Automático (idioma da conta)" msgid "Translation is not available right now. Please try again later." msgstr "A tradução não está disponível de momento. Tente novamente mais tarde." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Artistas dispensados só saem das suas Recomendações — continuam aparecendo na busca e em todo o resto." + +msgid "No dismissed artists" +msgstr "Nenhum artista dispensado" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Para deixar de ver um artista em Recomendações, abra o menu do seu cartão e escolha Não tenho interesse." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} não aparecerá mais em Recomendações" + +msgid "Artist dismissed from Recommendations" +msgstr "Artista dispensado das Recomendações" + +msgid "{} restored to Recommendations" +msgstr "{} restaurado em Recomendações" diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index a980f3d39..e21806d2d 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4808,3 +4808,22 @@ msgstr "Автоматически (язык аккаунта)" msgid "Translation is not available right now. Please try again later." msgstr "Перевод сейчас недоступен. Попробуйте ещё раз позже." +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Отклонённые исполнители исчезают только из Рекомендаций — они по-прежнему видны в поиске и повсюду остальном." + +msgid "No dismissed artists" +msgstr "Нет отклонённых исполнителей" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Чтобы больше не видеть исполнителя в Рекомендациях, откройте меню его карточки и выберите Не интересно." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} больше не появится в Рекомендациях" + +msgid "Artist dismissed from Recommendations" +msgstr "Исполнитель отклонён из Рекомендаций" + +msgid "{} restored to Recommendations" +msgstr "{} возвращён в Рекомендации" diff --git a/crates/qbz-ui/ui/discover/ArtistGridCard.slint b/crates/qbz-ui/ui/discover/ArtistGridCard.slint index 130135152..02831f30c 100644 --- a/crates/qbz-ui/ui/discover/ArtistGridCard.slint +++ b/crates/qbz-ui/ui/discover/ArtistGridCard.slint @@ -203,7 +203,7 @@ export component ArtistGridCard inherits Rectangle { x: 21px; y: 143px; width: 196px; - height: 3 * 33px + 10px; + height: 4 * 33px + 10px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/user.svg"); @@ -228,6 +228,15 @@ export component ArtistGridCard inherits Rectangle { root.media-action(root.follow-kind, root.artist.id, "follow"); } } + // Reco-scoped dismissal (NOT the blacklist): the artist + // leaves the Recommendations rails only. + ContextMenuItem { + icon: @image-url("../assets/icons/thumbs-down.svg"); + label: @tr("Not interested"); + clicked => { + root.media-action("artist", root.artist.id, "not-interested"); + } + } } } } diff --git a/crates/qbz-ui/ui/settings/BlacklistManagerView.slint b/crates/qbz-ui/ui/settings/BlacklistManagerView.slint index fd27d5c9a..dc4b53237 100644 --- a/crates/qbz-ui/ui/settings/BlacklistManagerView.slint +++ b/crates/qbz-ui/ui/settings/BlacklistManagerView.slint @@ -245,6 +245,96 @@ component AlbumRow inherits Rectangle { } } +// One reco-dismissed artist row (the Recommendations tab). Lighter than +// ArtistRow: the dismissal store carries no timestamp/notes, and the avatar +// is the generic user glyph (NOT blind-eye — this is not the blacklist). +component DismissedRow inherits Rectangle { + in property artist-name; + property hovered: ta.has-hover || rm-ta.has-hover; + callback select(); + callback remove(); + callback hover-changed(bool); + height: 64px; + border-radius: Radius.md; + background: root.hovered ? Theme.surface-elevated : Theme.surface-card; + + changed hovered => { + root.hover-changed(self.hovered); + } + + HorizontalLayout { + padding-left: 16px; + padding-right: 8px; + spacing: 12px; + + // Clickable artist-info region — opens the artist page. + Rectangle { + horizontal-stretch: 1; + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.select(); + } + } + HorizontalLayout { + spacing: 12px; + VerticalLayout { + alignment: center; + Rectangle { + width: 44px; + height: 44px; + border-radius: 22px; + background: Theme.surface-elevated; + QbzIcon { + source: @image-url("../assets/icons/user.svg"); + width: 20px; + height: 20px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: Theme.text-muted; + } + } + } + VerticalLayout { + alignment: center; + Text { + text: root.artist-name; + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + overflow: elide; + } + } + } + } + + // Per-row undo (optimistic, no confirm — mirrors the blacklist rows). + VerticalLayout { + alignment: center; + Rectangle { + width: 36px; + height: 36px; + border-radius: Radius.sm; + background: rm-ta.has-hover ? Theme.danger-bg : transparent; + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 18px; + height: 18px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: rm-ta.has-hover ? Theme.danger : Theme.text-muted; + } + rm-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.remove(); + } + } + } + } + } +} + export component BlacklistManagerView inherits Rectangle { background: ShellState.app-background-active ? transparent : Theme.surface-main; @@ -347,6 +437,28 @@ export component BlacklistManagerView inherits Rectangle { } } } + // Recommendations tab (reco-scoped "Not interested" dismissals). + Rectangle { + width: reco-lbl.preferred-width + 28px; + border-radius: Radius.sm; + background: BlacklistState.active-tab == 2 + ? Theme.surface-card + : (reco-ta.has-hover ? Theme.surface-hover : transparent); + reco-lbl := Text { + text: @tr("Recommendations"); + color: BlacklistState.active-tab == 2 ? Theme.text-primary : Theme.text-muted; + font-size: Typography.legal; + font-weight: Typography.medium; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + reco-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + BlacklistActions.set-tab(2); + } + } + } } } } @@ -356,7 +468,9 @@ export component BlacklistManagerView inherits Rectangle { vertical-stretch: 0; text: BlacklistState.active-tab == 0 ? @tr("Blacklisted artists are hidden from search results, radio, suggestions, and similar artists.") - : @tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed."); + : BlacklistState.active-tab == 1 + ? @tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed.") + : @tr("Dismissed artists only leave your Recommendations — they still appear in search and everywhere else."); color: Theme.text-muted; font-size: Typography.body; wrap: word-wrap; @@ -473,7 +587,9 @@ export component BlacklistManagerView inherits Rectangle { } // Clear-All — only when the active tab has something to clear. - if (BlacklistState.active-tab == 0 ? BlacklistState.count : BlacklistState.album-count) > 0: Rectangle { + // The Recommendations tab has no bulk clear (undo is per-row). + if (BlacklistState.active-tab == 0 ? BlacklistState.count + : BlacklistState.active-tab == 1 ? BlacklistState.album-count : 0) > 0: Rectangle { width: clear-all-row.preferred-width + 28px; height: 34px; border-radius: Radius.sm; @@ -519,7 +635,9 @@ export component BlacklistManagerView inherits Rectangle { Text { text: BlacklistState.active-tab == 0 ? @tr("{} artists", BlacklistState.count) - : @tr("{} albums", BlacklistState.album-count); + : BlacklistState.active-tab == 1 + ? @tr("{} albums", BlacklistState.album-count) + : @tr("{} artists", BlacklistState.dismissed-count); color: Theme.text-muted; font-size: Typography.legal; horizontal-alignment: right; @@ -774,6 +892,100 @@ export component BlacklistManagerView inherits Rectangle { hover-active: root.list-hover-rows > 0; } } + + // --- Recommendations tab branches (reco-scoped dismissals) ----- + // 2c) Empty (nothing dismissed). + if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-count == 0: VerticalLayout { + alignment: center; + spacing: 12px; + HorizontalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/thumbs-down.svg"); + width: 48px; + height: 48px; + tint: Theme.text-muted; + opacity: 0.3; + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("No dismissed artists"); + color: Theme.text-muted; + font-size: Typography.heading; + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("To stop seeing an artist in Recommendations, open its card menu and choose Not interested."); + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + } + } + } + + // 3c) No search results (dismissed). + if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-count > 0 && BlacklistState.dismissed-items.length == 0: VerticalLayout { + alignment: center; + spacing: 12px; + HorizontalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 48px; + height: 48px; + tint: Theme.text-muted; + opacity: 0.3; + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("No results for \"{}\"", BlacklistState.search-query); + color: Theme.text-muted; + font-size: Typography.heading; + } + } + } + + // 4c) The dismissed-artists list (per-row undo). + if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-items.length > 0: Rectangle { + dismissed-flick := Flickable { + width: 100%; + height: 100%; + viewport-width: self.width; + viewport-height: dismissed-content.preferred-height; + dismissed-content := VerticalLayout { + width: parent.width - 18px; + spacing: 4px; + for item in BlacklistState.dismissed-items: DismissedRow { + artist-name: item.artist-name; + select => { + BlacklistActions.artist-select(item.artist-id); + } + remove => { + BlacklistActions.remove-dismissed(item.artist-id); + } + hover-changed(h) => { + root.list-hover-rows = h ? root.list-hover-rows + 1 : Math.max(0, root.list-hover-rows - 1); + } + } + } + } + ListScrollbar { + x: parent.width - self.width - 4px; + width: 14px; + height: 100%; + viewport-height: dismissed-flick.viewport-height; + visible-height: dismissed-flick.height; + viewport-y <=> dismissed-flick.viewport-y; + auto-hide: true; + hover-active: root.list-hover-rows > 0; + } + } } } diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 6f68e3328..090df5b5b 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -2462,6 +2462,16 @@ export struct BlacklistedAlbumItem { has-notes: bool, } +// One reco-dismissed artist ("Not interested"). Reco-SCOPED — NOT the +// blacklist: the artist only leaves the Recommendations rails. The store +// carries no timestamp/notes, so the row is just the name (+ a generic +// avatar); removal is the undo. +export struct DismissedArtistItem { + // Numeric Qobuz artist id (same int pass-through as BlacklistedArtistItem). + artist-id: int, + artist-name: string, +} + export global BlacklistState { // The CURRENTLY-VISIBLE rows (already search-filtered by the controller). in property <[BlacklistedArtistItem]> items: []; @@ -2481,7 +2491,11 @@ export global BlacklistState { in property <[BlacklistedAlbumItem]> album-items: []; // FULL blocked-album count (drives the Albums tab badge + empty state). in property album-count: 0; - // Active manager tab: 0 = Artists, 1 = Albums. + // --- Reco-dismissal axis ("Not interested" — the Recommendations tab) --- + in property <[DismissedArtistItem]> dismissed-items: []; + // FULL dismissed count (drives the tab badge + empty state). + in property dismissed-count: 0; + // Active manager tab: 0 = Artists, 1 = Albums, 2 = Recommendations. in property active-tab: 0; } @@ -2498,12 +2512,14 @@ export global BlacklistActions { callback search-changed(string /* query */); // --- Album axis --- - callback set-tab(int /* 0=artists, 1=albums */); + callback set-tab(int /* 0=artists, 1=albums, 2=recommendations */); callback album-select(string /* album id */); callback remove-album(string /* album id */); callback clear-all-albums(); // Block an album from a context menu (id + display fields for the manager). callback block-album(string /* id */, string /* title */, string /* artist */, string /* cover-url */); + // --- Reco-dismissal axis --- + callback remove-dismissed(int /* artist id */); } // === My QBZ — Mixtapes & Collections (read-only index grids) ============ diff --git a/crates/qbz/packaging/qbz.desktop b/crates/qbz/packaging/qbz.desktop index 7e2dfbaeb..ee8b81049 100644 --- a/crates/qbz/packaging/qbz.desktop +++ b/crates/qbz/packaging/qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/crates/qbz/src/auth.rs b/crates/qbz/src/auth.rs index 241229e3c..4a46e3c5a 100644 --- a/crates/qbz/src/auth.rs +++ b/crates/qbz/src/auth.rs @@ -132,6 +132,8 @@ where if let Some(dir) = crate::offline_mode::user_data_dir(user_id) { crate::offline_mode::init_for_user(&dir); crate::fav_cache::init_for_user(&dir); + // Reco-scoped "Not interested" dismissal store (reco rails only). + crate::reco_dismiss::init_for_user(&dir); // Recommendation event store (shared events.db with Tauri). Train // after init (off-thread) so the seeds reflect this session's events. crate::reco::init_for_user(&dir); @@ -268,6 +270,9 @@ where if let Some(dir) = crate::offline_mode::user_data_dir(user_id) { crate::offline_mode::init_for_user(&dir); crate::fav_cache::init_for_user(&dir); + // Reco-scoped "Not interested" dismissal store (reco rails + // only). + crate::reco_dismiss::init_for_user(&dir); // Recommendation event store (shared events.db with Tauri). // Train after init (off-thread) so the seeds reflect this // session's events. @@ -337,6 +342,7 @@ where crate::offline::deactivate().await; crate::offline_mode::teardown(); crate::fav_cache::teardown(); + crate::reco_dismiss::teardown(); crate::reco::teardown(); runtime.core().clear_artist_vectors().await; crate::discover_prefs::teardown(); diff --git a/crates/qbz/src/blacklist_manager.rs b/crates/qbz/src/blacklist_manager.rs index 630e0ad1d..07a72fde7 100644 --- a/crates/qbz/src/blacklist_manager.rs +++ b/crates/qbz/src/blacklist_manager.rs @@ -3,7 +3,9 @@ //! `BlacklistState`, applies search-as-you-type filtering controller-side, and //! runs the toggle / remove / clear mutations against the //! `crate::artist_blacklist` wrapper (the same fail-open singleton the artist -//! toggle in T9 mutates). +//! toggle in T9 mutates). A third "Recommendations" tab (active-tab 2) lists +//! the reco-SCOPED "Not interested" dismissals from `crate::reco_dismiss` — +//! NOT the blacklist — with a per-row undo. //! //! Mirrors `crate::offline_manager`'s shape: a `load` entry point invoked on //! navigation + an action set wired in `main.rs`. There is no change-notify on @@ -29,7 +31,7 @@ use chrono::{DateTime, Utc}; use slint::{ComponentHandle, ModelRc, SharedString, VecModel}; use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache}; -use crate::{AppWindow, BlacklistState, BlacklistedAlbumItem, BlacklistedArtistItem}; +use crate::{AppWindow, BlacklistState, BlacklistedAlbumItem, BlacklistedArtistItem, DismissedArtistItem}; /// Shared image cache for resolving blocked-album cover thumbnails (the artist /// tab has no covers; the album tab does). Set once during startup wiring. @@ -137,16 +139,41 @@ fn build_album_items() -> (Vec, i32, Vec) { (items, count, jobs) } -/// Push the filtered items + full count + enabled flag + query into Slint (both -/// axes). Album covers resolve asynchronously via the shared image cache. +/// Build the visible (filtered) dismissed-artist items — the "Not interested" +/// reco-scoped list — from the store snapshot, applying the current query +/// (name match, same rule as the artist axis). `count` carries the FULL list +/// length for the tab badge + empty/no-results split. +fn build_dismissed_items() -> (Vec, i32) { + let all = crate::reco_dismiss::list(); + let count = all.len() as i32; + let needle = current_query().trim().to_lowercase(); + + let items: Vec = all + .into_iter() + .filter(|a| needle.is_empty() || a.name.to_lowercase().contains(&needle)) + .map(|a| DismissedArtistItem { + // Same int pass-through as the blacklist artist id. + artist_id: a.artist_id as i32, + artist_name: a.name.into(), + }) + .collect(); + + (items, count) +} + +/// Push the filtered items + full count + enabled flag + query into Slint (all +/// three axes). Album covers resolve asynchronously via the shared image cache. fn push(w: &AppWindow) { let (items, count) = build_items(); let (album_items, album_count, jobs) = build_album_items(); + let (dismissed_items, dismissed_count) = build_dismissed_items(); let st = w.global::(); st.set_items(ModelRc::new(VecModel::from(items))); st.set_count(count); st.set_album_items(ModelRc::new(VecModel::from(album_items))); st.set_album_count(album_count); + st.set_dismissed_items(ModelRc::new(VecModel::from(dismissed_items))); + st.set_dismissed_count(dismissed_count); st.set_enabled(crate::artist_blacklist::is_enabled()); st.set_search_query(SharedString::from(current_query())); // Kick off cover loads (best-effort; needs the cache + a weak handle). @@ -236,7 +263,7 @@ pub fn clear_all(w: &AppWindow) { // --- Album axis actions ------------------------------------------------- -/// Switch the manager's active tab (0 = Artists, 1 = Albums). +/// Switch the manager's active tab (0 = Artists, 1 = Albums, 2 = Recommendations). pub fn set_tab(w: &AppWindow, tab: i32) { w.global::().set_active_tab(tab); } @@ -303,3 +330,23 @@ pub fn clear_all_albums(w: &AppWindow) { } } } + + +// --- Reco-dismissal axis actions ----------------------------------------- + +/// Undo one "Not interested" dismissal (optimistic — the re-push drops the row +/// immediately) + toast. The artist becomes eligible for the Recommendations +/// rails again on their next paint (the §B filter reads the store). +pub fn remove_dismissed(w: &AppWindow, artist_id: i32) { + // Capture the name before removing, for the toast (falls back to the + // generic "Artist" for rows persisted without a resolved name). + let name = crate::reco_dismiss::list() + .into_iter() + .find(|a| a.artist_id == artist_id as u64) + .map(|a| a.name) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| qbz_i18n::t("Artist")); + crate::reco_dismiss::remove(artist_id as u64); + push(w); + crate::toast::success(w, qbz_i18n::t_args("{} restored to Recommendations", &[&name])); +} diff --git a/crates/qbz/src/deep_link.rs b/crates/qbz/src/deep_link.rs new file mode 100644 index 000000000..468775b29 --- /dev/null +++ b/crates/qbz/src/deep_link.rs @@ -0,0 +1,235 @@ +//! XDG / launcher deep links — `xdg-open https://open.qobuz.com/album/` +//! with the desktop files' `Exec=qbz %u` (Tauri parity: the old app scanned +//! argv in the single-instance plugin callback and on cold start; the Slint +//! rebuild kept the raise-half but dropped the URL-half, so a deep link +//! raised/focused the window without ever navigating). +//! +//! Two entry paths, one pending slot: +//! +//! - **Cold start:** `capture_argv()` at the top of `main()` stashes the +//! first Qobuz-link argv entry BEFORE the single-instance guard runs (a +//! second launch must read its own argv before deciding to raise+exit). +//! Drained at the END of `enter_shell` — after the startup-page/view +//! restore, so the restore can't re-root over the deep link. At that point +//! the session is active and the AppWindow exists, so NO sleep hack (the +//! Tauri 1500 ms delay is not ported). Sitting at the login screen (no +//! session) the URL simply stays pending until the next successful +//! `enter_shell`. Offline entry (`enter_shell_offline`) never binds the +//! shell context, so the URL rides until an online shell — navigation +//! needs the API (same limitation as the Tauri era). +//! - **Warm start:** the second launch forwards the URL over the +//! single-instance D-Bus interface (`OpenUrl`, see `single_instance.rs`), +//! which presents the running instance and drains through the same path. +//! +//! The dispatch itself is the EXISTING Ctrl+L machinery: +//! `link_resolver::resolve` → `apply_resolved_link` → `navigate_*`. + +use std::sync::{Arc, Mutex}; + +use qbz_app::shell::AppRuntime; + +use crate::adapter::SlintAdapter; +use crate::artwork; +use crate::AppWindow; + +/// The first Qobuz link seen, waiting for a shell to navigate it. A warm +/// `OpenUrl` overwrites: the newest user intent wins. +static PENDING: Mutex> = Mutex::new(None); + +/// Everything `dispatch` needs, bound once per shell entry (`enter_shell`) +/// and cleared on logout so "context set" means "a session is active and +/// navigation can succeed". The warm D-Bus path has no other way to reach +/// these — they only exist as locals in `main()`. +#[derive(Clone)] +struct ShellCtx { + runtime: Arc>, + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: artwork::ImageCache, +} + +static SHELL_CTX: Mutex> = Mutex::new(None); + +/// Whether a string looks like a Qobuz link (custom scheme or web URL) — +/// 1:1 the legacy Tauri `is_qobuz_link` prefixes +/// (`qbz-worktrees/legacy-tauri` `src-tauri/src/lib.rs:594`). +pub fn is_qobuz_link(arg: &str) -> bool { + arg.starts_with("qobuzapp://") + || arg.starts_with("https://play.qobuz.com/") + || arg.starts_with("http://play.qobuz.com/") + || arg.starts_with("https://open.qobuz.com/") + || arg.starts_with("http://open.qobuz.com/") +} + +/// The first Qobuz link in an argument list, if any (pure — unit-tested). +fn select_link(args: &[String]) -> Option { + args.iter().find(|a| is_qobuz_link(a)).cloned() +} + +/// Scan the process argv for a Qobuz link and stash it pending. Call at the +/// top of `main()`, BEFORE `single_instance::acquire_or_raise`: when another +/// instance owns the bus name the guard forwards the stashed URL to it, and +/// when we are the primary it rides until the `enter_shell` drain. +pub fn capture_argv() { + let args: Vec = std::env::args().skip(1).collect(); + if let Some(url) = select_link(&args) { + log::info!( + "[qbz-slint] deep link: captured from argv: {}", + url.split('?').next().unwrap_or(&url) + ); + stash(url); + } +} + +/// Stash a URL pending (cold argv capture and the warm D-Bus `OpenUrl`). +pub fn stash(url: String) { + if let Ok(mut guard) = PENDING.lock() { + *guard = Some(url); + } +} + +/// Take the pending URL, leaving the slot empty. +pub fn take_pending() -> Option { + PENDING.lock().ok().and_then(|mut guard| guard.take()) +} + +/// Bind the shell context at `enter_shell` — the gate that lets +/// `drain_pending` dispatch (session active, AppWindow alive). +pub fn bind_shell_ctx( + runtime: Arc>, + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: artwork::ImageCache, +) { + if let Ok(mut guard) = SHELL_CTX.lock() { + *guard = Some(ShellCtx { + runtime, + weak, + handle, + image_cache, + }); + } +} + +/// Clear the shell context on logout: back at the login screen a pending +/// URL must wait for the next `enter_shell`, not fire into a dead session. +pub fn clear_shell_ctx() { + if let Ok(mut guard) = SHELL_CTX.lock() { + *guard = None; + } +} + +/// Dispatch the pending URL through the existing Ctrl+L resolve flow, but +/// only when a shell is up. No-op otherwise — the URL stays pending for the +/// next successful `enter_shell`. Safe to call from any thread (the zbus +/// executor included): the resolve spawns on the stored tokio handle and +/// the navigation hops to the Slint event loop. +pub fn drain_pending() { + let ctx = SHELL_CTX.lock().ok().and_then(|guard| guard.clone()); + let Some(ctx) = ctx else { return }; + let Some(url) = take_pending() else { return }; + dispatch(url, ctx); +} + +/// Mirror of the Ctrl+L `LinkResolverActions::on_submit` flow in `main.rs`, +/// minus the modal state: resolve off the UI thread, then apply the +/// navigation on the event loop. Failures surface as a toast (there is no +/// modal to hold an error here). +fn dispatch(url: String, ctx: ShellCtx) { + let ShellCtx { + runtime, + weak, + handle, + image_cache, + } = ctx; + log::info!( + "[qbz-slint] deep link: resolving {}", + url.split('?').next().unwrap_or(&url) + ); + handle.clone().spawn(async move { + let result = crate::link_resolver::resolve(runtime.clone(), url).await; + let _ = weak.upgrade_in_event_loop(move |w| match result { + Ok(qbz_music_link::MusicLinkResult::Resolved { link, .. }) => { + crate::apply_resolved_link(link, &runtime, &w.as_weak(), &handle, &image_cache); + } + Ok(qbz_music_link::MusicLinkResult::PlaylistDetected { provider }) => { + // Unreachable for the native Qobuz shapes the argv/D-Bus + // matcher accepts (cross-platform provider playlists only). + log::info!("[qbz-slint] deep link: {provider} playlist — nothing to navigate to"); + } + Ok(qbz_music_link::MusicLinkResult::NotOnQobuz { .. }) => { + crate::toast::error( + &w, + qbz_i18n::t("This content is not available on Qobuz"), + ); + } + Err(e) => { + log::warn!("[qbz-slint] deep link: resolve failed: {e}"); + crate::toast::error(&w, qbz_i18n::t("Could not resolve that link")); + } + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_open_qobuz_album_https_and_http() { + assert!(is_qobuz_link("https://open.qobuz.com/album/kq3s910v1qufc")); + assert!(is_qobuz_link("http://open.qobuz.com/album/kq3s910v1qufc")); + } + + #[test] + fn matches_play_qobuz_https_and_http() { + assert!(is_qobuz_link("https://play.qobuz.com/album/kq3s910v1qufc")); + assert!(is_qobuz_link("http://play.qobuz.com/track/123456")); + } + + #[test] + fn matches_qobuzapp_scheme() { + assert!(is_qobuz_link("qobuzapp://album/kq3s910v1qufc")); + assert!(is_qobuz_link("qobuzapp://artist/123")); + } + + #[test] + fn ignores_non_link_args() { + assert!(!is_qobuz_link("--verbose")); + assert!(!is_qobuz_link("/home/user/music.flac")); + assert!(!is_qobuz_link("https://example.com/album/kq3s910v1qufc")); + assert!(!is_qobuz_link("https://open.qobuz.com.evil.test/album/x")); + // The dead `qbz://` scheme resolves nowhere and stays unmatched. + assert!(!is_qobuz_link("qbz://album/kq3s910v1qufc")); + assert!(!is_qobuz_link("")); + } + + #[test] + fn select_link_takes_first_match() { + let args = vec![ + "--flag".to_string(), + "https://open.qobuz.com/album/first".to_string(), + "qobuzapp://album/second".to_string(), + ]; + assert_eq!( + select_link(&args), + Some("https://open.qobuz.com/album/first".to_string()) + ); + } + + #[test] + fn select_link_returns_none_without_match() { + let args = vec!["--flag".to_string(), "cover.jpg".to_string()]; + assert_eq!(select_link(&args), None); + } + + /// Single stateful test: PENDING is process-global and cargo runs tests + /// on threads, so the round-trip + overwrite checks stay sequential here. + #[test] + fn pending_drains_once_and_newest_wins() { + stash("qobuzapp://album/old".to_string()); + stash("qobuzapp://album/new".to_string()); + assert_eq!(take_pending(), Some("qobuzapp://album/new".to_string())); + assert_eq!(take_pending(), None); + } +} diff --git a/crates/qbz/src/external_reco.rs b/crates/qbz/src/external_reco.rs index 79117f442..4072cd09d 100644 --- a/crates/qbz/src/external_reco.rs +++ b/crates/qbz/src/external_reco.rs @@ -9,6 +9,7 @@ //! Weekly Exploration/Jams (ListenBrainz), Deep-cut albums, and a Qobuz editorial //! cold-start fallback. +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -18,9 +19,9 @@ use qbz_app::shell::AppRuntime; use qbz_external_reco::{ build_deep_cut_albums, build_editorial, build_fresh_releases, build_rec_albums, build_rec_artists_common, build_rec_artists_recent, build_similar_albums_seeded, - build_weekly_exploration, build_weekly_jams, gather_history, is_cold_start, AlbumReco, - ArtistReco, ExternalCarousels, LastFmHandle, ListenBrainzHandle, LocalHistory, RecoCache, - RecoCatalog, RecoInputs, TrackReco, + build_weekly_exploration, build_weekly_jams, compose_artist_rails, gather_history, + is_cold_start, AlbumReco, ArtistReco, ExternalCarousels, LastFmHandle, ListenBrainzHandle, + LocalHistory, RecoCache, RecoCatalog, RecoInputs, TrackReco, ARTIST_DISPLAY_CAP, }; use qbz_integrations::{LastFmClient, ListenBrainzClient, MusicBrainzClient}; use qbz_models::{Album, Artist, Track}; @@ -32,6 +33,15 @@ use crate::{AlbumCardItem, AppWindow, DiscoverSection, ExternalRecoState, SlimIt static CACHE_DIR: Mutex> = Mutex::new(None); +/// Retained per-rail overflow (validated candidates past the visible cap) for +/// the two Recommended-Artist rails — (common, recent). Kept for live +/// backfill after a "not interested" dismissal, so a replacement card needs +/// no extra network. Rewritten on every rail paint (fresh build AND cached +/// blob paint; old blobs carry no overflow, so they simply don't backfill +/// until they expire). +static ARTIST_OVERFLOW: Mutex<(Vec, Vec)> = + Mutex::new((Vec::new(), Vec::new())); + pub fn init_for_user(base_dir: &Path) { if let Ok(mut g) = CACHE_DIR.lock() { *g = Some(base_dir.to_path_buf()); @@ -390,19 +400,21 @@ fn spawn( let history = gather_history(&inputs).await; let col = &collector; // Progressive: each branch paints its row AND collects it for the cache. - let b_common = async { - let r = build_rec_artists_common(&inputs, &history).await; + // The two artist rails build in parallel but paint TOGETHER through + // apply_artist_rails (the shared filter/dedup/backfill choke point — + // cross-rail dedup needs both pools). The collector stores the FULL + // validated pools (visible + overflow), so the results blob carries + // the backfill candidates too. + let b_artists = async { + let (common_pool, recent_pool) = tokio::join!( + build_rec_artists_common(&inputs, &history), + build_rec_artists_recent(&inputs, &history), + ); if let Ok(mut g) = col.lock() { - g.rec_artists_common = r.clone(); + g.rec_artists_common = common_pool.clone(); + g.rec_artists_recent = recent_pool.clone(); } - apply_artists(&weak, &image_cache, r, ArtistRow::RecArtistsCommon); - }; - let b_recent = async { - let r = build_rec_artists_recent(&inputs, &history).await; - if let Ok(mut g) = col.lock() { - g.rec_artists_recent = r.clone(); - } - apply_artists(&weak, &image_cache, r, ArtistRow::RecArtistsRecent); + apply_artist_rails(&weak, &image_cache, common_pool, recent_pool); }; let b_albums = async { let r = build_rec_albums(&inputs, &history).await; @@ -439,7 +451,7 @@ fn spawn( } apply_albums(&weak, &image_cache, r, AlbumRow::DeepCuts); }; - tokio::join!(b_common, b_recent, b_albums, b_fresh, b_explore, b_jams, b_deep); + tokio::join!(b_artists, b_albums, b_fresh, b_explore, b_jams, b_deep); } // 3. Store the built result for instant future opens (48h TTL). GUARD @@ -522,8 +534,7 @@ fn clear_all_pending(w: &AppWindow) { /// their own per-week cache by `build_and_apply_weeklies`, so the blob can never /// pin a stale/empty weekly for the 48h window. fn apply_all(weak: &slint::Weak, cache: &ImageCache, r: ExternalCarousels) { - apply_artists(weak, cache, r.rec_artists_common, ArtistRow::RecArtistsCommon); - apply_artists(weak, cache, r.rec_artists_recent, ArtistRow::RecArtistsRecent); + apply_artist_rails(weak, cache, r.rec_artists_common, r.rec_artists_recent); apply_albums(weak, cache, r.rec_albums, AlbumRow::RecAlbums); apply_albums(weak, cache, r.fresh_releases, AlbumRow::FreshReleases); apply_albums(weak, cache, r.deep_cut_albums, AlbumRow::DeepCuts); @@ -531,6 +542,150 @@ fn apply_all(weak: &slint::Weak, cache: &ImageCache, r: ExternalCarou apply_artists(weak, cache, r.top_artists, ArtistRow::TopArtists); } +/// Fold the three per-user exclusion sources — followed artists, the app-wide +/// blacklist, and the reco-scoped "not interested" dismissals — into one id +/// set for the rail composition. Every source is independently fail-open (an +/// unbound/unreadable store simply contributes nothing). +fn artist_exclusions() -> HashSet { + let mut ids = crate::fav_cache::all_artists(); + ids.extend(crate::artist_blacklist::ids_snapshot()); + ids.extend(crate::reco_dismiss::ids_snapshot()); + ids +} + +/// THE paint choke point for the two Recommended-Artist rails — the fresh +/// build AND the cached-blob paint both funnel through here. Composes the +/// visible rows from the validated pools AFTER exclusion filtering + +/// cross-rail dedup (common wins), takes the first ARTIST_DISPLAY_CAP +/// survivors per rail, and retains the rest as backfill overflow for the +/// "not interested" live replacement (compose_artist_rails does the split; +/// the full pools ride the results blob, so a cached paint backfills too). +fn apply_artist_rails( + weak: &slint::Weak, + cache: &ImageCache, + common_pool: Vec, + recent_pool: Vec, +) { + let excluded = artist_exclusions(); + let (common, recent) = + compose_artist_rails(common_pool, recent_pool, &excluded, ARTIST_DISPLAY_CAP); + if let Ok(mut g) = ARTIST_OVERFLOW.lock() { + g.0 = common.overflow; + g.1 = recent.overflow; + } + apply_artists(weak, cache, common.visible, ArtistRow::RecArtistsCommon); + apply_artists(weak, cache, recent.visible, ArtistRow::RecArtistsRecent); +} + +/// Pop the first retained-overflow candidate for `which` rail that still +/// passes the LIVE exclusions (a follow/blacklist may have landed since the +/// paint) and is not already visible in either rail. Non-passing entries stay +/// pooled (they may become eligible again, e.g. after an un-follow). +fn pop_backfill( + which: ArtistRow, + visible: &HashSet, + excluded: &HashSet, +) -> Option { + let mut g = ARTIST_OVERFLOW.lock().ok()?; + let pool = match which { + ArtistRow::RecArtistsCommon => &mut g.0, + ArtistRow::RecArtistsRecent => &mut g.1, + ArtistRow::TopArtists => return None, + }; + let idx = pool.iter().position(|r| { + !excluded.contains(&r.qobuz_artist_id) + && !visible.contains(&r.qobuz_artist_id.to_string()) + })?; + Some(pool.remove(idx)) +} + +/// Live "Not interested": drop the artist card from the Recommended-Artist +/// rail that carries it and backfill the freed slot from that rail's retained +/// overflow — already Qobuz-validated, so no network. Runs on the Slint event +/// loop. Returns the (name, image_url) snapshot of the dismissed card (for the +/// dismissal store + toast), or None when the artist is not currently in a +/// reco rail (e.g. dismissed from a search/home card). +pub fn apply_artist_dismissal( + w: &AppWindow, + cache: &ImageCache, + artist_id: &str, +) -> Option<(String, String)> { + let s = w.global::(); + let common_model = s.get_rec_artists_common(); + let recent_model = s.get_rec_artists_recent(); + let common_rows: Vec = (0..common_model.row_count()) + .filter_map(|i| common_model.row_data(i)) + .collect(); + let recent_rows: Vec = (0..recent_model.row_count()) + .filter_map(|i| recent_model.row_data(i)) + .collect(); + let in_common = common_rows.iter().any(|it| it.id.as_str() == artist_id); + let in_recent = recent_rows.iter().any(|it| it.id.as_str() == artist_id); + if !in_common && !in_recent { + return None; + } + let snapshot = common_rows + .iter() + .chain(recent_rows.iter()) + .find(|it| it.id.as_str() == artist_id) + .map(|it| (it.title.to_string(), it.artwork_url.to_string())); + + let excluded = artist_exclusions(); + // Rebuild the affected models from the kept rows — their already-decoded + // `artwork` images ride along, so only the backfilled row needs a job. + let mut common_new: Vec = common_rows + .into_iter() + .filter(|it| it.id.as_str() != artist_id) + .collect(); + let mut recent_new: Vec = recent_rows + .into_iter() + .filter(|it| it.id.as_str() != artist_id) + .collect(); + // Visible ids across BOTH rails (post-removal): the backfill candidate + // must not duplicate the other rail. + let mut visible: HashSet = common_new + .iter() + .chain(recent_new.iter()) + .map(|it| it.id.to_string()) + .collect(); + + let mut jobs: Vec = Vec::new(); + if in_common { + if let Some(reco) = pop_backfill(ArtistRow::RecArtistsCommon, &visible, &excluded) { + visible.insert(reco.qobuz_artist_id.to_string()); + if !reco.image_url.is_empty() { + jobs.push(ArtworkJob { + url: reco.image_url.clone(), + target: ArtworkTarget::ExtRecoRecArtistCommon { + index: common_new.len(), + }, + }); + } + common_new.push(slim_from_artist(&reco)); + } + s.set_rec_artists_common(ModelRc::new(VecModel::from(common_new))); + } + if in_recent { + if let Some(reco) = pop_backfill(ArtistRow::RecArtistsRecent, &visible, &excluded) { + visible.insert(reco.qobuz_artist_id.to_string()); + if !reco.image_url.is_empty() { + jobs.push(ArtworkJob { + url: reco.image_url.clone(), + target: ArtworkTarget::ExtRecoRecArtistRecent { + index: recent_new.len(), + }, + }); + } + recent_new.push(slim_from_artist(&reco)); + } + s.set_rec_artists_recent(ModelRc::new(VecModel::from(recent_new))); + } + if !jobs.is_empty() { + crate::artwork::spawn_loads(jobs, w.as_weak(), cache.clone()); + } + snapshot +} + /// Build + paint the two Weekly rows from their own per-week cache (cheap on a /// hit; one ListenBrainz `createdfor` call + a SQLite read). Used on the /// instant-paint path so the weeklies follow ListenBrainz's weekly cadence @@ -601,7 +756,11 @@ fn slim_from_artist(a: &ArtistReco) -> SlimItem { rank: "".into(), artwork_url: a.image_url.clone().into(), artwork: slint::Image::default(), - following: false, + // Live follow state from the login-seeded fav cache (the + // pinned_section precedent) — a hardcoded `false` mislabeled + // already-followed artists. Kept live afterwards by + // search::mark_artist_followed on every toggle. + following: crate::fav_cache::is_artist_favorite(a.qobuz_artist_id), } } fn slim_from_track(t: &TrackReco) -> SlimItem { diff --git a/crates/qbz/src/fav_cache.rs b/crates/qbz/src/fav_cache.rs index 51aef9f04..4dede5d36 100644 --- a/crates/qbz/src/fav_cache.rs +++ b/crates/qbz/src/fav_cache.rs @@ -272,6 +272,12 @@ pub fn is_artist_favorite(artist_id: u64) -> bool { .unwrap_or(false) } +/// Snapshot of the full followed-artist id set — the reco paint filter's +/// "already following" exclusion input (mirrors `all()` for tracks). +pub fn all_artists() -> HashSet { + FAV_ARTISTS.read().map(|g| g.clone()).unwrap_or_default() +} + /// Replace the followed-artist set with a freshly-fetched id list and mirror /// it to the per-user store (full replace — same lifecycle as the album set). /// Blocking disk write; call off the UI thread. diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 0a38ffa31..8feab7e6a 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -40,6 +40,7 @@ mod glibc_compat; mod log_viewer; mod sleep_timer; pub use qbz_text_utils::{dates, strip_html}; +mod deep_link; mod discover_browse; mod discord_rpc; mod discover_prefs; @@ -122,6 +123,7 @@ mod plex_settings; mod playlist_picker; mod quality; mod reco; +mod reco_dismiss; mod recently; mod scrobble; mod scrobbler_settings; @@ -351,6 +353,17 @@ async fn enter_shell( ) { let tray = init_shell_for_user(&runtime, &weak, session.user_id); + // Deep links (argv capture / warm D-Bus OpenUrl) may now drain: the + // session is active and the AppWindow exists. Bound here at the top; the + // pending URL itself is dispatched at the very END of this function so + // the startup-page/view restore below can't re-root over the deep link. + deep_link::bind_shell_ctx( + runtime.clone(), + weak.clone(), + tokio::runtime::Handle::current(), + image_cache.clone(), + ); + let _ = weak.upgrade_in_event_loop(move |w| { let state = w.global::(); state.set_user_name(session.display_name.into()); @@ -494,6 +507,30 @@ async fn enter_shell( } } } + // External-reco artist rows (Discover > Recommendations): + // same in-place re-seed — the rows may already be painted + // from the results blob before this warm lands (their + // build-time fav_cache seed was stale/empty then). + let reco = w.global::(); + for model in [ + reco.get_rec_artists_common(), + reco.get_rec_artists_recent(), + reco.get_top_artists(), + ] { + for i in 0..model.row_count() { + if let Some(mut it) = model.row_data(i) { + let following = it + .id + .parse::() + .map(|id| fav_cache::is_artist_favorite(id)) + .unwrap_or(false); + if it.following != following { + it.following = following; + model.set_row_data(i, it); + } + } + } + } }); } Err(e) => log::warn!("[qbz-slint] favorite artists warm failed: {e}"), @@ -635,6 +672,14 @@ async fn enter_shell( let _ = weak.upgrade_in_event_loop(move |w| { favorites::apply_counts(&w, counts); }); + + // XDG deep link: drain a pending Qobuz URL LAST — after the startup-page + // / view-restore block above, so the restore can't re-root over the deep + // link (the navigation lands on top of whatever was restored). Session + // active, AppWindow alive: no readiness sleep needed. Nothing pending => + // no-op. (Offline entries never reach here — enter_shell_offline keeps + // the URL pending; navigation needs the API.) + deep_link::drain_pending(); } /// Offline session entry — "Start offline" on the login screen (spec §4.1). @@ -684,6 +729,9 @@ async fn enter_shell_offline( if let Some(dir) = crate::offline_mode::user_data_dir(user_id) { crate::offline_mode::init_for_user(&dir); crate::fav_cache::init_for_user(&dir); + // Reco-scoped "Not interested" dismissal store (Discover > + // Recommendations rails only — NOT the blacklist). + crate::reco_dismiss::init_for_user(&dir); // Recommendation event store (shared events.db with Tauri). crate::reco::init_for_user(&dir); // External-recommendations resolution cache (4th Discover tab). @@ -8062,6 +8110,12 @@ fn main() -> Result<(), Box> { std::env::var("SLINT_SCALE_FACTOR").unwrap_or_default() ); } + + // XDG deep link (Exec=qbz %u): capture a Qobuz link from argv BEFORE the + // single-instance guard below — a second launch must read its own argv + // before deciding to raise the primary and exit (the guard forwards the + // stashed URL over D-Bus; as primary it drains at the end of enter_shell). + deep_link::capture_argv(); // Artwork decode targets grow with the preset so covers stay sharp at // Large/XL (and shrink RAM at Small). Set before any artwork job runs. crate::artwork::set_ui_scale_factor(ui_scale_factor); @@ -10555,6 +10609,9 @@ fn main() -> Result<(), Box> { window.on_logout(move || { // Drop any Discord "now listening" activity on logout. discord_rpc::clear(&handle); + // Back at the login screen a pending deep link must wait for the + // next enter_shell, not fire into the torn-down session. + deep_link::clear_shell_ctx(); // Clear the per-user purchases cache + download-status store so the // next account never sees the previous user's purchased items or // in-flight download statuses (cross-account data leak). @@ -12546,6 +12603,70 @@ fn main() -> Result<(), Box> { }); } } + // "Not interested" (reco-scoped dismissal — NOT the app-wide + // blacklist): persist the dismissal, drop the card from the + // Recommendations rails live, and backfill the freed slot from + // the retained overflow. The artist stays visible everywhere + // else (search/home/label pages); future paints exclude it via + // the §B filter. + ("artist", "not-interested") => { + if let Some(w) = weak.upgrade() { + let snapshot = + crate::external_reco::apply_artist_dismissal(&w, &image_cache, &id); + match snapshot { + Some((name, image)) => { + if let Ok(aid) = id.parse::() { + crate::reco_dismiss::dismiss(aid, &name, &image); + } + crate::toast::info_weak( + &weak, + qbz_i18n::t_args( + "{} won't appear in Recommendations anymore", + &[&name], + ), + ); + } + None => { + // Dismissed from a non-reco surface (search / + // home / pinned card): nothing to remove live + // — resolve the display name, then persist. + let runtime = runtime.clone(); + let weak = weak.clone(); + let artist_id = id.clone(); + handle.spawn(async move { + let Ok(aid) = artist_id.parse::() else { + return; + }; + let (name, image) = runtime + .core() + .get_artist(aid) + .await + .map(|a| { + ( + a.name, + a.image + .and_then(|i| i.best().cloned()) + .unwrap_or_default(), + ) + }) + .unwrap_or_default(); + crate::reco_dismiss::dismiss(aid, &name, &image); + let msg = if name.is_empty() { + qbz_i18n::t("Artist dismissed from Recommendations") + } else { + qbz_i18n::t_args( + "{} won't appear in Recommendations anymore", + &[&name], + ) + }; + let _ = weak.upgrade_in_event_loop(move |w| { + crate::toast::info(&w, msg); + }); + }); + } + } + } + } // === Label landing actions =============================== ("label", "follow") => { // Toggle the label favorite, optimistically flipping the @@ -16525,6 +16646,17 @@ fn main() -> Result<(), Box> { } }); } + // --- Reco-dismissal callbacks (the Recommendations tab) --- + { + let weak = window.as_weak(); + window + .global::() + .on_remove_dismissed(move |id| { + if let Some(w) = weak.upgrade() { + blacklist_manager::remove_dismissed(&w, id); + } + }); + } { let weak = window.as_weak(); let bl_runtime_b = app_runtime.clone(); diff --git a/crates/qbz/src/reco_dismiss.rs b/crates/qbz/src/reco_dismiss.rs new file mode 100644 index 000000000..0c5b0e5e3 --- /dev/null +++ b/crates/qbz/src/reco_dismiss.rs @@ -0,0 +1,234 @@ +//! Per-user "Not interested" dismissal store for Discover > Recommendations. +//! +//! Reco-SCOPED dismissal — deliberately NOT the app-wide blacklist: a +//! dismissed artist only leaves the two Recommended-Artist rails ("More like +//! the artists you love" / "Based on what you've been into lately"); it stays +//! visible in search, home, and label pages. The paint choke point in +//! `crate::external_reco` folds [`ids_snapshot`] into its exclusion set, and +//! the Blacklist Manager's "Recommendations" tab lists / undoes entries. +//! +//! Shape follows the light `discovery_dismiss` precedent (one small JSON file, +//! a full read on each op — no SQLite, no change-notify) but bound PER-USER +//! like `fav_cache` / `artist_blacklist`: a process-global path set via +//! [`init_for_user`] / dropped by [`teardown`]. Fail-open everywhere: with no +//! session bound (or a corrupt file) reads are empty and mutations no-op. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +/// JSON file name inside the per-user data dir. +const FILE_NAME: &str = "reco_dismiss.json"; + +/// One dismissed artist. `image_url` is optional (used only if a future +/// surface wants a thumbnail; the manager tab renders a generic avatar). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DismissedArtist { + #[serde(default)] + pub artist_id: u64, + #[serde(default)] + pub name: String, + #[serde(default)] + pub image_url: String, +} + +#[derive(Default, Serialize, Deserialize)] +struct DismissStore { + #[serde(default)] + artists: Vec, +} + +/// The bound per-user file. `None` outside an active session (pure fail-open +/// window), matching the `fav_cache` lifecycle. +static STORE_PATH: Mutex> = Mutex::new(None); + +/// Bind the per-user store path from `/reco_dismiss.json`. Called on +/// every session activation — login, restore, AND offline entry — next to +/// `fav_cache::init_for_user`. +pub fn init_for_user(base_dir: &Path) { + if let Ok(mut guard) = STORE_PATH.lock() { + *guard = Some(base_dir.join(FILE_NAME)); + } +} + +/// Drop the binding on logout. Mirrors `fav_cache::teardown`. +pub fn teardown() { + if let Ok(mut guard) = STORE_PATH.lock() { + *guard = None; + } +} + +fn store_path() -> Option { + STORE_PATH.lock().ok().and_then(|g| g.clone()) +} + +/// Fail-open read: no binding, unreadable file, or unknown/corrupt format all +/// yield an empty store (a corrupted file never blocks recommendations — the +/// user simply re-dismisses). +fn load_store() -> DismissStore { + let Some(path) = store_path() else { + return DismissStore::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => DismissStore::default(), + } +} + +fn write_store(store: &DismissStore) { + let Some(path) = store_path() else { + return; + }; + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("[qbz-slint] reco-dismiss dir failed: {e}"); + return; + } + } + match serde_json::to_vec_pretty(store) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + log::warn!("[qbz-slint] reco-dismiss write failed: {e}"); + } + } + Err(e) => log::warn!("[qbz-slint] reco-dismiss serialize failed: {e}"), + } +} + +/// Snapshot of the dismissed id set — the §B paint-filter input. Empty when +/// no session is bound. Id 0 (a corrupt row) never matches. +pub fn ids_snapshot() -> HashSet { + load_store() + .artists + .into_iter() + .map(|a| a.artist_id) + .filter(|id| *id != 0) + .collect() +} + +/// All dismissed artists in insertion order, for the manager tab. Empty on no +/// session or a corrupt file. +pub fn list() -> Vec { + load_store() + .artists + .into_iter() + .filter(|a| a.artist_id != 0) + .collect() +} + +/// Record a dismissal (idempotent). A re-dismiss with a richer snapshot +/// backfills a previously empty name/image (e.g. first dismissed offline, +/// where the name could not be resolved). +pub fn dismiss(artist_id: u64, name: &str, image_url: &str) { + if artist_id == 0 { + return; + } + let mut store = load_store(); + match store.artists.iter().position(|a| a.artist_id == artist_id) { + Some(idx) => { + let existing = &mut store.artists[idx]; + if existing.name.is_empty() && !name.is_empty() { + existing.name = name.to_string(); + } + if existing.image_url.is_empty() && !image_url.is_empty() { + existing.image_url = image_url.to_string(); + } + } + None => store.artists.push(DismissedArtist { + artist_id, + name: name.to_string(), + image_url: image_url.to_string(), + }), + } + write_store(&store); +} + +/// Remove a dismissal (the manager tab's undo). No-op when absent / unbound. +pub fn remove(artist_id: u64) { + let mut store = load_store(); + let before = store.artists.len(); + store.artists.retain(|a| a.artist_id != artist_id); + if store.artists.len() != before { + write_store(&store); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unique temp dir under the system temp root (no `tempfile` dev-dep on + /// qbz-slint). Created here, removed at the end of the test. + fn unique_temp_dir() -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("qbz-slint-reco-dismiss-test-{nanos}")); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + /// One combined test: the path singleton is process-global, so splitting + /// into parallel tests would let them clobber each other. Covers the full + /// lifecycle: bind, dismiss (idempotent + snapshot backfill), persistence + /// across a re-bind, remove, unknown-format tolerance, and the fail-open + /// unbound state. + #[test] + fn lifecycle_roundtrip() { + let dir = unique_temp_dir(); + + init_for_user(&dir); + assert!(ids_snapshot().is_empty(), "fresh store has no ids"); + assert!(list().is_empty(), "fresh store has no rows"); + + dismiss(42, "Artist X", "https://img/x.jpg"); + dismiss(7, "Artist Y", ""); + assert!(ids_snapshot().contains(&42)); + assert!(ids_snapshot().contains(&7)); + assert_eq!(list().len(), 2); + + // Idempotent: a re-dismiss does not duplicate, but backfills an empty + // snapshot field. + dismiss(42, "Artist X", "https://img/x.jpg"); + dismiss(7, "Artist Y", "https://img/y.jpg"); + assert_eq!(list().len(), 2, "no duplicate rows"); + assert_eq!(list()[1].image_url, "https://img/y.jpg", "image backfilled"); + + // Id 0 is rejected and never matches. + dismiss(0, "Nobody", ""); + assert!(!ids_snapshot().contains(&0)); + + // Persistence: re-binding the same dir loads the file back. + teardown(); + assert!(ids_snapshot().is_empty(), "fail-open after teardown"); + dismiss(9, "Lost", ""); // no-op while unbound; must not panic + init_for_user(&dir); + assert!(ids_snapshot().contains(&42), "rows survive a re-bind"); + assert!(!ids_snapshot().contains(&9), "unbound mutation did not persist"); + + // Undo. + remove(42); + assert!(!ids_snapshot().contains(&42)); + assert_eq!(list().len(), 1); + remove(42); // absent: no-op, no write needed + + // Unknown-format tolerance: junk in the file reads as an empty store. + std::fs::write(dir.join(FILE_NAME), b"{ not json !!").expect("write junk"); + assert!(ids_snapshot().is_empty(), "corrupt file fails open"); + assert!(list().is_empty()); + // A partially-unknown row shape degrades to defaults, not a crash. + std::fs::write( + dir.join(FILE_NAME), + br#"{"artists":[{"artist_id":5},{"artist_id":0,"name":"zero"}]}"#, + ) + .expect("write partial"); + assert!(ids_snapshot().contains(&5), "partial row still loads"); + assert!(!ids_snapshot().contains(&0), "id 0 row is dropped"); + + teardown(); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/qbz/src/single_instance.rs b/crates/qbz/src/single_instance.rs index b06ace406..22a8f47ce 100644 --- a/crates/qbz/src/single_instance.rs +++ b/crates/qbz/src/single_instance.rs @@ -6,14 +6,18 @@ //! The first instance takes ownership of the well-known session-bus name //! `com.blitzfc.qbz` (Flatpak auto-grants owning the app-id name — no //! finish-args change needed) and exports a `com.blitzfc.qbz.SingleInstance` -//! interface with a `Present()` method. A second launch sees the name taken, -//! calls `Present()` — which raises whichever window is current (the mini -//! when the miniplayer is open, else the main window) and works from process -//! start, login screen included — and exits. If the primary predates the -//! interface (≤2.0.x) the call errors and the second launch falls back to -//! the MPRIS `Raise` method, which only exists after session entry. Any -//! D-Bus problem — no session bus, weird sandbox — falls through as "we are -//! primary": the guard must never block startup. +//! interface with `Present()` and `OpenUrl(url)` methods. A second launch +//! sees the name taken and calls `OpenUrl(url)` when its own argv carried a +//! Qobuz deep link (the primary presents itself AND navigates — Tauri +//! parity, the piece #618 didn't port) or `Present()` otherwise — which +//! raises whichever window is current (the mini when the miniplayer is +//! open, else the main window) and works from process start, login screen +//! included — and exits. If the primary predates the interface (≤2.0.x) the +//! call errors and the second launch falls back to the MPRIS `Raise` +//! method, which only exists after session entry. `OpenUrl` failing on an +//! older primary (interface present, method missing) falls back to bare +//! `Present()`. Any D-Bus problem — no session bus, weird sandbox — falls +//! through as "we are primary": the guard must never block startup. //! //! Blocking zbus API on purpose: this runs once on the main thread before //! the UI exists, and the async-io executor self-drives the connection @@ -54,16 +58,35 @@ static PENDING_PRESENT: AtomicBool = AtomicBool::new(false); /// primary sits at the login window must still raise it). struct SingleInstanceIface; +/// Shared Present path for the iface methods: raise whichever window is +/// current, or remember the request until `bind_window` (simultaneous cold +/// starts: the DoNotQueue loser can call in while the winner is still +/// initializing). +fn present_or_defer() { + match MAIN_WEAK.get() { + Some(weak) => crate::tray::present(weak), + None => PENDING_PRESENT.store(true, Ordering::SeqCst), + } +} + #[zbus::interface(name = "com.blitzfc.qbz.SingleInstance")] impl SingleInstanceIface { /// Raise whichever window is current (mini when the miniplayer is open, /// else the main window). Runs on a zbus executor thread — never touch /// Slint state here; `tray::present` routes through the event loop. fn present(&self) { - match MAIN_WEAK.get() { - Some(weak) => crate::tray::present(weak), - None => PENDING_PRESENT.store(true, Ordering::SeqCst), - } + present_or_defer(); + } + + /// A second launch carrying a Qobuz deep link forwards it here: stash + /// the URL, present ourselves, and dispatch it through the running + /// instance's Ctrl+L link-resolver flow (`deep_link::drain_pending`). + /// With no shell up (sitting at the login screen, or offline) the URL + /// stays pending for the next successful `enter_shell`. + fn open_url(&self, url: &str) { + crate::deep_link::stash(url.to_string()); + present_or_defer(); + crate::deep_link::drain_pending(); } } @@ -110,9 +133,22 @@ fn probe() -> zbus::Result { // runs. Ask it to present itself; both calls are best-effort — the // duplicate still must not start. RequestNameReply::Exists | RequestNameReply::InQueue => { - let presented = conn - .call_method(Some(BUS_NAME), OBJECT_PATH, Some(IFACE_NAME), "Present", &()) - .is_ok(); + // Carrying a deep link from argv (captured at process start)? + // Forward it: OpenUrl makes the primary present itself AND + // navigate. An older primary without the method errors → the + // bare-Present ladder below (version-skew tolerance); the URL + // is then lost with this exiting process, same as a failed emit + // in the Tauri era. + let forwarded = match crate::deep_link::take_pending() { + Some(url) => conn + .call_method(Some(BUS_NAME), OBJECT_PATH, Some(IFACE_NAME), "OpenUrl", &url) + .is_ok(), + None => false, + }; + let presented = forwarded + || conn + .call_method(Some(BUS_NAME), OBJECT_PATH, Some(IFACE_NAME), "Present", &()) + .is_ok(); if !presented { // Older primary (≤2.0.x) without the SingleInstance interface: // fall back to MPRIS Raise. Full MPRIS name = diff --git a/flatpak/com.blitzfc.qbz.desktop b/flatpak/com.blitzfc.qbz.desktop index c0e8f61c4..93bf12922 100644 --- a/flatpak/com.blitzfc.qbz.desktop +++ b/flatpak/com.blitzfc.qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/arch/com.blitzfc.qbz.desktop b/packaging/arch/com.blitzfc.qbz.desktop index c0e8f61c4..93bf12922 100644 --- a/packaging/arch/com.blitzfc.qbz.desktop +++ b/packaging/arch/com.blitzfc.qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/aur/qbz.desktop b/packaging/aur/qbz.desktop index 87aefe75e..80b93c913 100644 --- a/packaging/aur/qbz.desktop +++ b/packaging/aur/qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/flatpak/com.blitzfc.qbz.desktop b/packaging/flatpak/com.blitzfc.qbz.desktop index c0e8f61c4..93bf12922 100644 --- a/packaging/flatpak/com.blitzfc.qbz.desktop +++ b/packaging/flatpak/com.blitzfc.qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/linux/qbz.desktop b/packaging/linux/qbz.desktop index 45b8b30fb..f8ca2203a 100644 --- a/packaging/linux/qbz.desktop +++ b/packaging/linux/qbz.desktop @@ -9,4 +9,4 @@ Categories=AudioVideo;Audio;Music;Player; Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; StartupWMClass=com.blitzfc.qbz StartupNotify=true -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/snap/snap/gui/qbz.desktop b/packaging/snap/snap/gui/qbz.desktop index 577fad69f..04c40d4c3 100644 --- a/packaging/snap/snap/gui/qbz.desktop +++ b/packaging/snap/snap/gui/qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp;