diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index 55e72b380..2e4478c7b 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -70,11 +70,13 @@ Rectangle { // so the two surfaces stay pixel-identical. property bool localMode: false - // Album blacklist ("Block this album"): no write seam on the Qt bridge — - // the entry stays out of the menu until one lands, rather than shipping - // a row that silently no-ops. Flip this and fill the "block" branch in - // `menuAction` together. - readonly property bool hasBlacklistSeam: false + // Album blacklist ("Block this album") — LIVE since QbzBlacklist landed + // (`blockAlbum(id, title, artist, coverUrl)`). Kept as a property, not + // inlined, because a host whose `albumId` is not a Qobuz catalog id must + // be able to turn it off; `localMode` already covers the two shipped + // cases. One-way, exactly like the .slint: the card drops on the grid's + // next reload, there is no live removal. + property bool hasBlacklistSeam: true signal openRequested() signal playRequested() @@ -169,6 +171,11 @@ Rectangle { else if (a === "later") QbzPlayer.enqueueAlbum(root.albumId, "later") else if (a === "queue") QbzPlayer.enqueueAlbum(root.albumId, "queue") else if (a === "favorite") root.toggleFavorite() + // `artworkUrl` (the REMOTE url), never `artSource` — the store keeps a + // denormalized snapshot and a file:// cache path is dead on any other + // machine, the same reason the pin payload uses it. + else if (a === "block") QbzBlacklist.blockAlbum(root.albumId, root.title, + root.artist, root.artworkUrl) } Column { diff --git a/crates/qbz-qt/qml/cards/ArtistCard.qml b/crates/qbz-qt/qml/cards/ArtistCard.qml index 190fbb062..526b50727 100644 --- a/crates/qbz-qt/qml/cards/ArtistCard.qml +++ b/crates/qbz-qt/qml/cards/ArtistCard.qml @@ -32,8 +32,8 @@ // // --- Menu inventory vs discover/ArtistGridCard.slint ------------------- // Open artist · Play · Follow/Following (show-follow) · Not interested -// The first three are live. "Not interested" is gated off -// (`hasNotInterestedSeam`) — it used to render and do nothing at all. +// All four are live; "Not interested" rides `hasNotInterestedSeam` (default +// on) and writes the reco-dismissal store through QbzBlacklist. // ⋯ and a right press on the portrait or the name open the same menu. import QtQuick @@ -55,10 +55,13 @@ Rectangle { property string followMode: "toggle" property string followKind: "artist" - // --- Seams the Qt bridge does NOT have (menu-parity round) ----------- - // hasNotInterestedSeam -> the reco-scoped dismissal store is not - // opened by the Qt port - // That entry stays out of the menu until its invokable lands. + // --- "Not interested" (the reco-scoped dismissal) -------------------- + // LIVE since QbzBlacklist landed: `dismissArtist(id, name, imageUrl)` + // writes the reco-dismissal store (NOT the artist blacklist). Kept as a + // property, not inlined, so a host whose `item.id` is not a Qobuz artist + // id can turn it off. Live BACKFILL is out of scope — the card leaves the + // rails on the next publish, not on the click (src/recommendations_qt.rs + // documents the retained-overflow drop). // // `hasFollowSeam` is GONE, and the header comment it carried ("There is // NO artist-follow invokable on the Qt bridge") was simply wrong: @@ -67,7 +70,7 @@ Rectangle { // `add_favorite` / `remove_favorite`, and ArtistView's own header button // has been calling it all along. The constant made the follow affordance // disappear from EVERY artist card in the app. - readonly property bool hasNotInterestedSeam: false + property bool hasNotInterestedSeam: true color: "transparent" @@ -311,7 +314,15 @@ Rectangle { if (a === "open") QbzArtist.openArtist(root.item.id) else if (a === "play") QbzPlayer.playArtistCard(root.item.id) else if (a === "follow") root.toggleFollow() - // "not-interested" is unreachable while hasNotInterestedSeam is - // false — the entry is not built. + // THREE args: the store persists (id, name, image_url) and only + // backfills a field that is EMPTY, so a blank first write can never be + // repaired later. `artworkUrl` first because that is the host- + // normalized REMOTE url (some producers spell the key `imageUrl`, e.g. + // LibraryView) — `item.artUrl` is the HomeCard fallback, and never + // `artSource`, which is a local file:// cache path. + else if (a === "not-interested") + QbzBlacklist.dismissArtist(root.item.id, + root.item.name || root.item.title || "", + root.artworkUrl !== "" ? root.artworkUrl : (root.item.artUrl || "")) } } diff --git a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml new file mode 100644 index 000000000..15760774a --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml @@ -0,0 +1,231 @@ +// QbzConfirmModal — THE port's confirm dialog. Before this file the tree had +// no confirm surface at all (grep -rl confirm qml/ found only LyricsPanel and +// ArtistView, neither a dialog), so every destructive action either mutated +// straight away or was not shipped. It is written generic on purpose: the +// Blacklist manager's Clear-All is the first caller, and MyQBZ delete, +// playlist delete and the offline-cache purge all need the same surface. +// +// Geometry is 1:1 with the Slint Clear-All modal +// (settings/BlacklistManagerView.slint:992-1113), which itself mirrors +// PlaylistDuplicateConfirmModal: +// scrim full-bleed #000000bf, click dismisses +// panel width min(host - 80, 400), height = content, centered, Radius.md, +// surface-main, 1px border-subtle, drop-shadow blur 32 #00000080 +// padding 24, spacing 16; title heading/semibold, body 15 text-secondary +// buttons right-aligned, spacing 10, both 36px tall, Radius.sm, +// width = label + 32 +// danger fill Theme.danger, hover Theme.danger.darker(0.12) — NOT +// `danger-hover`, which is a translucent tint for GHOST danger +// surfaces and would wash a filled button out (the Slint comment at +// :1080-1089 spells this out at length); label hard #ffffff +// +// MOUNT IT LAST inside the view root with `anchors.fill: parent`. ADR-009's +// ">= 3000" is satisfied both structurally (declaration order IS z-order) and +// by the explicit z: 3100 below — the same layer the port's only other in-pane +// modal scrim sits on (controls/DiscoverConfigModal.qml:56), so the two never +// fight. +// +// The shadow is FAKED (a translucent rounded rect behind the panel) — the +// LoginScreen.qml:30-49 precedent, including its 2026-07-29 note: the old +// "effects render nothing here" doctrine is RETIRED (both effect modules are +// installed and this port runs on the GPU; RoundedImage.qml detects the +// software path with GraphicsInfo.api instead of assuming it). A real blurred +// drop shadow is a visual change owing its own parity pass, so the fake stays +// for now, exactly as LoginScreen's does. The port's only other in-pane modal +// (controls/DiscoverConfigModal.qml) draws no shadow at all. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + property string title: "" + property string body: "" + /// "" is not a valid label — the default is the shared msgid, as a BINDING + /// so a live language switch re-translates it (QbzSession.trRev). + property string cancelLabel: QbzSession.tr("Cancel", QbzSession.trRev) + property string confirmLabel: "" + /// Filled-red confirm button (the Clear-All / delete voice). false gives + /// the accent fill instead, for a non-destructive confirmation. + /// + /// NAMED `danger`, not `destructive`: spec 03 §6.10/§10 specifies `danger`, + /// and `controls/SettingsButton.qml:15` already carries `property bool + /// danger` for exactly this concept (red border + red label + a `favorite` + /// glyph tint). Two names for one idea across two neighbouring controls is + /// how a call site ends up setting a property that does not exist — which in + /// QML is not an error, just a silently-ignored line. + property bool danger: true + + readonly property bool opened: root._open + property bool _open: false + + signal confirmed() + signal cancelled() + + QbzTheme { id: theme } + + function open() { + root._open = true + // AFTER _open, so `visible` is already true — an invisible item cannot + // take active focus and the Esc handler would be dead. + keyScope.forceActiveFocus() + } + function close() { + root._open = false + } + function _cancel() { + root.close() + root.cancelled() + } + function _confirm() { + root.close() + root.confirmed() + } + + visible: root._open + enabled: root._open + z: 3100 + + // Esc dismiss (the Slint esc-scope FocusScope, :1006-1017). No visual and + // no MouseArea, so it never intercepts a press meant for the scrim. + FocusScope { + id: keyScope + anchors.fill: parent + Keys.onEscapePressed: function (event) { + root._cancel() + event.accepted = true + } + } + + // Scrim. `radius` is load-bearing, not decoration: this Item fills the + // view root, which fills AppShell's rounded content frame, and Qt Quick's + // `clip` is a rectangular scissor that does not follow `radius` — so an + // opaque full-bleed child paints into the frame's four bezel corners + // (AppShell.qml:252-259, which names DiscoverConfigModal's scrim as the + // one that had to be fixed exactly this way). + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: root._cancel() + } + } + + // Faked drop shadow (blur 32 / #00000080) — see the header note. + Rectangle { + x: panel.x + y: panel.y + 8 + width: panel.width + height: panel.height + radius: theme.radiusMd + color: "#00000080" + opacity: 0.5 + } + + Rectangle { + id: panel + width: Math.min(root.width - 80, 400) + // The Slint panel is `height: panel.preferred-height` over a + // VerticalLayout with padding 24 -> content + 48. + height: panelCol.implicitHeight + 48 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceMain + border.width: 1 + border.color: theme.borderSubtle + + // Swallow clicks so they never reach the scrim (the Slint's empty + // panel TouchArea, :1030). + MouseArea { anchors.fill: parent } + + Column { + id: panelCol + x: 24 + y: 24 + width: parent.width - 48 + spacing: 16 + + Text { + width: parent.width + text: root.title + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + wrapMode: Text.WordWrap + } + Text { + visible: root.body !== "" + width: parent.width + text: root.body + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + Item { + width: parent.width + height: 36 + Row { + anchors.right: parent.right + spacing: 10 + + Rectangle { + width: cancelLbl.implicitWidth + 32 + height: 36 + radius: theme.radiusSm + color: cancelArea.containsMouse ? theme.surfaceHover + : theme.surfaceElevated + Text { + id: cancelLbl + anchors.centerIn: parent + text: root.cancelLabel + color: theme.textPrimary + font.pixelSize: theme.fontBody + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root._cancel() + } + } + Rectangle { + width: confirmLbl.implicitWidth + 32 + height: 36 + radius: theme.radiusSm + color: root.danger + ? (confirmArea.containsMouse ? Qt.darker(theme.danger, 1.12) + : theme.danger) + : (confirmArea.containsMouse ? theme.accentHover : theme.accent) + Text { + id: confirmLbl + anchors.centerIn: parent + text: root.confirmLabel + // The Slint hard-codes #ffffff on the filled danger + // button and that is correct here — `danger` is a + // fixed red under every palette. The accent arm goes + // through the theme's on-accent selector instead + // (QbzTheme.qml:314-317), which is the port's + // owner-approved WCAG deviation. + color: root.danger ? "#ffffff" : theme.accentGlyphColor + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + } + MouseArea { + id: confirmArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root._confirm() + } + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/QbzEmptyState.qml b/crates/qbz-qt/qml/controls/QbzEmptyState.qml index bda8689d3..5c5b42bf5 100644 --- a/crates/qbz-qt/qml/controls/QbzEmptyState.qml +++ b/crates/qbz-qt/qml/controls/QbzEmptyState.qml @@ -10,30 +10,41 @@ import com.blitzfc.qbz import "../theme" Column { + id: emptyRoot property string iconName: "" property string title: "" property string body: "" property string actionLabel: "" + // Per-surface metrics. The consolidated default is the phase-22 one (40px + // glyph, full opacity, primary semibold title); the Blacklist manager's + // three empty branches are a 48px glyph at 0.3 with a MUTED regular title + // (BlacklistManagerView.slint:702-732, :801-831, :898-928), which is a + // call-site number, not a second component. + property int iconSize: 40 + property real iconOpacity: 1.0 + property bool titleMuted: false + property int titleWeight: theme.weightSemibold signal actionClicked() QbzTheme { id: theme } spacing: 0 QbzIcon { - visible: iconName !== "" - name: iconName - width: 40 - height: 40 + visible: emptyRoot.iconName !== "" + name: emptyRoot.iconName + width: emptyRoot.iconSize + height: emptyRoot.iconSize + opacity: emptyRoot.iconOpacity anchors.horizontalCenter: parent.horizontalCenter tintName: "muted" } Item { visible: iconName !== ""; width: 1; height: 14 } Text { anchors.horizontalCenter: parent.horizontalCenter - text: title - color: theme.textPrimary + text: emptyRoot.title + color: emptyRoot.titleMuted ? theme.textMuted : theme.textPrimary font.pixelSize: theme.fontSection - font.weight: theme.weightSemibold + font.weight: emptyRoot.titleWeight } Item { visible: body !== ""; width: 1; height: 6 } Text { diff --git a/crates/qbz-qt/qml/rows/BlacklistRow.qml b/crates/qbz-qt/qml/rows/BlacklistRow.qml new file mode 100644 index 000000000..92c02edb8 --- /dev/null +++ b/crates/qbz-qt/qml/rows/BlacklistRow.qml @@ -0,0 +1,212 @@ +// BlacklistRow — the Blacklist manager's row, ONE file with THREE arms. +// +// The Slint reference declares three components +// (settings/BlacklistManagerView.slint: ArtistRow :31-135, AlbumRow :138-246, +// DismissedRow :251-336) and they share the frame, the hover rule, the text +// column and the remove button VERBATIM — they differ only in the leading +// visual and in which meta lines exist. Collapsing them is the phase-21/22 +// TrackRow unification precedent (4 copies -> 1, -716 lines) that +// TRACK-RULES §5 cites. +// +// Common frame, all three arms: +// height 64 · radius Radius.md (12) · hovered ? surface-elevated +// : surface-card, where hovered = BODY hover OR REMOVE-button hover +// (:36, :143, :254) · padding left 16 / right 8 (:49-50) · gap 12 (:51) +// · body cursor pointer, click opens the artist / album (:57-60) +// · text column vertically centred, spacing 2 (:83-85) +// +// arm leading visual lines +// artist 44x44 circle (r22) surface-elevated name 15/medium · +// + blind-eye 20x20 muted (:67-80) "Added {}" 13 muted · +// notes 13 muted ITALIC +// when has-notes (:99-105) +// album 48x48 rounded-8 clipped tile: title 15/medium · +// blind-eye 20x20 muted UNDERNEATH, artist 13 TEXT-SECONDARY · +// the cover on top (:176-194) "Added {}" 13 muted +// dismissed 44x44 circle + `user` 20x20 muted name 15/medium ONLY, and +// — NOT blind-eye, this is not the the column has NO spacing +// blacklist (:283-296, note :248-250) (:298-307) +// +// Remove / undo button, identical in all three (:113-132, :224-243, :314-333): +// 36x36, Radius.sm, danger-bg on hover else transparent, `x` glyph 18x18, +// pointer cursor, OPTIMISTIC — no confirm. +// +// TINT TRAP: the Slint tints that glyph `Theme.danger` on hover. There is NO +// `danger` tint in this port — the vocabulary is a closed list of NAMES +// (src/icon_tint_qt.rs:276-293, theme/QbzIcon.qml:32-58) and a name outside it +// resolves to a missing directory, so the glyph renders NOTHING with NOTHING +// logged (QbzIcon.qml:93-105). The port's red glyph tint is `favorite` +// (#ef4444 vs the Slint's #e0564f — the already-accepted substitution, +// controls/CardMenu.qml:20-26, controls/SettingsButton.qml:38). +// That also needs assets/icons/favorite/x.svg, which the deliberately partial +// 52-of-96 `favorite/` dir does not carry — the runtime bake produces it, but +// the qrc FLOOR is reachable (no writable cache dir, a pruned tint dir, a +// pre-publish theme document) and on the floor the glyph vanishes. +// +// The cover is theme/RoundedImage.qml (the Canvas raster clip — QML `clip` is +// rectangular and shader masks draw nothing on this port's software path). The +// blind-eye fallback sits UNDER it and is never hidden, exactly the Slint +// layering, so there is no placeholder to gate on `ready` here. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + /// "artist" | "album" | "dismissed" + property string arm: "artist" + /// One row object out of blacklistJson's artists[] / albums[] / dismissed[]. + property var row: ({}) + /// Resolved cover path for the album arm ("" = show the blind-eye only). + property string coverSource: "" + + signal selectRequested() + signal removeRequested() + + QbzTheme { id: theme } + + readonly property bool hovered: bodyArea.containsMouse || removeArea.containsMouse + readonly property bool isAlbum: root.arm === "album" + readonly property int leadSize: root.isAlbum ? 48 : 44 + + height: 64 + radius: theme.radiusMd + color: root.hovered ? theme.surfaceElevated : theme.surfaceCard + + // ---- body (clickable: opens the artist / album page) ----------------- + Item { + id: bodyRegion + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: removeSlot.left + anchors.rightMargin: 12 + + MouseArea { + id: bodyArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.selectRequested() + } + + Row { + anchors.fill: parent + spacing: 12 + + // Leading visual. + Item { + id: leadWrap + width: root.leadSize + height: parent.height + Rectangle { + anchors.centerIn: parent + width: root.leadSize + height: root.leadSize + // Radius.sm on the album tile, a full circle otherwise. + radius: root.isAlbum ? theme.radiusSm : (root.leadSize / 2) + color: theme.surfaceElevated + clip: root.isAlbum + + QbzIcon { + anchors.centerIn: parent + name: root.arm === "dismissed" ? "user" : "blind-eye" + width: 20 + height: 20 + tintName: "muted" + } + RoundedImage { + visible: root.isAlbum && root.coverSource !== "" + anchors.fill: parent + source: root.coverSource + radius: theme.radiusSm + } + } + } + + // Name / meta column. + Column { + width: Math.max(0, bodyRegion.width - leadWrap.width - 12) + anchors.verticalCenter: parent.verticalCenter + // The dismissed arm is one line and its Slint column sets no + // spacing at all (:298-307). + spacing: root.arm === "dismissed" ? 0 : 2 + + Text { + width: parent.width + text: root.isAlbum ? (root.row.title || "") : (root.row.name || "") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + elide: Text.ElideRight + } + // Album arm only — and text-SECONDARY, not muted (:206-210). + Text { + visible: root.isAlbum + width: parent.width + text: root.row.artist || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + // The dismissal store carries no timestamp, so this line has no + // dismissed arm (reco_dismiss's DismissedArtist has no addedAt). + Text { + visible: root.arm !== "dismissed" + width: parent.width + text: QbzSession.tr("Added {}", QbzSession.trRev) + .replace("{}", root.row.addedDisplay || "") + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + // Notes render on the ARTIST rows only. The album row carries + // the field in the store but the Slint renders added-display in + // that slot instead (:196-218) — port the asymmetry as-is. + Text { + visible: root.arm === "artist" && root.row.hasNotes === true + width: parent.width + text: root.row.notes || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.italic: true + elide: Text.ElideRight + } + } + } + } + + // ---- remove / undo (optimistic, no confirm) --------------------------- + Item { + id: removeSlot + width: 36 + height: 36 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: removeArea.containsMouse ? theme.dangerBg : "transparent" + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 18 + height: 18 + // NOT "danger" — that tint does not exist; see the header. + tintName: removeArea.containsMouse ? "favorite" : "muted" + } + MouseArea { + id: removeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.removeRequested() + } + } + } +} diff --git a/crates/qbz-qt/qml/settings/BlacklistSettings.qml b/crates/qbz-qt/qml/settings/BlacklistSettings.qml index 40edf170b..eb3dd248c 100644 --- a/crates/qbz-qt/qml/settings/BlacklistSettings.qml +++ b/crates/qbz-qt/qml/settings/BlacklistSettings.qml @@ -1,18 +1,35 @@ // Settings > Blacklist — the QML port of crates/qbz-ui/ui/settings/ -// ContentFilteringSettings.slint: ONE row (blind-eye glyph + "Blacklist" + +// ContentFilteringSettings.slint: ONE 64px row (blind-eye glyph + "Blacklist" + // "{n} artists, {m} albums blocked", with a "(disabled)" suffix when the -// feature is off). There is no enable toggle here in the Slint either — the -// switch lives inside the Blacklist Manager. +// feature is off) and a right-aligned "Manage" button that opens the full-page +// Blacklist Manager. There is no enable toggle here in the Slint either +// (:13-16) — the switch lives inside the manager. // -// The counters are the REAL per-user store (artist_blacklist.db, read through -// the shared BlacklistService — settings_qt/devtools.rs). +// Layout, verbatim from the reference (:66-115): outer spacing 4; a 64px row +// with spacing 24; the left group horizontal-stretch 1 / spacing 12 with the +// blind-eye 18x18 tint secondary, then a text column at spacing 3 holding +// "Blacklist" (text-primary 15 medium) over the status line (text-muted, a +// LITERAL 12px — not a Typography token); the ManageButton right-aligned and +// vertically centred. // -// Delta vs the Slint: the "Manage" chevron is NOT shipped — this port has no -// Blacklist Manager view for it to open, and a button that opens nothing is -// exactly what this pass exists to remove. +// ManageButton (:26-64) is `width: max(content + 32, 160)`, height 34, Radius.sm, +// 1px border-subtle, surfaceHover on hover else surfaceElevated, content centred +// with spacing 6: label "Manage" text-secondary 15 medium, then chevron-right +// 16x16 tint secondary. controls/SettingsButton.qml already matches every one of +// those metrics, so it is reused with a trailing-icon slot rather than forked. +// Accepted deltas (the port's settings-button voice): content padding 24 vs 32, +// icon 15 vs 16, label textPrimary vs textSecondary. +// +// The counters come from the per-user blacklist singleton +// (crate::artist_blacklist, published into the settings document by +// settings_qt/devtools.rs::blacklist_counts), so they tell the truth +// immediately after a mutation — every blacklist mutation republishes the +// settings document as well as its own, which is what makes +// Manage -> unblock -> Back show fresh numbers. import QtQuick import com.blitzfc.qbz +import "../controls" import "../theme" Column { @@ -28,39 +45,59 @@ Column { Item { width: parent.width height: 64 + Row { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 12 - QbzIcon { + anchors.fill: parent + // The Slint's 24px gap between the left group and the button. + spacing: 24 + + Row { + id: leftGroup + width: Math.max(0, parent.width - 24 - manageBtn.width) anchors.verticalCenter: parent.verticalCenter - name: "blind-eye" - width: 18 - height: 18 - tintName: "secondary" + spacing: 12 + + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "blind-eye" + width: 18 + height: 18 + tintName: "secondary" + } + Column { + // 30 = the 18px glyph + the 12px row spacing. + width: Math.max(0, parent.width - 30) + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Text { + width: parent.width + text: QbzSession.tr("Blacklist", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + } + Text { + width: parent.width + // Two placeholders, replaced left to right: + // artists first, then albums. + text: (root.dev.blacklistEnabled === false + ? QbzSession.tr("{} artists, {} albums blocked (disabled)", QbzSession.trRev) + : QbzSession.tr("{} artists, {} albums blocked", QbzSession.trRev)) + .replace("{}", root.dev.blacklistArtists || 0) + .replace("{}", root.dev.blacklistAlbums || 0) + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } } - Column { - width: parent.width - 30 - spacing: 3 - Text { - width: parent.width - text: QbzSession.tr("Blacklist", QbzSession.trRev) - color: theme.textPrimary - font.pixelSize: theme.fontBody - font.weight: theme.weightMedium - } - Text { - width: parent.width - text: (root.dev.blacklistEnabled === false - ? QbzSession.tr("{} artists, {} albums blocked (disabled)", QbzSession.trRev) - : QbzSession.tr("{} artists, {} albums blocked", QbzSession.trRev)) - .replace("{}", root.dev.blacklistArtists || 0) - .replace("{}", root.dev.blacklistAlbums || 0) - color: theme.textMuted - font.pixelSize: 12 - wrapMode: Text.WordWrap - } + + SettingsButton { + id: manageBtn + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Manage", QbzSession.trRev) + trailingIconName: "chevron-right" + onClicked: QbzBlacklist.openManager() } } } diff --git a/crates/qbz-qt/qml/settings/SettingsView.qml b/crates/qbz-qt/qml/settings/SettingsView.qml index 174d042e8..5d49a5d5b 100644 --- a/crates/qbz-qt/qml/settings/SettingsView.qml +++ b/crates/qbz-qt/qml/settings/SettingsView.qml @@ -36,7 +36,19 @@ Item { // The parsed settingsJson document ({} until the first publish lands). property var doc: ({}) // Active sub-section (display order — see the header comment). - property int section: 0 + // + // A BINDING onto bridge state, never local truth. The content Loader + // unmounts this whole view when the user navigates away, so a plain + // `property int section: 0` was lost on every round trip — the Blacklist + // panel's "Manage" chevron opens the manager VIEW, and coming Back landed + // on Audio. See `bridge.rs`'s note on `settings_section`. + // + // Consequently every sub-nav row calls `QbzBridge.settingsSetSection(n)` + // and NOTHING assigns `root.section` directly: in QML an imperative + // assignment destroys the binding it lands on, so a single surviving + // `root.section = n` would work once and then re-introduce the bug one + // navigation later, in a way that looks fixed under casual testing. + readonly property int section: QbzBridge.settingsSection readonly property bool sandboxed: { const m = (doc.dev || ({})).installMethod || "" @@ -145,49 +157,49 @@ Item { name: "volume-2" label: QbzSession.tr("Audio", QbzSession.trRev) active: root.section === 0 - onClicked: root.section = 0 + onClicked: QbzBridge.settingsSetSection(0) } SubNavItem { name: "play-fill" label: QbzSession.tr("Playback", QbzSession.trRev) active: root.section === 1 - onClicked: root.section = 1 + onClicked: QbzBridge.settingsSetSection(1) } SubNavItem { name: "layers" label: QbzSession.tr("Appearance", QbzSession.trRev) active: root.section === 2 - onClicked: root.section = 2 + onClicked: QbzBridge.settingsSetSection(2) } SubNavItem { name: "cloud-download" label: QbzSession.tr("Offline", QbzSession.trRev) active: root.section === 3 - onClicked: root.section = 3 + onClicked: QbzBridge.settingsSetSection(3) } SubNavItem { name: "hard-drive" label: QbzSession.tr("Local Library", QbzSession.trRev) active: root.section === 4 - onClicked: root.section = 4 + onClicked: QbzBridge.settingsSetSection(4) } SubNavItem { name: "blind-eye" label: QbzSession.tr("Blacklist", QbzSession.trRev) active: root.section === 5 - onClicked: root.section = 5 + onClicked: QbzBridge.settingsSetSection(5) } SubNavItem { name: "refresh-cw" label: QbzSession.tr("Integrations", QbzSession.trRev) active: root.section === 6 - onClicked: root.section = 6 + onClicked: QbzBridge.settingsSetSection(6) } SubNavItem { name: "bug" label: QbzSession.tr("Developer", QbzSession.trRev) active: root.section === 7 - onClicked: root.section = 7 + onClicked: QbzBridge.settingsSetSection(7) } // Sandboxed installs only (Flatpak / Snap permissions). SubNavItem { @@ -197,7 +209,7 @@ Item { ? QbzSession.tr("Snap", QbzSession.trRev) : QbzSession.tr("Flatpak", QbzSession.trRev) active: root.section === 8 - onClicked: root.section = 8 + onClicked: QbzBridge.settingsSetSection(8) } } diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml index a29cea7f3..a4de3b0e6 100644 --- a/crates/qbz-qt/qml/views/AlbumListRow.qml +++ b/crates/qbz-qt/qml/views/AlbumListRow.qml @@ -9,12 +9,15 @@ // (Open album / Play / Play next / Play later / Add to queue). Right-clicking // the row opens the same menu at the pointer, like every other ⋯ site. // -// "Block this album" is absent for the same reason AlbumCard.qml gates it -// off: the Qt bridge exposes the blacklist COUNTERS but no block-album -// invokable, and a row that renders and does nothing is a defect. +// "Block this album" is the sixth entry, live since QbzBlacklist landed +// (primitives/AlbumListRow.slint:434-441). It is pushed conditionally rather +// than sitting in the literal because the .slint gates it on a non-local / +// non-plex source — see the `entries` block. // // item contract: home_qt::HomeCard — { id, title, artist, artistId, year, -// qualityTier, qualityDetail, artPath }. +// qualityTier, qualityDetail, artUrl, artPath }. `artUrl` is the REMOTE cover +// url and `artPath` the local file:// cache path; only the former may be +// persisted (src/home_qt.rs:117-121). import QtQuick import com.blitzfc.qbz @@ -65,18 +68,39 @@ Rectangle { CardMenu { id: rowMenu menuWidth: 196 - entries: [ - { "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" }, - { "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" }, - { "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" }, - { "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" }, - { "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }, - ] + // A JS expression, not a literal: the block row is CONDITIONAL. The + // function form also re-evaluates on `trRev`, which the literal + // already did through each `tr(…, trRev)` call. + entries: { + var t = QbzSession.tr + var r = QbzSession.trRev + var m = [ + { "label": t("Open album", r), "icon": "library-big", "action": "open" }, + { "label": t("Play", r), "icon": "play-fill", "action": "play" }, + { "label": t("Play next", r), "icon": "list-start", "action": "next" }, + { "label": t("Play later", r), "icon": "list-plus", "action": "later" }, + { "label": t("Add to queue", r), "icon": "list-end", "action": "queue" }, + ] + // AlbumListRow.slint:434-441 gates the row on a non-local/plex + // source. `home_qt::HomeCard` carries NO `source` field, so this + // reads "" and the row shows — correct for these two catalog-only + // surfaces (Discover Browse, Label Releases). Written defensively + // so a producer that starts emitting `source` is honoured without + // a second edit here. + var src = root.item.source || "" + if (src !== "local" && src !== "plex") + m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" }) + return m + } onPicked: function (a) { var id = root.item.id || "" if (id === "") return if (a === "open") QbzAlbum.openAlbum(id) else if (a === "play") QbzPlayer.playAlbum(id) + // `artUrl`, never `artPath`: the store keeps a denormalized cover + // url and a file:// cache path is dead on any other machine. + else if (a === "block") QbzBlacklist.blockAlbum(id, root.item.title || "", + root.item.artist || "", root.item.artUrl || "") else QbzPlayer.enqueueAlbum(id, a) } } diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index 8b855917e..393dc9d51 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -155,6 +155,21 @@ Rectangle { root.setToggleState("album", value) } } + // Album-blacklist settle / rollback. `blacklistChanged` carries the state + // the write actually produced — flipped on success, UNCHANGED on failure + // (blacklist_qt.rs `album_toggle`), so this is both the cross-surface walk + // (the manager's row `x`, a card's "Block this album") and the rollback for + // the header menu's optimistic flip below. Same two-arg `{kind}:{id}` shape + // as pinChanged / libraryFavoriteChanged above; a separate Connections + // block only because the signal lives on a different singleton. + Connections { + target: QbzBlacklist + function onBlacklistChanged(key, value) { + var id = (header && header.id) ? header.id : "" + if (id !== "" && key === "album:" + id) + root.setToggleState("blocked", value) + } + } Component.onCompleted: { syncAlbumState(); dispatchCovers() } onAlbumChanged: { syncAlbumState(); dispatchCovers() } // The derived binding settles AFTER onAlbumChanged fires (stale race) — @@ -944,6 +959,15 @@ Rectangle { onEnqueueRequested: function (m) { QbzPlayer.enqueueAlbumTrack(header.id, item.id, m === "next" ? "next" : "later") } + // MyQBZ "Add to mixtape" — the HOST builds the + // AddItem array (TrackRow does not know + // itemType/source). + onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{ + "itemType": "track", "source": "qobuz", + "sourceItemId": item.id, "title": item.title || "", + "subtitle": item.artist || "", "artworkUrl": "", + "year": null, "trackCount": null + }])) } } } @@ -1085,7 +1109,9 @@ Rectangle { target: pageFlick } - // Album ⋯ menu (AlbumContextMenu subset — card menu + pin). + // Album ⋯ menu (AlbumContextMenu subset — card menu + pin + the block + // toggle; the .slint's playlist/mixtape/share/offline rows are the parts + // still out of scope). QbzContextMenu { id: albumMenu menuWidth: 196 @@ -1140,5 +1166,62 @@ Rectangle { } } } + + // AlbumContextMenu.slint:153 — a 1px border-subtle separator, then + // the album-blacklist toggle (:157-172). The .slint writes it as two + // `if` arms (Block / Unblock) whose row count is constant; one row + // with a flipping label is the same single row, and it is the shape + // ArtistPageView.slint:561-572 uses for the identical toggle. Own + // Rectangle rather than a sixth Repeater entry because the inline + // delegate above has no separator arm — `{sep:true}` is CardMenu's + // vocabulary, not this hand-rolled menu's (ArtistView.qml's + // overflow menu draws its blacklist row exactly this way). + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + Rectangle { + id: ablkRow + width: parent.width + height: 33 + radius: 5 + // Seeded from the header document, which does NOT carry the + // field yet (album_qt.rs still has to port album.rs:683's + // `set_is_album_blocked` seed — spec 03 F13). Read defensively: + // `undefined` folds to false through toggleState's + // `fallback === true`, so the row is correct the moment the + // seed lands and never throws before then. + readonly property bool blocked: root.toggleState("blocked", header.isAlbumBlocked) + color: ablkArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + QbzIcon { name: "blind-eye"; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" } + Text { + height: parent.height + width: parent.width - 23 + text: ablkRow.blocked + ? QbzSession.tr("Unblock album", QbzSession.trRev) + : QbzSession.tr("Block this album", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: ablkArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + albumMenu.close() + // Optimistic flip (main.rs:12835), then the mutation; + // `blacklistChanged` above settles it — or rolls it + // back on a write failure (main.rs:12859). + root.setToggleState("blocked", !ablkRow.blocked) + QbzBlacklist.albumToggle(header.id, header.title, + header.artist, header.artUrl) + } + } + } } } diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index 90b213906..9c356d8b7 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -200,6 +200,28 @@ Rectangle { localToggles = Object.assign({}, m) } + // --- Artist blacklist ------------------------------------------------- + // ONE source for the two surfaces the .slint drives off the same + // ArtistState.is-blacklisted: the overflow-menu row label + // (ArtistPageView.slint:565-567) and the hidden-artist banner (:595, :600). + // artist_qt.rs does not seed the field yet (spec 03 C5 — main.rs:2653-2659 + // does it in the reference), so `artist.isBlacklisted` reads `undefined` + // today and toggleState's `fallback === true` folds that to false; the + // optimistic flip + the `blacklistChanged` settle below make the page + // correct within a visit, and it becomes correct on ENTRY the moment the + // seed lands, with no QML change. + readonly property bool artistBlacklisted: toggleState("artistBlacklist", artist.isBlacklisted) + function toggleBlacklist() { + var aid = artist.id || "" + if (aid === "") + return + // Optimistic flip first (main.rs:12777 `st.set_is_blacklisted(!was)`), + // then the mutation; `blacklistChanged` settles it — or rolls it back + // on a failed write (blacklist_qt.rs `artist_toggle`). + setToggleState("artistBlacklist", !artistBlacklisted) + QbzBlacklist.artistToggle(aid, artist.name || "") + } + readonly property var discoveryRows: { var out = [] var rows = network.discovery || [] @@ -315,6 +337,22 @@ Rectangle { root.setToggleState("artistPin", value) } } + // Blacklist settle / rollback. `blacklistChanged` carries the state the + // write actually produced — flipped on success, UNCHANGED on failure + // (blacklist_qt.rs `artist_toggle`), which is exactly what main.rs:12799 + // does with its rollback branch. Also the cross-surface walk: unblocking + // the same artist from the manager view while this page is mounted moves + // the menu label and drops the banner. Same two-arg `{kind}:{id}` shape as + // the two signals above; its own Connections block only because the signal + // lives on a different singleton. + Connections { + target: QbzBlacklist + function onBlacklistChanged(key, value) { + var aid = (artist && artist.id) ? artist.id : "" + if (aid !== "" && key === "artist:" + aid) + root.setToggleState("artistBlacklist", value) + } + } Connections { target: QbzArtist @@ -1253,6 +1291,95 @@ Rectangle { } } + // --- Hidden-artist banner (ArtistPageView.slint:595-660) ------ + // Only when the CURRENTLY displayed artist is blacklisted. The page + // stays fully navigable — a direct fetch-by-id is never blocked + // (.slint:595-599) — so this is an unblock affordance, not a lock. + // Sits between the header and the body row, exactly where the + // .slint puts it (after the header block at :594, before the body + // row), with the .slint's own 16px spacer (:600). + // Built inline rather than through controls/WarningBanner.qml: that + // control has no action slot, and this banner's whole point is the + // right-hand "Show artist" button. + Item { visible: root.artistBlacklisted; width: 1; height: 16 } + Rectangle { + id: hiddenBanner + visible: root.artistBlacklisted + width: parent.width - 64 + // .slint `height: banner-row.preferred-height` where the row is + // a HorizontalLayout with padding 12 (:602) — so 24 plus the + // tallest child: the 16px glyph, the wrapped copy, or the 28px + // button (:604, :620, :639). + height: visible ? 24 + Math.max(16, bannerCopy.implicitHeight, 28) : 0 + radius: 8 + // LITERALS, not theme tokens: the .slint hardcodes both + // (:596-599). theme.warningBg / warningBorder are a different + // amber (#fbbf24-based) and would not match the reference. + color: "#eab3081a" + border.width: 1 + border.color: "#eab3084d" + + // 16x16 blind-eye (:606-611). The .slint tints it with the + // literal #eab308; QbzIcon.tintName is a CLOSED vocabulary of + // names with no #eab308 bake, so "warning" (theme.warning + // #fbbf24) is the nearest available and the only theme-following + // amber — spec 03 C6's default decision. + QbzIcon { + id: bannerGlyph + name: "blind-eye" + width: 16 + height: 16 + tintName: "warning" + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + } + // "Show artist" button (:634-659): width = label + 20, height + // 28, radius 6, hover fill surface-elevated else transparent, + // label accent -> accentHover on hover, 13 / semibold. + Rectangle { + id: bannerBtn + width: bannerBtnLabel.implicitWidth + 20 + height: 28 + radius: 6 + color: bannerBtnArea.containsMouse ? theme.surfaceElevated : "transparent" + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + Text { + id: bannerBtnLabel + anchors.centerIn: parent + text: QbzSession.tr("Show artist", QbzSession.trRev) + color: bannerBtnArea.containsMouse ? theme.accentHover : theme.accent + font.pixelSize: 13 + font.weight: theme.weightSemibold + } + MouseArea { + id: bannerBtnArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // Same seam as the menu row (:654-657) — one toggle. + onClicked: root.toggleBlacklist() + } + } + // Copy (:619-627): text-secondary, Typography.legal = 13, + // word-wrap. The 10px gaps on both sides are the .slint's + // HorizontalLayout spacing (:603). + Text { + id: bannerCopy + anchors.left: bannerGlyph.right + anchors.leftMargin: 10 + anchors.right: bannerBtn.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("This artist is hidden from discovery", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + } + Item { width: 1; height: 20 } // --- JUMP TO bar --------------------------------------------- @@ -1632,13 +1759,13 @@ Rectangle { readonly property var libTracks: libItems.filter(function (x) { return x.kind === "track" }) Text { - visible: libTracks.length > 0 + visible: libraryTab.libTracks.length > 0 text: QbzSession.tr("Tracks", QbzSession.trRev) color: theme.textPrimary font.pixelSize: theme.fontHeading font.weight: theme.weightSemibold } - Item { visible: libTracks.length > 0; width: 1; height: 10 } + Item { visible: libraryTab.libTracks.length > 0; width: 1; height: 10 } Repeater { model: libraryTab.libTracks delegate: PopularTrackRow { @@ -2168,7 +2295,7 @@ Rectangle { // ArtistPageView.slint:523) — dimmed, no hover, no click, // and the menu keeps its shape. model: [ - { "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "stub", "live": false }, + { "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "disco", "live": true }, { "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "stub", "live": false }, { "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "stub", "live": false }, { "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin", "live": true }, @@ -2206,22 +2333,28 @@ Rectangle { if (modelData.action === "pin") { root.setToggleState("artistPin", !root.toggleState("artistPin", artist.isPinned)) QbzLibrary.togglePin("artist", artist.id, artist.name, "", artist.artUrl) + } else if (modelData.action === "disco") { + // Discography Builder — ArtistPageView.slint + // :505-511 `media-action("artist", id, + // "build-collection")`. This is the ONLY route + // to it; the nav flyout has no builder entry. + QbzDisco.open(artist.id) } } } } } Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } - // Blacklist: the Qt bridge exposes the blacklist COUNTERS but no - // artist-block invokable (same gap AlbumCard.qml documents for - // "Block this album"), so the row is dimmed and inert rather than - // closing the menu and doing nothing. + // ArtistPageView.slint:557-572 — the 1px border-subtle separator + // above, then the LAST item, whose label flips + // "Show artist" / "Blacklist artist" on ArtistState.is-blacklisted. + // LIVE since QbzBlacklist landed (`artistToggle(id, name)`); it was + // dimmed-and-inert only while the bridge had no invokable. Rectangle { width: parent.width height: 33 radius: 5 - opacity: 0.4 - color: "transparent" + color: blkArea.containsMouse ? theme.surfaceHover : "transparent" Row { anchors.fill: parent anchors.leftMargin: 8 @@ -2229,10 +2362,24 @@ Rectangle { QbzIcon { name: "blind-eye"; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" } Text { height: parent.height - text: QbzSession.tr("Blacklist artist", QbzSession.trRev) + width: parent.width - 23 + text: root.artistBlacklisted + ? QbzSession.tr("Show artist", QbzSession.trRev) + : QbzSession.tr("Blacklist artist", QbzSession.trRev) color: theme.textSecondary font.pixelSize: 13 verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: blkArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + overflowMenu.close() + root.toggleBlacklist() } } } diff --git a/crates/qbz-qt/qml/views/BlacklistManagerView.qml b/crates/qbz-qt/qml/views/BlacklistManagerView.qml new file mode 100644 index 000000000..72b98774a --- /dev/null +++ b/crates/qbz-qt/qml/views/BlacklistManagerView.qml @@ -0,0 +1,539 @@ +// Blacklist Manager — QML port of crates/qbz-ui/ui/settings/ +// BlacklistManagerView.slint (1114 lines). A FULL-PAGE content view, not a +// settings subview: the Slint mounts it in AppShell (shell/AppShell.slint:604) +// and it is reached only from the Settings > Blacklist "Manage" button, exactly +// as here (route id "blacklist"). +// +// Three tabs (Artists u64 ids · Albums String ids · Recommendations, the +// reco-scoped "Not interested" dismissals), each with the SAME four +// mutually-exclusive body branches in a load-bearing order: +// 1 loading · 2 FULL count == 0 (empty) · 3 FULL count > 0 but the filtered +// list is empty (no results) · 4 the list. +// Branch 2 vs 3 is the whole reason the document's `count` / `albumCount` / +// `dismissedCount` are the FULL list lengths and never the filtered ones +// (blacklist_manager.rs:72, 104, 148). +// +// Everything the view shows comes out of ONE document, QbzBlacklist +// .blacklistJson — filtering, ordering, the date strings and the has-notes +// flag are all Rust-side (src/blacklist_qt.rs), and QML never filters. The tab +// index and the search query live in Rust too (TAB / QUERY), so leaving and +// re-entering the manager keeps both, like Slint's BlacklistState. +// +// Deltas vs the Slint, each one deliberate and stated: +// - NO per-view Back chrome (the Slint's NavButtons row, :351-354). Nav history +// is the global HeaderBar in this port — the same treatment +// settings/SettingsView.qml:24-25 already documents for the identical case. +// ADR-004 is satisfied by the HeaderBar. +// - `UiFocusState.text-input-focused` (:554) is NOT ported: the port has no +// equivalent guard anywhere and no global keybinding layer to guard (neither +// Main.qml nor AppShell.qml declares a Shortcut). It belongs to the +// port-wide hotkey pass; recorded here as its call site. +// - The list is a recycling ListView, not a Flickable over every row +// (TRACK-RULES §6 — a list that can be large is born windowed), so the +// Slint's `list-hover-rows` scrollbar reveal-on-row-hover is dropped: +// QbzScrollBar reveals on scroll + gutter hover/press instead, and a +// per-row hover counter across a recycling delegate is cost for a cosmetic +// cue. +// - No toast host exists in this port, so the reference's failure toasts have +// nowhere to land. Failures log Rust-side and the settled `blacklistChanged` +// republish corrects any optimistic glyph. +// +// Nav re-entry: nav_qt::back()/forward() republish `currentView` and run NO +// per-view load, so this view calls QbzBlacklist.reload() in +// Component.onCompleted to match Slint's reload-on-apply-entry +// (main.rs:3299-3305). + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../rows" +import "../theme" + +Rectangle { + id: root + + // Transparent while the ambient background is active — the frosted content + // panel shows through (HomeView.qml:53 and its twelve siblings). + color: root.ambientOn ? "transparent" : theme.surfaceMain + readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack + + // Round to the AppShell content-frame bezel (Radius.md): QML clips are + // rectangular, so the frame's rounding never reaches the view — the view's + // own fill must round instead. A literal in all 14 painting view roots. + radius: 12 + + QbzTheme { id: theme } + + // ============================== data ================================== + + readonly property var doc: { + try { + return JSON.parse(QbzBlacklist.blacklistJson) + } catch (e) { + return ({}) + } + } + readonly property int activeTab: root.doc.activeTab || 0 + // `!== false`, NOT `=== true`: BlacklistState.enabled defaults to true + // (state.slint:2489), so reading a `{}` first-frame document the other way + // flashes the amber "filtering is disabled" banner on every mount. + readonly property bool filterEnabled: root.doc.enabled !== false + readonly property string searchQuery: root.doc.searchQuery || "" + + readonly property int artistCount: root.doc.count || 0 + readonly property int albumCount: root.doc.albumCount || 0 + readonly property int dismissedCount: root.doc.dismissedCount || 0 + /// The FULL count for the active tab — what separates "empty" from + /// "no results", and what the count badge and Clear-All gate read. + readonly property int fullCount: root.activeTab === 0 ? root.artistCount + : root.activeTab === 1 ? root.albumCount : root.dismissedCount + + /// The already-filtered rows for the active tab. + readonly property var listRows: root.activeTab === 0 ? (root.doc.artists || []) + : root.activeTab === 1 ? (root.doc.albums || []) : (root.doc.dismissed || []) + + /// The four body branches, evaluated in the reference's order. + readonly property int bodyBranch: QbzBlacklist.blacklistLoading ? 1 + : root.fullCount === 0 ? 2 + : root.listRows.length === 0 ? 3 : 4 + + Component.onCompleted: QbzBlacklist.reload() + + function tabTabs(rev) { + return [ + { "id": "artists", "label": QbzSession.tr("Artists", rev) }, + { "id": "albums", "label": QbzSession.tr("Albums", rev) }, + { "id": "reco", "label": QbzSession.tr("Recommendations", rev) } + ] + } + + // ------------------------- album cover window ------------------------- + // Covers are NEVER in the document: albums[].artPath is the already-cached + // path ("" when unresolved) and the misses are fetched for the VISIBLE + // window only. Arrivals land on the existing QbzLibrary.libraryArtworkReady + // signal keyed by the cover URL and are coalesced into ONE rebind per frame + // — rebinding per arrival is quadratic in the page (the documented + // views/ArtistView.qml:265-288 fix). + property var coverMap: ({}) + property var _coverInbox: ({}) + + Timer { + id: coverFlush + interval: 16 + repeat: false + onTriggered: { + var m = Object.assign({}, root.coverMap, root._coverInbox) + root._coverInbox = ({}) + // A rebind needs a NEW object reference (same-ref assignment is not + // a change in QML). + root.coverMap = m + } + } + Connections { + target: QbzLibrary + function onLibraryArtworkReady(key, path) { + root._coverInbox[key] = path + if (!coverFlush.running) coverFlush.start() + } + } + + Timer { + id: coverWindow + interval: 80 + repeat: false + onTriggered: root.reportCoverWindow() + } + /// Row pitch is 64 + 4 spacing = 68. Computed arithmetically rather than + /// with indexAt(), which is what the port's other windowed lists do + /// (LibraryView.qml:1474) — the rows are a fixed height, so the two agree. + function reportCoverWindow() { + if (root.activeTab !== 1) + return + var m = root.listRows + if (m.length === 0) + return + var first = Math.max(0, Math.floor(rowList.contentY / 68) - 2) + var last = Math.min(m.length - 1, + Math.ceil((rowList.contentY + rowList.height) / 68) + 2) + var urls = [] + for (var i = first; i <= last; i++) { + var u = m[i].coverUrl || "" + if (u !== "" && (m[i].artPath || "") === "" && !root.coverMap[u]) + urls.push(u) + } + if (urls.length === 0) + return + QbzBlacklist.artworkWindow(JSON.stringify(urls)) + } + + // ============================ chrome ================================== + // The Slint outer VerticalLayout is padding 24 / spacing 14 with the body + // at vertical-stretch: 1. QML has no layout here (this port uses none), so + // the fixed head is a Column and the body fills what is left. + + Column { + id: head + x: 24 + y: 24 + width: root.width - 48 + spacing: 14 + + // ---- header (:357-378) ------------------------------------------- + Row { + spacing: 12 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "blind-eye" + width: 24 + height: 24 + tintName: "muted" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Blacklist", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightBold + } + } + + // ---- tab bar (:381-464) — ADR-008, no pills ---------------------- + // The shared control. Its signal carries the tab ID STRING, not an + // index (QbzTabBar.qml:24, emitted :97), so the handler maps it; a + // handler written for an index passes `undefined` to an i32 invokable + // and every click is a runtime error `cargo check` cannot see. + QbzTabBar { + tabs: root.tabTabs(QbzSession.trRev) + activeId: root.activeTab === 1 ? "albums" + : root.activeTab === 2 ? "reco" : "artists" + onSelected: function (id) { + QbzBlacklist.setTab(id === "artists" ? 0 : id === "albums" ? 1 : 2) + } + } + + // ---- description, per tab (:467-477) ----------------------------- + Text { + width: parent.width + text: root.activeTab === 0 + ? QbzSession.tr("Blacklisted artists are hidden from search results, radio, suggestions, and similar artists.", QbzSession.trRev) + : root.activeTab === 1 + ? QbzSession.tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed.", QbzSession.trRev) + : QbzSession.tr("Dismissed artists only leave your Recommendations — they still appear in search and everywhere else.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + + // ---- controls row (:480-646) — spacing 12 ------------------------ + Item { + width: parent.width + height: 34 + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + + // (a) Enable/Disable toggle (:485-522). The ONLY enable toggle + // in the feature — the Settings row deliberately has none + // (ContentFilteringSettings.slint:13-16). + Rectangle { + width: toggleRow.implicitWidth + 28 + height: 34 + radius: theme.radiusSm + color: toggleArea.containsMouse ? theme.surfaceHover + : theme.surfaceElevated + Row { + id: toggleRow + x: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: root.filterEnabled ? "eye" : "eye-off" + width: 18 + height: 18 + tintName: root.filterEnabled ? "accent" : "muted" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.filterEnabled + ? QbzSession.tr("Enabled", QbzSession.trRev) + : QbzSession.tr("Disabled", QbzSession.trRev) + color: root.filterEnabled ? theme.accent : theme.textMuted + font.pixelSize: theme.fontLegal + } + } + MouseArea { + id: toggleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBlacklist.toggleEnabled() + } + } + + // (b) Search (:525-587) — search-as-you-type, NO debounce: + // every edit re-pushes the Rust-filtered rows. The shared + // control's search arm; the width override replaces its + // 240 default. + // + // A PLAIN BINDING on `text`, which is also what re-seeds the box + // on re-entry (the Rust QUERY survives navigation, so the + // last-published document already carries it and the binding + // shows it before `reload()` lands). This used to be an + // imperative `syncSearchField()` because `clearSearch()` wrote + // `root.text = ""` and destroyed exactly this binding on the + // first click of the ×; the control no longer touches `text` at + // all (QbzLineEdit.qml:66-92), so the workaround is gone. Its + // focus-loss re-seed Binding is what keeps the field from + // fighting the per-keystroke republish. + QbzLineEdit { + id: searchField + anchors.verticalCenter: parent.verticalCenter + searchMode: true + width: 280 + placeholder: "" + text: root.searchQuery + onEdited: function (v) { QbzBlacklist.searchChanged(v) } + } + + // (c) Clear All (:591-627) — only when the ACTIVE tab's FULL + // count > 0, and never on tab 2 (the Recommendations tab has no + // bulk clear; undo is per row). It opens the confirm, it does + // NOT mutate. + // + // controls/SettingsButton.qml was considered and rejected: its + // `width: Math.max(160, …)` floor against the Slint's + // content + 28 (~105px for "Clear All" + a 16px glyph) would + // put a 160px pill next to a 34px toggle, and it always draws a + // 1px border and hovers to surfaceHover where this button is + // borderless and hovers to dangerBg. + Rectangle { + visible: root.activeTab === 0 ? root.artistCount > 0 + : root.activeTab === 1 ? root.albumCount > 0 : false + width: clearRow.implicitWidth + 28 + height: 34 + radius: theme.radiusSm + color: clearArea.containsMouse ? theme.dangerBg + : theme.surfaceElevated + Row { + id: clearRow + x: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "trash-2" + width: 16 + height: 16 + // NOT "danger" — no such tint exists; `favorite` + // (#ef4444) is the port-wide substitution and + // favorite/trash-2.svg is on the qrc floor. + tintName: "favorite" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Clear All", QbzSession.trRev) + color: theme.danger + font.pixelSize: theme.fontLegal + } + } + MouseArea { + id: clearArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: clearConfirm.open() + } + } + } + + // (d) count badge (:629-645) — the FULL count, right-aligned. Tab 2 + // says "artists" too. + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.activeTab === 1 + ? QbzSession.tr("{} albums", QbzSession.trRev).replace("{}", root.albumCount) + : QbzSession.tr("{} artists", QbzSession.trRev) + .replace("{}", root.activeTab === 0 ? root.artistCount + : root.dismissedCount) + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignRight + } + } + + // ---- disabled warning banner (:649-678) -------------------------- + // The shared control, variant "warning" (its default). The copy goes in + // `title` because that is the TONE-coloured line (WarningBanner.qml:58); + // `body` is textSecondary, which is wrong for this banner. Accepted + // control deltas: radius 8 vs Radius.md 12, glyph `info` vs + // `circle-alert` (which exists in NO tint dir and is not in MASTERS — + // the substitution WarningBanner.qml:5-8 already documents), semibold + // vs regular, and 10%/30% tone alphas vs warning-bg/warning-border's + // ~15%/35%. + WarningBanner { + visible: !root.filterEnabled + variant: "warning" + title: QbzSession.tr("Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it.", QbzSession.trRev) + } + } + + // ============================== body ================================== + Item { + id: bodyHost + x: 24 + y: head.y + head.height + 14 + width: root.width - 48 + height: Math.max(0, root.height - y - 24) + + // 1) Loading. The Slint stacks the spinner and the label with no + // spacing (two centred HorizontalLayouts in a VerticalLayout). + Column { + visible: root.bodyBranch === 1 + anchors.centerIn: parent + spacing: 0 + QbzSpinner { + anchors.horizontalCenter: parent.horizontalCenter + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + // U+2026, not three dots — an exact-byte msgid. + text: QbzSession.tr("Loading blacklist…", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + } + + // 2) Empty, per tab (:702-732, :801-831, :898-928). The shared control + // with its four optional metric props set to the Slint's numbers: + // 48px glyph at 0.3 opacity, a MUTED regular-weight title. + QbzEmptyState { + visible: root.bodyBranch === 2 + anchors.centerIn: parent + iconName: root.activeTab === 2 ? "thumbs-down" : "blind-eye" + iconSize: 48 + iconOpacity: 0.3 + titleMuted: true + titleWeight: theme.weightRegular + title: root.activeTab === 0 + ? QbzSession.tr("No blacklisted artists", QbzSession.trRev) + : root.activeTab === 1 + ? QbzSession.tr("No blocked albums", QbzSession.trRev) + : QbzSession.tr("No dismissed artists", QbzSession.trRev) + body: root.activeTab === 0 + ? QbzSession.tr("To hide an artist, go to their page and use the menu.", QbzSession.trRev) + : root.activeTab === 1 + ? QbzSession.tr("To block an album, open its menu and choose Block this album.", QbzSession.trRev) + : QbzSession.tr("To stop seeing an artist in Recommendations, open its card menu and choose Not interested.", QbzSession.trRev) + } + + // 3) No results — identical on all three tabs, and no body line (the + // control hides `body` when it is ""). + QbzEmptyState { + visible: root.bodyBranch === 3 + anchors.centerIn: parent + iconName: "search" + iconSize: 48 + iconOpacity: 0.3 + titleMuted: true + titleWeight: theme.weightRegular + title: QbzSession.tr("No results for \"{}\"", QbzSession.trRev) + .replace("{}", root.searchQuery) + } + + // 4) The list. THREE-edge anchors + an explicit width, never + // `anchors.fill` + `width` — the two are mutually exclusive, the anchor + // wins, the Slint's 18px scrollbar reserve silently never applies AND + // Qt logs a `.qml:` warning the gate greps for. + ListView { + id: rowList + visible: root.bodyBranch === 4 + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width - 18 + model: root.listRows + spacing: 4 + clip: true + // ~2 rows of pre-instantiation past the viewport. + cacheBuffer: 128 + boundsBehavior: Flickable.StopAtBounds + + onContentYChanged: coverWindow.restart() + onModelChanged: coverWindow.restart() + // The window is computed from contentY + height, and `height` is 0 + // until the Loader has sized the view — without this the first + // dispatch after a mount asks for 3 rows instead of a screenful. + onHeightChanged: coverWindow.restart() + Component.onCompleted: coverWindow.restart() + + delegate: BlacklistRow { + required property var modelData + width: rowList.width + arm: root.activeTab === 0 ? "artist" + : root.activeTab === 1 ? "album" : "dismissed" + row: modelData + coverSource: root.activeTab === 1 + ? (root.coverMap[modelData.coverUrl] || modelData.artPath || "") + : "" + onSelectRequested: { + if (root.activeTab === 1) + QbzBlacklist.openAlbum(modelData.id) + else + QbzBlacklist.openArtist(modelData.id) + } + onRemoveRequested: { + if (root.activeTab === 0) + QbzBlacklist.removeArtist(modelData.id) + else if (root.activeTab === 1) + QbzBlacklist.removeAlbum(modelData.id) + else + QbzBlacklist.removeDismissed(modelData.id) + } + } + } + // The ListScrollbar replica — 14px gutter, inset 4 from the pane's + // right edge, which is exactly the 18px the list gave back. + QbzScrollBar { + // The control's own rule is `visible: maxScroll > 0` (its auto-hide, + // = the Slint ListScrollbar's `auto-hide: true`). Assigning `visible` + // here REPLACES that binding, so the overflow test has to be carried + // over or a short list gets a full-height thumb on gutter hover + // (Sidebar.qml:383 combines the two the same way). + visible: root.bodyBranch === 4 + && rowList.contentHeight > rowList.height + target: rowList + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + + // ======================== Clear-All confirm ============================ + // Declared LAST: declaration order IS z-order, and it also carries z: 3100 + // (ADR-009's floor is 3000; 3100 is the layer the port's other in-pane + // modal scrim uses). Cancel / Esc / a backdrop click all dismiss WITHOUT + // mutating. + QbzConfirmModal { + id: clearConfirm + anchors.fill: parent + title: QbzSession.tr("Clear Blacklist?", QbzSession.trRev) + body: root.activeTab === 0 + ? QbzSession.tr("This will remove all {} blacklisted artists. This cannot be undone.", QbzSession.trRev) + .replace("{}", root.artistCount) + : QbzSession.tr("This will remove all {} blocked albums. This cannot be undone.", QbzSession.trRev) + .replace("{}", root.albumCount) + confirmLabel: QbzSession.tr("Clear All", QbzSession.trRev) + danger: true + onConfirmed: { + if (root.activeTab === 0) + QbzBlacklist.clearAllArtists() + else + QbzBlacklist.clearAllAlbums() + } + } +} diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index eac8f1fda..8e030ef9e 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -142,6 +142,14 @@ pub struct AlbumHeader { pub is_favorite: bool, #[serde(rename = "isPinned")] pub is_pinned: bool, + /// Blocked state at build time, seeded from the per-user album blacklist + /// (1:1 with `qbz/src/album.rs:683`, which seeds it right after the heart). + /// `AlbumView.qml`'s header menu folds this through `toggleState("blocked", + /// ...)`, so without it an ALREADY-BLOCKED album offers "Block this album" + /// and the first click UN-blocks — the same lie the pin glyph told before + /// `isPinned` existed. + #[serde(rename = "isAlbumBlocked")] + pub is_album_blocked: bool, /// EXTERNAL LINKS sidebar block — deep links into the three music /// databases, built from artist + title. These are plain URLs handed to /// the system browser on click: nothing is fetched, no account is needed @@ -556,6 +564,7 @@ pub async fn load_album(runtime: &Arc>, album_id: &st // and the toggle then read the same false and re-added it. is_favorite: crate::library_qt::is_favorite("album", &album.id), is_pinned: crate::sidebar_qt::is_pinned("album", &album.id), + is_album_blocked: crate::artist_blacklist::is_album_blacklisted(&album.id), id: album.id, title, artist, diff --git a/crates/qbz-qt/src/artist_blacklist.rs b/crates/qbz-qt/src/artist_blacklist.rs new file mode 100644 index 000000000..5a06082c1 --- /dev/null +++ b/crates/qbz-qt/src/artist_blacklist.rs @@ -0,0 +1,391 @@ +//! Per-user artist-blacklist lifecycle + access wrapper — Slint-free port of +//! `crates/qbz/src/artist_blacklist.rs` (1:1; only the log tag changed). +//! +//! A process-global singleton over the headless +//! `qbz_app::settings::artist_blacklist::BlacklistService` (ADR-006: all model +//! logic — schema, O(1) lookup set, enable flag, mutations — lives in +//! `qbz-app`; this module only owns the per-user store lifecycle and the thin +//! accessors the Qt surfaces call). The shared service is consumed AS-IS. +//! +//! Lifecycle mirrors `fav_cache_qt`: a process-global `Mutex>` +//! bound per session via [`init_for_user`] / [`teardown`], next to the other +//! per-user stores. The service keeps its own in-memory `HashSet` + enabled +//! flag, so reads never round-trip SQLite; there is no separate cache here and +//! — matching `fav_cache_qt` — no change-notify mechanism: callers re-read +//! after mutating (`blacklist_qt::publish` re-pushes the document, and the +//! `blacklistChanged` signal settles the optimistic glyphs). +//! +//! This REPLACES the per-publish throwaway service that +//! `settings_qt::devtools::blacklist_counts` used to open (a fresh SQLite open +//! + schema init + two set loads on the Qt thread, per settings publish, and +//! unusable for mutations because every other reader in the process stayed +//! stale). +//! +//! Fail-open everywhere: with no session bound (`None`), checks behave as "not +//! blacklisted" / "enabled", snapshots are empty, and mutations return the exact +//! Tauri error string so the UI shows the same message. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::Mutex; + +use qbz_app::settings::artist_blacklist::{ + BlacklistService, BlacklistedAlbum, BlacklistedArtist, DB_FILE_NAME, +}; + +/// Per-user blacklist service. `None` outside an active session (online or +/// offline); pure fail-open behavior in that window. +static SERVICE: Mutex> = Mutex::new(None); + +/// The exact error string the Tauri build returns for a mutation attempted +/// with no active session. Kept verbatim so the UI surfaces the same message. +const NO_SESSION_ERR: &str = "No active session - please log in"; + +// --------------------------------------------------------------------------- +// Lifecycle (mirrors fav_cache_qt::{init_for_user, teardown}) +// --------------------------------------------------------------------------- + +/// Bind the per-user store from `/artist_blacklist.db`. Called on every +/// session activation — login, restore, AND offline entry — next to +/// `fav_cache_qt::init_for_user`. Best-effort: a store-open failure logs and +/// leaves the singleton `None` (fail-open: nothing is blacklisted, the feature +/// reads as enabled, never blocks entry). The offline binding is NOT optional: +/// it is the fix for the Tauri gap where the blacklist was never initialized in +/// offline mode and blacklisted artists leaked into the offline surfaces. +pub fn init_for_user(base_dir: &Path) { + let db_path = base_dir.join(DB_FILE_NAME); + match BlacklistService::new(&db_path) { + Ok(service) => { + if let Ok(mut guard) = SERVICE.lock() { + *guard = Some(service); + } + } + Err(e) => log::error!("[qbz-qt] artist blacklist store open failed: {e}"), + } +} + +/// Drop the per-user store on logout. Mirrors `fav_cache_qt::teardown` — the +/// next account must not inherit this one's blacklist. +pub fn teardown() { + if let Ok(mut guard) = SERVICE.lock() { + *guard = None; + } +} + +// --------------------------------------------------------------------------- +// Accessors (fail-open when no session is bound) +// --------------------------------------------------------------------------- + +/// Run a closure against the bound service, or `default` when there is none / +/// the lock is poisoned. +fn with_service(default: T, f: impl FnOnce(&BlacklistService) -> T) -> T { + SERVICE + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(f)) + .unwrap_or(default) +} + +/// True when the artist id is blacklisted (and the feature is enabled). +/// Fail-open `false` when no session is bound. +pub fn is_blacklisted(artist_id: u64) -> bool { + with_service(false, |s| s.is_blacklisted(artist_id)) +} + +/// True when the string-form artist id parses and is blacklisted. Non-numeric +/// ids (local artists) are never blacklisted. For row code that carries string +/// ids — which, in this port, is every id that crosses the bridge. +pub fn is_blacklisted_id_str(artist_id: &str) -> bool { + let Ok(id) = artist_id.parse::() else { + return false; + }; + is_blacklisted(id) +} + +/// True when the album id is blocked (and the feature is enabled). Album ids +/// are alphanumeric strings; an empty id never matches. Fail-open `false` when +/// no session is bound. Orthogonal to artist blocking — an album is hidden by +/// its OWN id regardless of its artist. +pub fn is_album_blacklisted(album_id: &str) -> bool { + if album_id.is_empty() { + return false; + } + with_service(false, |s| s.is_album_blacklisted(album_id)) +} + +/// Card-grid predicate: `true` when an album-card-shaped row (string album id + +/// string primary-artist id) should be hidden from a grid/carousel. Honors the +/// enabled gate + no-session fail-open via the underlying checks. Album axis +/// (own id) OR artist axis (primary-artist id). Use for the read-only album +/// grids that map an `AlbumCard`-like row rather than a typed `Album`. +// Consumed by the data-layer filter slice (spec 03 §9.2, items F5/F9/F11/F14), +// which lands separately from the manager. +#[allow(dead_code)] +pub fn card_blacklisted(album_id: &str, artist_id: &str) -> bool { + is_album_blacklisted(album_id) || is_blacklisted_id_str(artist_id) +} + +/// Stamp value for a track row's `isBlacklisted` cell. The single rule every +/// track controller (album / playlist / favorites / the four Q-mixes) reuses so +/// render and the queue filters agree on what "blacklisted" means per row: +/// +/// - **HARD local/Plex guard** — a non-Qobuz `source` is NEVER blacklisted +/// (local copies with a numeric Qobuz id must still stay playable). +/// `qobuz_download` rows render `source == "qobuz"`, so they are treated as +/// Qobuz here — that matches Tauri (VTL keys on `!isLocal`). +/// - Resolve the artist from the candidate string ids in order; the first +/// non-empty, numeric, blacklisted id wins (performer OR composer; album rows +/// that lack a performer fall back to the album's primary artist). +/// - Missing / zero / non-numeric ids => fail-open (`false`). +/// +/// The enabled-flag gate and the no-session fail-open live in +/// [`is_blacklisted`], so this never blocks when the feature is off or no +/// session is bound. +/// +/// Live re-stamp contract: there is no change-notify here (the fav_cache +/// pattern). Every controller calls this at LOAD time, so navigating to a view +/// always shows correct state; to refresh an ALREADY-loaded list after a +/// mutation, the mutation site re-runs that controller's existing reload path. +/// There is intentionally no global listener/observer. +// Consumed by the data-layer filter slice (spec 03 §9.2, items F7/F12/F15-F18). +#[allow(dead_code)] +pub fn stamp_row(source: &str, artist_ids: &[&str], album_id: Option<&str>) -> bool { + // Local / Plex / ephemeral rows are protected — never blacklisted. + if source != "qobuz" { + return false; + } + // Album axis (orthogonal): the row's own album id being blocked drops it + // regardless of artist. Then the artist axis (performer/composer/primary). + if album_id.is_some_and(is_album_blacklisted) { + return true; + } + artist_ids.iter().any(|id| is_blacklisted_id_str(id)) +} + +/// THE single queue/playback predicate. Returns `true` when this track should +/// be DROPPED from any play / shuffle / queue-next / queue-later / radio +/// builder. Implemented in terms of the exact same source-guard + per-id check +/// as [`stamp_row`] so the queue filter and the row greyout can NEVER diverge: +/// a row that greys out is the row that drops from the queue, and vice versa. +/// +/// - **HARD local/Plex guard** — delegates to [`stamp_row`]'s guard by passing +/// `source` through. +/// - Performer OR composer — pass both numeric ids; either one being +/// blacklisted drops the track. +/// - Missing / `None` ids => fail-open (`false`), so id-less tracks always play. +// Consumed by the data-layer filter slice (spec 03 §9.2, item F18 — playback). +#[allow(dead_code)] +pub fn is_track_blacklisted( + source: &str, + performer_id: Option, + composer_id: Option, + album_id: Option<&str>, +) -> bool { + // Reuse stamp_row's guard + checks by funneling the numeric ids through the + // same string path — the SINGLE underlying predicate shared with rendering. + // The album id (a blocked album hides all its tracks) is passed straight + // through, so render greyout and queue-drop can never diverge. + let performer = performer_id.map(|id| id.to_string()).unwrap_or_default(); + let composer = composer_id.map(|id| id.to_string()).unwrap_or_default(); + stamp_row(source, &[performer.as_str(), composer.as_str()], album_id) +} + +/// True when the blacklist feature is enabled. Default-enabled (`true`) when no +/// session is bound — the one read that fail-opens to `true`. +pub fn is_enabled() -> bool { + with_service(true, |s| s.is_enabled()) +} + +/// Snapshot of the full blacklisted-id set, for `qbz_core::search_all`-style +/// filtering. Empty when no session is bound. Derived from `get_all` so it +/// reflects the persisted rows (ignores the enabled flag — callers gate on +/// [`is_enabled`] separately). +// Consumed by the data-layer filter slice (spec 03 §9.2, items F1/F3/F6/F8...). +#[allow(dead_code)] +pub fn ids_snapshot() -> HashSet { + with_service(HashSet::new(), |s| { + s.get_all() + .map(|list| list.into_iter().map(|a| a.artist_id).collect()) + .unwrap_or_default() + }) +} + +/// All blacklisted artists (name-sorted, `COLLATE NOCASE`), for the manager +/// view. Empty on no session or query error. +pub fn get_all() -> Vec { + with_service(Vec::new(), |s| s.get_all().unwrap_or_default()) +} + +/// Count of blacklisted artists (ignores the enabled flag). `0` when no session +/// is bound. +pub fn count() -> usize { + with_service(0, |s| s.count()) +} + +/// Snapshot of the full blocked-album-id set, for `qbz_core` album/track +/// filtering. Empty when no session is bound. Reflects persisted rows (ignores +/// the enabled flag — callers gate on [`is_enabled`] separately). +// Consumed by the data-layer filter slice (spec 03 §9.2, items F1/F3/F6/F8...). +#[allow(dead_code)] +pub fn album_ids_snapshot() -> HashSet { + with_service(HashSet::new(), |s| { + s.get_all_albums() + .map(|list| list.into_iter().map(|a| a.album_id).collect()) + .unwrap_or_default() + }) +} + +/// All blocked albums (title-sorted, `COLLATE NOCASE`), for the manager view. +/// Empty on no session or query error. +pub fn get_all_albums() -> Vec { + with_service(Vec::new(), |s| s.get_all_albums().unwrap_or_default()) +} + +/// Count of blocked albums (ignores the enabled flag). `0` when no session is +/// bound. +pub fn album_count() -> usize { + with_service(0, |s| s.album_count()) +} + +// --------------------------------------------------------------------------- +// Mutations (Err with the Tauri "no active session" string when unbound) +// --------------------------------------------------------------------------- + +/// Run a mutation against the bound service, returning the Tauri "no active +/// session" error string when there is none / the lock is poisoned. +fn mutate(f: impl FnOnce(&BlacklistService) -> Result<(), String>) -> Result<(), String> { + match SERVICE.lock() { + Ok(guard) => match guard.as_ref() { + Some(service) => f(service), + None => Err(NO_SESSION_ERR.into()), + }, + Err(_) => Err(NO_SESSION_ERR.into()), + } +} + +/// Add an artist to the blacklist. +pub fn add(artist_id: u64, artist_name: &str, notes: Option<&str>) -> Result<(), String> { + mutate(|s| s.add(artist_id, artist_name, notes)) +} + +/// Remove an artist from the blacklist. +pub fn remove(artist_id: u64) -> Result<(), String> { + mutate(|s| s.remove(artist_id)) +} + +/// Toggle the global enable flag. +pub fn set_enabled(enabled: bool) -> Result<(), String> { + mutate(|s| s.set_enabled(enabled)) +} + +/// Clear all blacklisted artists (leaves the enabled flag + albums untouched). +pub fn clear_all() -> Result<(), String> { + mutate(|s| s.clear_all()) +} + +/// Add an album to the blacklist. +pub fn add_album( + album_id: &str, + album_title: &str, + artist_name: &str, + cover_url: &str, + notes: Option<&str>, +) -> Result<(), String> { + mutate(|s| s.add_album(album_id, album_title, artist_name, cover_url, notes)) +} + +/// Remove an album from the blacklist. +pub fn remove_album(album_id: &str) -> Result<(), String> { + mutate(|s| s.remove_album(album_id)) +} + +/// Clear all blocked albums (leaves the enabled flag + artists untouched). +pub fn clear_all_albums() -> Result<(), String> { + mutate(|s| s.clear_all_albums()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unique temp dir under the system temp root (no `tempfile` dev-dep on + /// qbz-qt). 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-qt-blacklist-test-{nanos}")); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + /// One combined test: the singleton is process-global, so splitting into + /// parallel tests would let them clobber each other. Covers the full + /// round-trip: empty snapshot + default-enabled after init, add reflected in + /// both check + snapshot, then teardown restores the fail-open 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!(is_enabled(), "blacklist defaults to enabled"); + assert!(!is_blacklisted(42), "nothing blacklisted yet"); + + add(42, "X", None).expect("add succeeds with a bound store"); + assert!(is_blacklisted(42), "added id is blacklisted"); + assert!(is_blacklisted_id_str("42"), "string-id check matches"); + assert!(!is_blacklisted_id_str("not-a-number"), "local ids never match"); + assert!(ids_snapshot().contains(&42), "snapshot contains the added id"); + assert_eq!(count(), 1); + + // Album axis: orthogonal, String-keyed, shares the enabled flag. + assert!(!is_album_blacklisted("zzz"), "nothing album-blocked yet"); + add_album("zzz", "Bogus", "X", "", None).expect("add_album succeeds"); + assert!(is_album_blacklisted("zzz"), "added album id is blocked"); + assert!(!is_album_blacklisted(""), "empty album id never matches"); + assert!( + album_ids_snapshot().contains("zzz"), + "album snapshot contains the added id" + ); + assert_eq!(album_count(), 1); + // A blocked album drops via the shared stamp predicate, artist-independent. + assert!(stamp_row("qobuz", &[], Some("zzz")), "album-blocked row drops"); + assert!( + !stamp_row("local", &[], Some("zzz")), + "non-qobuz row is protected even when album-blocked" + ); + // The queue predicate funnels through the same guard. + assert!( + is_track_blacklisted("qobuz", Some(42), None, None), + "performer-blacklisted track drops" + ); + assert!( + !is_track_blacklisted("local", Some(42), None, None), + "non-qobuz track is protected" + ); + assert!(card_blacklisted("zzz", "1"), "card drops on the album axis"); + assert!(card_blacklisted("aaa", "42"), "card drops on the artist axis"); + assert_eq!(count(), 1, "album add did not touch the artist count"); + + teardown(); + assert!(!is_blacklisted(42), "fail-open after teardown"); + assert!(ids_snapshot().is_empty(), "empty snapshot after teardown"); + assert!(!is_album_blacklisted("zzz"), "album fail-open after teardown"); + assert!(album_ids_snapshot().is_empty(), "empty album snapshot"); + assert_eq!(count(), 0); + assert_eq!(album_count(), 0); + assert!(is_enabled(), "default-enabled after teardown"); + assert!( + add(1, "Y", None).is_err(), + "mutation with no session returns the Tauri error string" + ); + assert!( + add_album("a", "b", "c", "", None).is_err(), + "album mutation with no session returns the error string" + ); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/qbz-qt/src/artist_qt.rs b/crates/qbz-qt/src/artist_qt.rs index 5e89db167..e38aa5b0d 100644 --- a/crates/qbz-qt/src/artist_qt.rs +++ b/crates/qbz-qt/src/artist_qt.rs @@ -195,6 +195,14 @@ pub struct ArtistViewData { pub is_following: bool, #[serde(rename = "isPinned")] pub is_pinned: bool, + /// Blacklisted state at build time, seeded from the per-user artist + /// blacklist (1:1 with `qbz/src/main.rs:2653-2659`, which seeds it beside + /// the pin state before the view flips). `ArtistView.qml` folds it through + /// `toggleState("artistBlacklist", ...)` to choose between "Blacklist + /// artist" and "Show artist" and to show the hidden banner. Reads false + /// when the feature is disabled — the reference accepts that here too. + #[serde(rename = "isBlacklisted")] + pub is_blacklisted: bool, #[serde(rename = "libraryCount")] pub library_count: i64, #[serde(rename = "topTracks")] @@ -541,6 +549,7 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData { artwork_url, is_following, is_pinned: crate::sidebar_qt::is_pinned("artist", &page.id.to_string()), + is_blacklisted: crate::artist_blacklist::is_blacklisted(page.id), library_count, top_tracks, appears_on, diff --git a/crates/qbz-qt/src/blacklist_bridge.rs b/crates/qbz-qt/src/blacklist_bridge.rs new file mode 100644 index 000000000..63e668540 --- /dev/null +++ b/crates/qbz-qt/src/blacklist_bridge.rs @@ -0,0 +1,278 @@ +//! QbzBlacklist — Blacklist domain bridge (phase-23 per-domain pattern; the +//! pattern is documented in main.rs). +//! +//! The module is `qbz_blacklist_bridge`, not `qbz_blacklist`: bridge modules are +//! never named after a workspace crate (the `qbz_library` collision is the +//! documented precedent). The QML type name comes from the QObject +//! (`QbzBlacklist`), not from the module, so QML is unaffected. +//! +//! Props: the ONE manager document (all three axes) + its loading flag. +//! Invokables: the manager's own actions plus the three consumer-surface entry +//! points (`artistToggle`, `albumToggle`, `blockAlbum`, `dismissArtist`) — the +//! blacklist is mutated from album cards, album/artist headers and artist cards, +//! not only from the manager, and a domain owns its own mutations. +//! Signal: `blacklistChanged(key, value)` — the cross-surface settle channel, +//! same two-arg `"{kind}:{id}"` shape as `QbzLibrary.pinChanged` / +//! `libraryFavoriteChanged`. +//! +//! Every invokable body is a one-line forward into `crate::blacklist_qt`. + +use std::pin::Pin; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_blacklist_bridge { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + // ONE JSON document (blacklist_qt.rs BlacklistDoc: activeTab + enabled + // + searchQuery + the three axes with their FULL counts); QML + // JSON.parse()s it once. + #[qproperty(QString, blacklist_json)] + // Defaults to TRUE, unlike every other *_loading in the tree: the + // Loader mounts this view BEFORE Component.onCompleted fires reload(), + // so a `false` default would flash the empty state ("No blacklisted + // artists") before the spinner. Slint's BlacklistState.loading has the + // same `: true` default for the same reason. + #[qproperty(bool, blacklist_loading)] + + type QbzBlacklist = super::QbzBlacklistRust; + + /// Registers this object's Qt-thread hop (Main.qml boots EVERY domain + /// singleton; only QbzSession.boot also fires crate::on_boot). + #[qinvokable] + fn boot(self: Pin<&mut QbzBlacklist>); + + /// Settings > Blacklist > Manage: push the "blacklist" route and load. + #[qinvokable] + fn open_manager(self: Pin<&mut QbzBlacklist>); + /// Re-read the store and re-publish. The view calls this from + /// Component.onCompleted because nav back/forward runs no per-view load. + #[qinvokable] + fn reload(self: Pin<&mut QbzBlacklist>); + /// 0 = Artists, 1 = Albums, 2 = Recommendations. + #[qinvokable] + fn set_tab(self: Pin<&mut QbzBlacklist>, tab: i32); + /// Search-as-you-type; no debounce, matching the reference. + #[qinvokable] + fn search_changed(self: Pin<&mut QbzBlacklist>, query: QString); + /// Flip the global enable flag (the counts are unaffected by it). + #[qinvokable] + fn toggle_enabled(self: Pin<&mut QbzBlacklist>); + + /// Artists tab row `x` — un-blacklist, optimistic, no confirm. + #[qinvokable] + fn remove_artist(self: Pin<&mut QbzBlacklist>, artist_id: QString); + /// Artists tab Clear All (leaves the enabled flag + the album axis). + #[qinvokable] + fn clear_all_artists(self: Pin<&mut QbzBlacklist>); + /// Artists tab row body click — open the artist page. + #[qinvokable] + fn open_artist(self: Pin<&mut QbzBlacklist>, artist_id: QString); + + /// Albums tab row `x` — unblock, optimistic, no confirm. + #[qinvokable] + fn remove_album(self: Pin<&mut QbzBlacklist>, album_id: QString); + /// Albums tab Clear All (leaves the enabled flag + the artist axis). + #[qinvokable] + fn clear_all_albums(self: Pin<&mut QbzBlacklist>); + /// Albums tab row body click — open the album page. + #[qinvokable] + fn open_album(self: Pin<&mut QbzBlacklist>, album_id: QString); + + /// Album card / list row ⋯ menu "Block this album" (one-way). The + /// display fields are persisted with the row, so the manager can render + /// it without a fetch — `cover_url` must be the REMOTE url, never a + /// `file://` cache path. + #[qinvokable] + fn block_album( + self: Pin<&mut QbzBlacklist>, + album_id: QString, + title: QString, + artist: QString, + cover_url: QString, + ); + /// Album page header ⋯ menu Block <-> Unblock; settles on + /// `blacklistChanged("album:" + id, blocked)`. + #[qinvokable] + fn album_toggle( + self: Pin<&mut QbzBlacklist>, + album_id: QString, + title: QString, + artist: QString, + cover_url: QString, + ); + /// Artist page ⋯ menu Blacklist <-> Show; settles on + /// `blacklistChanged("artist:" + id, blacklisted)`. + #[qinvokable] + fn artist_toggle(self: Pin<&mut QbzBlacklist>, artist_id: QString, artist_name: QString); + + /// Artist card ⋯ menu "Not interested" — reco-scoped dismissal, NOT the + /// blacklist. THREE args: the store only backfills an EMPTY name/image, + /// so dropping the image url would poison the row permanently. + #[qinvokable] + fn dismiss_artist( + self: Pin<&mut QbzBlacklist>, + artist_id: QString, + artist_name: QString, + image_url: QString, + ); + /// Recommendations tab row `x` — undo one dismissal. + #[qinvokable] + fn remove_dismissed(self: Pin<&mut QbzBlacklist>, artist_id: QString); + + /// Albums tab cover window: a JSON array of the cover urls currently + /// visible. Arrivals land on `QbzLibrary.libraryArtworkReady(url, path)`. + #[qinvokable] + fn artwork_window(self: Pin<&mut QbzBlacklist>, urls_json: QString); + + /// Cross-surface settle after any single-item mutation. `key` is the + /// composite `"{kind}:{id}"` (`"artist:1234567"` / `"album:0060254798934"`) + /// and the value is the SETTLED state — flipped on success, unchanged on + /// failure, so this is also the rollback channel. + #[qsignal] + fn blacklist_changed(self: Pin<&mut QbzBlacklist>, key: QString, value: bool); + } + + impl cxx_qt::Threading for QbzBlacklist {} +} + +use qbz_blacklist_bridge::QbzBlacklist; + +/// Rust side of the blacklist bridge (plain storage, phase-1 pattern). +pub struct QbzBlacklistRust { + blacklist_json: QString, + blacklist_loading: bool, +} + +impl Default for QbzBlacklistRust { + fn default() -> Self { + Self { + // Never QString::default() for a document QML JSON.parse()s. + blacklist_json: QString::from("{}"), + // See the #[qproperty] comment: `true` is load-bearing. + blacklist_loading: true, + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop (bridges/mod.rs pattern) +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue a blacklist-bridge mutation onto the Qt event loop (no-op before +/// boot registers the thread). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzBlacklist>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +impl qbz_blacklist_bridge::QbzBlacklist { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] blacklist Qt thread already registered"); + } + } + + pub fn open_manager(self: Pin<&mut Self>) { + crate::blacklist_qt::open_manager(); + } + pub fn reload(self: Pin<&mut Self>) { + crate::blacklist_qt::load(); + } + pub fn set_tab(self: Pin<&mut Self>, tab: i32) { + crate::blacklist_qt::set_tab(tab); + } + pub fn search_changed(self: Pin<&mut Self>, query: QString) { + crate::blacklist_qt::search_changed(query.to_string()); + } + pub fn toggle_enabled(self: Pin<&mut Self>) { + crate::blacklist_qt::toggle_enabled(); + } + + pub fn remove_artist(self: Pin<&mut Self>, artist_id: QString) { + crate::blacklist_qt::remove_artist(artist_id.to_string()); + } + pub fn clear_all_artists(self: Pin<&mut Self>) { + crate::blacklist_qt::clear_all_artists(); + } + pub fn open_artist(self: Pin<&mut Self>, artist_id: QString) { + crate::blacklist_qt::open_artist(artist_id.to_string()); + } + + pub fn remove_album(self: Pin<&mut Self>, album_id: QString) { + crate::blacklist_qt::remove_album(album_id.to_string()); + } + pub fn clear_all_albums(self: Pin<&mut Self>) { + crate::blacklist_qt::clear_all_albums(); + } + pub fn open_album(self: Pin<&mut Self>, album_id: QString) { + crate::blacklist_qt::open_album(album_id.to_string()); + } + + pub fn block_album( + self: Pin<&mut Self>, + album_id: QString, + title: QString, + artist: QString, + cover_url: QString, + ) { + crate::blacklist_qt::block_album( + album_id.to_string(), + title.to_string(), + artist.to_string(), + cover_url.to_string(), + ); + } + pub fn album_toggle( + self: Pin<&mut Self>, + album_id: QString, + title: QString, + artist: QString, + cover_url: QString, + ) { + crate::blacklist_qt::album_toggle( + album_id.to_string(), + title.to_string(), + artist.to_string(), + cover_url.to_string(), + ); + } + pub fn artist_toggle(self: Pin<&mut Self>, artist_id: QString, artist_name: QString) { + crate::blacklist_qt::artist_toggle(artist_id.to_string(), artist_name.to_string()); + } + + pub fn dismiss_artist( + self: Pin<&mut Self>, + artist_id: QString, + artist_name: QString, + image_url: QString, + ) { + crate::blacklist_qt::dismiss_artist( + artist_id.to_string(), + artist_name.to_string(), + image_url.to_string(), + ); + } + pub fn remove_dismissed(self: Pin<&mut Self>, artist_id: QString) { + crate::blacklist_qt::remove_dismissed(artist_id.to_string()); + } + + pub fn artwork_window(self: Pin<&mut Self>, urls_json: QString) { + crate::blacklist_qt::artwork_window(urls_json.to_string()); + } +} diff --git a/crates/qbz-qt/src/blacklist_qt.rs b/crates/qbz-qt/src/blacklist_qt.rs new file mode 100644 index 000000000..ffed82951 --- /dev/null +++ b/crates/qbz-qt/src/blacklist_qt.rs @@ -0,0 +1,786 @@ +//! Blacklist Manager controller — Slint-free port of +//! `crates/qbz/src/blacklist_manager.rs`. +//! +//! Reads the per-user blacklist through `crate::artist_blacklist` (the fail-open +//! singleton every other surface mutates too), applies search-as-you-type +//! filtering controller-side, and publishes ONE JSON document +//! (`QbzBlacklist.blacklistJson`) carrying all three axes. A third +//! "Recommendations" tab (`activeTab == 2`) lists the reco-SCOPED "Not +//! interested" dismissals from `crate::reco_dismiss_qt` — NOT the blacklist — +//! with a per-row undo. +//! +//! There is no change-notify on the blacklist store (the fav_cache pattern), so +//! the manager reloads on every open (`BlacklistManagerView.qml` calls +//! `reload()` in `Component.onCompleted`, because `nav_qt::back()` runs no +//! per-view load) and its own mutations re-push the filtered list in place. +//! +//! ## Two deltas vs the Slint controller, both forced by the transport +//! +//! 1. **`set_tab` republishes.** In Slint the active tab is its own global +//! property (`BlacklistState.active-tab`) so `set_tab` only writes it. Here +//! `activeTab` lives INSIDE the document, so a non-publishing `setTab` would +//! be a dead button. +//! 2. **Every mutation also calls `crate::publish_settings()`.** The Settings +//! row's counters live in a DIFFERENT document (`QbzBridge.settingsJson`) and +//! `nav_qt::back()` re-runs no per-view load, so Manage -> unblock -> Back +//! would otherwise show pre-mutation counters. Slint gets this free from the +//! shared `BlacklistState` global plus its `seed_blacklist_status` on +//! entering Settings. +//! +//! ## Toasts: wired, all 17 +//! +//! An earlier draft dropped them for want of a toast host; `crate::toast_qt` +//! exists now (`toast_qt.rs:96-110`, the `in_app_toasts` gate applied Rust-side, +//! no window handle), so every reference toast is reproduced on the SAME +//! condition with the SAME message. Thirteen come from +//! `blacklist_manager.rs:220-351`; the other four are the `main.rs` menu +//! handlers whose Qt equivalents live in THIS file — `artist_toggle` +//! (`main.rs:12787-12817`), `album_toggle` (`main.rs:12846-12866`), +//! `dismiss_artist` (`main.rs:12986-13037`) — plus `block_album`, which the +//! reference routes through `blacklist_manager::block_album:287-292`. +//! +//! Two of them are `qbz_i18n::tf` plurals (the clear-all counts): the count is +//! formatted in RUST precisely because QML has no plural entry point, so +//! neither string ever crosses the bridge as a template. +//! +//! One deliberate wart is carried over verbatim: the artist visibility pair +//! (`"{name} is now visible"` / `"{name} is now hidden"`) and +//! `"Failed to update artist visibility"` are **not** translated in the +//! reference either — `main.rs:12799-12816` builds them with `format!` and a +//! bare `&str`, and the three msgids exist in none of the eight catalogs. +//! Reproduced as-is rather than silently diverging; translating them is a +//! both-sides change, not a port fix. +//! +//! ## Covers +//! +//! `albums[].artPath` is filled from the SYNCHRONOUS disk cache only +//! (`artwork_qt::cached_path`); nothing is downloaded on publish. The view +//! dispatches the visible window through [`artwork_window`] (debounced, on +//! scroll and on model change) and arrivals land on the existing +//! `QbzLibrary.libraryArtworkReady(key = cover url, path)` — the port's +//! artwork-is-never-in-the-document rule. + +use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::Mutex; + +use cxx_qt_lib::QString; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// The document (spec 03 §5). Per-FIELD serde renames — the crate's convention. +// --------------------------------------------------------------------------- + +#[derive(Clone, Default, Serialize)] +struct ArtistRowDoc { + /// String-carried u64 (no 2^31 truncation, and QML has no 64-bit int). + id: String, + name: String, + #[serde(rename = "addedAt")] + added_at: i64, + #[serde(rename = "addedDisplay")] + added_display: String, + notes: String, + #[serde(rename = "hasNotes")] + has_notes: bool, +} + +#[derive(Clone, Default, Serialize)] +struct AlbumRowDoc { + id: String, + title: String, + artist: String, + #[serde(rename = "coverUrl")] + cover_url: String, + /// `file://` path from the disk cache, `""` until it resolves (the row + /// shows the blind-eye fallback underneath either way). + #[serde(rename = "artPath")] + art_path: String, + #[serde(rename = "addedAt")] + added_at: i64, + #[serde(rename = "addedDisplay")] + added_display: String, + /// Carried for shape parity with the artist row; the Slint `AlbumRow` + /// renders `added-display` in that slot and never the notes. + notes: String, + #[serde(rename = "hasNotes")] + has_notes: bool, +} + +#[derive(Clone, Default, Serialize)] +struct DismissedRowDoc { + id: String, + name: String, +} + +#[derive(Clone, Default, Serialize)] +struct BlacklistDoc { + #[serde(rename = "activeTab")] + active_tab: i32, + /// Shared by BOTH axes (artists + albums); the dismissals ignore it. + enabled: bool, + /// Echoed back so the field and the "No results for …" message agree. + #[serde(rename = "searchQuery")] + search_query: String, + + /// FULL length, never the filtered one — this is what separates "empty" + /// from "no results". + count: i32, + artists: Vec, + + #[serde(rename = "albumCount")] + album_count: i32, + albums: Vec, + + #[serde(rename = "dismissedCount")] + dismissed_count: i32, + dismissed: Vec, +} + +// --------------------------------------------------------------------------- +// Controller state (Rust-side source of truth, like the reference) +// --------------------------------------------------------------------------- + +/// The live search query. The view echoes it back from the document; this is +/// what the builders read, so a toggle/remove/clear re-push keeps the current +/// filter applied. Persists across navigation — the reference never clears it. +static QUERY: Mutex = Mutex::new(String::new()); + +/// Active tab: 0 = Artists, 1 = Albums, 2 = Recommendations. Persists across +/// navigation, exactly like Slint's `BlacklistState.active-tab`. +static TAB: AtomicI32 = AtomicI32::new(0); + +fn current_query() -> String { + QUERY.lock().map(|q| q.clone()).unwrap_or_default() +} + +fn set_query_value(q: String) { + if let Ok(mut guard) = QUERY.lock() { + *guard = q; + } +} + +// --------------------------------------------------------------------------- +// Date formatting — "%b %-d, %Y" without pulling `chrono` into this crate +// --------------------------------------------------------------------------- + +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/// Format a unix-SECONDS timestamp as `"MMM D, YYYY"` (English — `qbz_i18n` has +/// no Rust-side date catalog, same as the reference). Byte-identical to the +/// reference's `chrono` `"%b %-d, %Y"` on a UTC timestamp; `qbz-qt` has no +/// `chrono` dependency, so the civil-date conversion is inlined below rather +/// than adding one for one string. +fn format_added(secs: i64) -> String { + let days = secs.div_euclid(86_400); + // The reference's `DateTime::from_timestamp` yields `None` outside its + // range and renders "". Guard the same way for a corrupt row. + if !(-100_000_000..=100_000_000).contains(&days) { + return String::new(); + } + let (year, month, day) = civil_from_days(days); + format!("{} {}, {}", MONTHS[(month - 1) as usize], day, year) +} + +/// Howard Hinnant's `civil_from_days`: days since 1970-01-01 -> (year, month, +/// day), pure integer arithmetic, valid for the whole proleptic Gregorian +/// range. Truncating division with the era adjustment is the algorithm as +/// published — do not "simplify" it to `div_euclid`. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097; + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + (y + i64::from(m <= 2), m as u32, d as u32) +} + +// --------------------------------------------------------------------------- +// Builders — filtering is entirely Rust-side; QML never filters +// --------------------------------------------------------------------------- + +/// Artists axis: substring on `artist_name` ONLY (notes are NOT searched), +/// preserving the store's `COLLATE NOCASE` order. Returns the filtered rows and +/// the FULL count. +fn build_artists() -> (Vec, i32) { + let all = crate::artist_blacklist::get_all(); + let count = all.len() as i32; + let needle = current_query().trim().to_lowercase(); + + let rows: Vec = all + .into_iter() + .filter(|a| needle.is_empty() || a.artist_name.to_lowercase().contains(&needle)) + .map(|a| { + let notes = a.notes.unwrap_or_default(); + ArtistRowDoc { + id: a.artist_id.to_string(), + name: a.artist_name, + added_at: a.added_at, + added_display: format_added(a.added_at), + has_notes: !notes.is_empty(), + notes, + } + }) + .collect(); + + (rows, count) +} + +/// Albums axis: substring on `album_title` OR `artist_name`, preserving the +/// store's `COLLATE NOCASE` order. Returns the filtered rows, the FULL count, +/// and the cover urls that are NOT on disk yet (the view dispatches those it can +/// actually see through [`artwork_window`]). +fn build_albums() -> (Vec, i32, Vec) { + let all = crate::artist_blacklist::get_all_albums(); + let count = all.len() as i32; + let needle = current_query().trim().to_lowercase(); + + let mut missing: Vec = Vec::new(); + let rows: Vec = all + .into_iter() + .filter(|a| { + needle.is_empty() + || a.album_title.to_lowercase().contains(&needle) + || a.artist_name.to_lowercase().contains(&needle) + }) + .map(|a| { + let notes = a.notes.unwrap_or_default(); + let art_path = if a.cover_url.is_empty() { + String::new() + } else { + crate::artwork_qt::cached_path(&a.cover_url) + }; + if art_path.is_empty() && !a.cover_url.is_empty() { + missing.push(a.cover_url.clone()); + } + AlbumRowDoc { + id: a.album_id, + title: a.album_title, + artist: a.artist_name, + cover_url: a.cover_url, + art_path, + added_at: a.added_at, + added_display: format_added(a.added_at), + has_notes: !notes.is_empty(), + notes, + } + }) + .collect(); + + (rows, count, missing) +} + +/// Reco-dismissals axis: substring on `name`, in the store's FILE order (never +/// sorted). Returns the filtered rows and the FULL count. +fn build_dismissed() -> (Vec, i32) { + let all = crate::reco_dismiss_qt::list(); + let count = all.len() as i32; + let needle = current_query().trim().to_lowercase(); + + let rows: Vec = all + .into_iter() + .filter(|a| needle.is_empty() || a.name.to_lowercase().contains(&needle)) + .map(|a| DismissedRowDoc { + id: a.artist_id.to_string(), + name: a.name, + }) + .collect(); + + (rows, count) +} + +// --------------------------------------------------------------------------- +// Publish / load +// --------------------------------------------------------------------------- + +/// Rebuild all three axes and push the ONE document. Synchronous: the wrapper +/// reads are an in-memory set plus a single SQLite query, exactly as in the +/// reference (there is no worker hop). +pub fn publish() { + let (artists, count) = build_artists(); + let (albums, album_count, missing) = build_albums(); + let (dismissed, dismissed_count) = build_dismissed(); + if !missing.is_empty() { + // Not downloaded here on purpose: the view window-dispatches through + // `artwork_window`, so a 200-album blacklist never fetches 200 covers + // to paint 8 rows. + log::debug!( + "[qbz-qt] blacklist: {} album covers not on disk yet", + missing.len() + ); + } + let doc = BlacklistDoc { + active_tab: TAB.load(Ordering::Relaxed), + enabled: crate::artist_blacklist::is_enabled(), + search_query: current_query(), + count, + artists, + album_count, + albums, + dismissed_count, + dismissed, + }; + let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string()); + crate::blacklist_bridge::ui(move |mut b| { + b.as_mut() + .set_blacklist_json(QString::from(json.as_str())); + }); +} + +/// Load (or refresh) the manager: raise loading, read the store, push, clear +/// loading. The three hops are queued onto the Qt thread in order, so the view +/// never sees a published document while `blacklistLoading` is still true. +pub fn load() { + crate::blacklist_bridge::ui(|mut b| b.as_mut().set_blacklist_loading(true)); + publish(); + crate::blacklist_bridge::ui(|mut b| b.as_mut().set_blacklist_loading(false)); +} + +/// Settings > Blacklist "Manage": push the route and load. `nav_qt::record` +/// republishes `canBack` / `canForward` / `currentView` in one hop, which is +/// what Slint's `open()` needs `update_nav_flags` for. +pub fn open_manager() { + crate::nav_qt::record("blacklist"); + load(); +} + +/// Counters for the Settings row (`settings_qt::devtools`): enabled flag + +/// artist count + album count, straight off the per-user singleton. Replaces +/// the throwaway `BlacklistService` that was opened per settings publish. The +/// counts deliberately ignore the enabled flag. +pub fn counts() -> (bool, i32, i32) { + ( + crate::artist_blacklist::is_enabled(), + crate::artist_blacklist::count() as i32, + crate::artist_blacklist::album_count() as i32, + ) +} + +/// Cross-surface settle signal: `key` is the composite `"{kind}:{id}"` and +/// `value` is the SETTLED state (flipped on success, unchanged on failure) — +/// the same two-arg shape `QbzLibrary.pinChanged` / `libraryFavoriteChanged` +/// use, so a consumer's `Connections` block reads identically. +fn emit_changed(kind: &str, id: &str, value: bool) { + let key = format!("{kind}:{id}"); + crate::blacklist_bridge::ui(move |mut b| { + b.as_mut() + .blacklist_changed(QString::from(key.as_str()), value); + }); +} + +/// Republish the document AND the settings document. Every mutation ends here — +/// see the module docs for why the second one is not cosmetic. +fn republish_all() { + publish(); + crate::publish_settings(); +} + +// --------------------------------------------------------------------------- +// Actions — search / tab / enable +// --------------------------------------------------------------------------- + +/// Search-as-you-type (no debounce, matching the reference): store the query +/// and re-push. `count` stays the FULL length so the empty-vs-no-results split +/// stays correct. +pub fn search_changed(query: String) { + set_query_value(query); + publish(); +} + +/// Switch the active tab (0 Artists, 1 Albums, 2 Recommendations). Republishes +/// because `activeTab` lives inside the document. +pub fn set_tab(tab: i32) { + TAB.store(tab.clamp(0, 2), Ordering::Relaxed); + publish(); +} + +/// Toggle the global enable flag (the ONLY enable affordance in the feature — +/// the Settings row deliberately has none). The counts are unaffected by it. +pub fn toggle_enabled() { + let new_state = !crate::artist_blacklist::is_enabled(); + match crate::artist_blacklist::set_enabled(new_state) { + Ok(()) => { + republish_all(); + // INFO, not success (`blacklist_manager.rs:220`). + crate::toast_qt::info(if new_state { + qbz_i18n::t("Blacklist enabled") + } else { + qbz_i18n::t("Blacklist disabled") + }); + } + Err(e) => { + log::error!("[qbz-qt] blacklist toggle-enabled failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Failed to toggle blacklist")); + } + } +} + +// --------------------------------------------------------------------------- +// Actions — artist axis +// --------------------------------------------------------------------------- + +/// Manager row `x`: un-blacklist one artist (optimistic — the re-push drops the +/// row immediately). A non-numeric id is a silent no-op. +pub fn remove_artist(artist_id: String) { + let Ok(id) = artist_id.parse::() else { + log::debug!("[qbz-qt] blacklist remove skipped for non-numeric id '{artist_id}'"); + return; + }; + // Name captured BEFORE the delete, for the toast — the row is gone after + // (`blacklist_manager.rs:232-236`). No `is_empty` guard: the reference has + // none on this axis (it does have one on the dismissal axis). + let name = crate::artist_blacklist::get_all() + .into_iter() + .find(|a| a.artist_id == id) + .map(|a| a.artist_name) + .unwrap_or_else(|| qbz_i18n::t("Artist")); + match crate::artist_blacklist::remove(id) { + Ok(()) => { + republish_all(); + emit_changed("artist", &artist_id, false); + crate::toast_qt::success(qbz_i18n::t_args("{} removed from blacklist", &[&name])); + } + Err(e) => { + log::error!("[qbz-qt] blacklist remove failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Failed to remove artist")); + } + } +} + +/// Clear-All on the Artists tab (leaves the enabled flag + the albums axis). +/// No per-row settle signal: the reference has none either — an artist page +/// opened before the clear re-seeds on its next navigation. +pub fn clear_all_artists() { + // Count BEFORE the wipe (`blacklist_manager.rs:251`); the toast reports it. + let count = crate::artist_blacklist::count(); + match crate::artist_blacklist::clear_all() { + Ok(()) => { + republish_all(); + crate::toast_qt::success(qbz_i18n::tf( + "Removed {} artist from blacklist", + "Removed {} artists from blacklist", + count as i64, + &[&count.to_string()], + )); + } + Err(e) => { + log::error!("[qbz-qt] blacklist clear-all failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Failed to clear blacklist")); + } + } +} + +/// Artist page ⋯ menu, Blacklist <-> Show. Direction comes from +/// `is_blacklisted_id_str` — which honours the enabled gate, exactly like the +/// reference's `is_blacklisted(id_num)`; the store's `INSERT OR REPLACE` makes +/// a redundant add idempotent. Settles through `blacklistChanged` +/// (`key == "artist:" + id`), which is also the rollback channel. +pub fn artist_toggle(artist_id: String, artist_name: String) { + let Ok(id) = artist_id.parse::() else { + log::debug!("[qbz-qt] blacklist toggle skipped for non-numeric id '{artist_id}'"); + return; + }; + let was_blacklisted = crate::artist_blacklist::is_blacklisted_id_str(&artist_id); + let res = if was_blacklisted { + crate::artist_blacklist::remove(id) + } else { + crate::artist_blacklist::add(id, &artist_name, None) + }; + let settled = match res { + Ok(()) => { + republish_all(); + // UNTRANSLATED on purpose — `main.rs:12799-12804` builds these two + // with `format!`, and neither msgid exists in any catalog. See the + // module docs. + crate::toast_qt::success(if was_blacklisted { + format!("{artist_name} is now visible") + } else { + format!("{artist_name} is now hidden") + }); + !was_blacklisted + } + Err(e) => { + log::error!("[qbz-qt] blacklist artist toggle failed: {e}"); + // Also untranslated in the reference (`main.rs:12813-12816`). + crate::toast_qt::error("Failed to update artist visibility"); + was_blacklisted + } + }; + emit_changed("artist", &artist_id, settled); +} + +/// Manager row click: open the artist page (`BlacklistActions.artist-select`). +pub fn open_artist(artist_id: String) { + if artist_id.is_empty() { + return; + } + crate::open_artist(artist_id); +} + +// --------------------------------------------------------------------------- +// Actions — album axis +// --------------------------------------------------------------------------- + +/// One-way block from a card / list-row ⋯ menu ("Block this album"). The source +/// grid drops the card on its next reload — there is no global observer, which +/// is the artist-block convention. +pub fn block_album(album_id: String, title: String, artist: String, cover_url: String) { + if album_id.is_empty() { + return; + } + match crate::artist_blacklist::add_album(&album_id, &title, &artist, &cover_url, None) { + Ok(()) => { + republish_all(); + emit_changed("album", &album_id, true); + crate::toast_qt::success(qbz_i18n::t_args("Album \"{}\" blocked", &[&title])); + } + Err(e) => { + log::error!("[qbz-qt] album block failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Failed to block album")); + } + } +} + +/// Album page header ⋯ menu, Block <-> Unblock. Same optimistic-flip contract +/// as [`artist_toggle`], settled on `key == "album:" + id`. +pub fn album_toggle(album_id: String, title: String, artist: String, cover_url: String) { + if album_id.is_empty() { + return; + } + let was_blocked = crate::artist_blacklist::is_album_blacklisted(&album_id); + let res = if was_blocked { + crate::artist_blacklist::remove_album(&album_id) + } else { + crate::artist_blacklist::add_album(&album_id, &title, &artist, &cover_url, None) + }; + let settled = match res { + Ok(()) => { + republish_all(); + crate::toast_qt::success(if was_blocked { + qbz_i18n::t_args("Album \"{}\" unblocked", &[&title]) + } else { + qbz_i18n::t_args("Album \"{}\" blocked", &[&title]) + }); + !was_blocked + } + Err(e) => { + log::error!("[qbz-qt] album block toggle failed: {e}"); + crate::toast_qt::error(if was_blocked { + qbz_i18n::t("Failed to unblock album") + } else { + qbz_i18n::t("Failed to block album") + }); + was_blocked + } + }; + emit_changed("album", &album_id, settled); +} + +/// Manager row `x` on the Albums tab: unblock one album (optimistic). +pub fn remove_album(album_id: String) { + if album_id.is_empty() { + return; + } + // Title captured BEFORE the delete (`blacklist_manager.rs:298-302`). + let title = crate::artist_blacklist::get_all_albums() + .into_iter() + .find(|a| a.album_id == album_id) + .map(|a| a.album_title) + .unwrap_or_else(|| qbz_i18n::t("Album")); + match crate::artist_blacklist::remove_album(&album_id) { + Ok(()) => { + republish_all(); + emit_changed("album", &album_id, false); + crate::toast_qt::success(qbz_i18n::t_args("Album \"{}\" unblocked", &[&title])); + } + Err(e) => { + log::error!("[qbz-qt] album remove failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Failed to unblock album")); + } + } +} + +/// Clear-All on the Albums tab (leaves the enabled flag + the artists axis). +pub fn clear_all_albums() { + // Count BEFORE the wipe (`blacklist_manager.rs:321`). + let count = crate::artist_blacklist::album_count(); + match crate::artist_blacklist::clear_all_albums() { + Ok(()) => { + republish_all(); + crate::toast_qt::success(qbz_i18n::tf( + "Removed {} album from blacklist", + "Removed {} albums from blacklist", + count as i64, + &[&count.to_string()], + )); + } + Err(e) => { + log::error!("[qbz-qt] album clear-all failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Failed to clear album blacklist")); + } + } +} + +/// Manager row click on the Albums tab: open the album page +/// (`BlacklistActions.album-select`). +pub fn open_album(album_id: String) { + if album_id.is_empty() { + return; + } + crate::open_album(album_id); +} + +// --------------------------------------------------------------------------- +// Actions — reco-dismissal axis +// --------------------------------------------------------------------------- + +/// "Not interested" on an artist card. The store only backfills an EMPTY +/// name/image, so a blank first write could never be repaired: when the card +/// could not supply a name we resolve it from the catalog first (the reference's +/// non-reco path), then persist and re-publish. Live rail backfill is out of +/// scope — the card leaves on the rail's next publish. +pub fn dismiss_artist(artist_id: String, artist_name: String, image_url: String) { + let Ok(id) = artist_id.parse::() else { + log::debug!("[qbz-qt] reco dismiss skipped for non-numeric id '{artist_id}'"); + return; + }; + if !artist_name.is_empty() { + crate::reco_dismiss_qt::dismiss(id, &artist_name, &image_url); + republish_all(); + // INFO, like the reference (`main.rs:12996-13002`). + crate::toast_qt::info(qbz_i18n::t_args( + "{} won't appear in Recommendations anymore", + &[&artist_name], + )); + return; + } + // No display name on the card (search / home / pinned rail): resolve it + // before persisting, or the manager row would render an empty line. + let runtime = crate::app(); + crate::spawn(async move { + let (name, image) = match runtime.core().get_artist(id).await { + Ok(a) => ( + a.name, + a.image + .and_then(|i| i.best().cloned()) + .unwrap_or_default(), + ), + Err(e) => { + log::warn!("[qbz-qt] reco dismiss name resolve failed for {id}: {e}"); + (String::new(), String::new()) + } + }; + let image = if image.is_empty() { image_url } else { image }; + crate::reco_dismiss_qt::dismiss(id, &name, &image); + republish_all(); + // Unresolvable name falls back to the generic line, exactly as the + // reference's non-reco branch does (`main.rs:13026-13033`). + crate::toast_qt::info(if name.is_empty() { + qbz_i18n::t("Artist dismissed from Recommendations") + } else { + qbz_i18n::t_args("{} won't appear in Recommendations anymore", &[&name]) + }); + }); +} + +/// Recommendations tab row `x`: undo one dismissal. The artist becomes eligible +/// for the reco rails again on their next paint (the filter reads the store). +pub fn remove_dismissed(artist_id: String) { + let Ok(id) = artist_id.parse::() else { + log::debug!("[qbz-qt] reco undismiss skipped for non-numeric id '{artist_id}'"); + return; + }; + // Name captured BEFORE the removal, with the reference's `is_empty` guard + // on this axis (`blacklist_manager.rs:343-348`) — a row persisted without a + // resolved name falls back to the generic "Artist". + let name = crate::reco_dismiss_qt::list() + .into_iter() + .find(|a| a.artist_id == id) + .map(|a| a.name) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| qbz_i18n::t("Artist")); + crate::reco_dismiss_qt::remove(id); + republish_all(); + // Unconditional: the store's `remove` is infallible (`:349-351`). + crate::toast_qt::success(qbz_i18n::t_args("{} restored to Recommendations", &[&name])); +} + +// --------------------------------------------------------------------------- +// Cover window dispatch +// --------------------------------------------------------------------------- + +/// Albums-tab cover window: a JSON array of cover urls the view can currently +/// see. Disk hits emit immediately; misses download, then emit. Arrivals reuse +/// the existing `QbzLibrary.libraryArtworkReady(key, path)` with `key` = the +/// cover URL (same contract as the sidebar's url-keyed dispatch). +pub fn artwork_window(urls_json: String) { + let urls: Vec = serde_json::from_str(&urls_json).unwrap_or_default(); + log::debug!("[qbz-qt] blacklist artwork window: {} urls", urls.len()); + let mut missing: Vec = Vec::new(); + for url in urls { + let path = crate::artwork_qt::cached_path(&url); + if path.is_empty() { + missing.push(url); + } else { + emit_artwork(url, path); + } + } + if missing.is_empty() { + return; + } + crate::spawn(async move { + let urls = missing; + crate::artwork_qt::download_missing(urls.clone()).await; + for url in urls { + let path = crate::artwork_qt::cached_path(&url); + if !path.is_empty() { + emit_artwork(url, path); + } + } + }); +} + +/// One `ui()` hop per domain: this signal belongs to `QbzLibrary`, so it goes +/// through the library bridge and never gets folded into a blacklist closure. +fn emit_artwork(key: String, path: String) { + crate::library_bridge::ui(move |mut b| { + b.as_mut() + .library_artwork_ready(QString::from(key.as_str()), QString::from(path.as_str())); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_added_matches_chrono_b_d_y() { + // Epoch itself. + assert_eq!(format_added(0), "Jan 1, 1970"); + // No zero padding on the day (%-d), month abbreviated (%b). + assert_eq!(format_added(1_751_500_000), "Jul 2, 2025"); + // Leap day. + assert_eq!(format_added(1_709_164_800), "Feb 29, 2024"); + // A December date, i.e. the mp >= 10 branch of civil_from_days. + assert_eq!(format_added(1_735_689_599), "Dec 31, 2024"); + // Pre-epoch (floor division, not truncation). + assert_eq!(format_added(-1), "Dec 31, 1969"); + // A corrupt value renders empty rather than a bogus date. + assert_eq!(format_added(i64::MAX), ""); + } + + #[test] + fn civil_from_days_month_boundaries() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // 1972-01-01 is day 730 (two 365-day years); +59 lands on the first + // leap day after the epoch, +60 on the day after it. + assert_eq!(civil_from_days(730), (1972, 1, 1)); + assert_eq!(civil_from_days(730 + 59), (1972, 2, 29)); + assert_eq!(civil_from_days(730 + 60), (1972, 3, 1)); + } +} diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index b7e88f1f5..d7daebd1d 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -34,6 +34,17 @@ pub mod qbz_bridge { // One JSON document (settings_qt.rs SettingsDoc: audio + playback // row states, select option lists, output-device groups). #[qproperty(QString, settings_json)] + // The ACTIVE Settings sub-section (0 Audio … 8 Flatpak/Snap — the + // display order is documented in settings/SettingsView.qml's header). + // + // Bridge state rather than QML state, and that is load-bearing: the + // content Loader UNMOUNTS SettingsView when the user navigates away, + // which destroys a local `property int section` along with it. The + // Blacklist panel's "Manage" chevron does exactly that — it opens the + // manager view — so on Back the user landed on Audio instead of + // Blacklist. This is Slint's `SettingsState.section`, which is a global + // for the same reason (SettingsView.slint:121-200). + #[qproperty(i32, settings_section)] // --- Search (phase 15) --------------------------------------------- // Cortinilla (live dropdown): open/loading flags, ONE JSON payload @@ -91,6 +102,15 @@ pub mod qbz_bridge { /// Free-text rows (Qobuz Connect device name). #[qinvokable] fn settings_string(self: Pin<&mut QbzBridge>, key: QString, value: QString); + /// Settings sub-navigation: switch the active section. + /// + /// The rows MUST call this rather than assigning `section` in QML. + /// `section` is a BINDING onto `settingsSection`, and in QML an + /// imperative assignment silently destroys the binding it lands on — so + /// one stray `root.section = n` would work for exactly one click and + /// then strand the view on stale local state. + #[qinvokable] + fn settings_set_section(self: Pin<&mut QbzBridge>, index: i32); /// "Reset to defaults" (audio + playback stores, quality pref). #[qinvokable] fn settings_reset(self: Pin<&mut QbzBridge>); @@ -239,6 +259,7 @@ use cxx_qt_lib::QString; /// storage (as required by cxx-qt's Default-constructed qobjects). pub struct QbzBridgeRust { settings_json: QString, + settings_section: i32, cortinilla_open: bool, cortinilla_loading: bool, cortinilla_json: QString, @@ -255,6 +276,8 @@ impl Default for QbzBridgeRust { fn default() -> Self { Self { settings_json: QString::from("{}"), + // 0 = Audio, the section Settings opens on. + settings_section: 0, cortinilla_open: false, cortinilla_loading: false, cortinilla_json: QString::from("{}"), @@ -302,6 +325,13 @@ impl qbz_bridge::QbzBridge { crate::settings_string(key.to_string(), value.to_string()); } + /// Purely bridge-local state — no crate handler, nothing to persist. The + /// Slint global is not persisted either: Settings always opens on Audio, + /// the section only has to survive a Loader unmount WITHIN a session. + pub fn settings_set_section(mut self: Pin<&mut Self>, index: i32) { + self.as_mut().set_settings_section(index); + } + pub fn settings_reset(self: Pin<&mut Self>) { crate::settings_reset(); } diff --git a/crates/qbz-qt/src/reco_dismiss_qt.rs b/crates/qbz-qt/src/reco_dismiss_qt.rs new file mode 100644 index 000000000..3efc8855f --- /dev/null +++ b/crates/qbz-qt/src/reco_dismiss_qt.rs @@ -0,0 +1,250 @@ +//! Per-user "Not interested" dismissal store for Discover > Recommendations — +//! Slint-free port of `crates/qbz/src/reco_dismiss.rs` (1:1; only the log tag +//! changed). +//! +//! 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::recommendations_qt` folds [`ids_snapshot`] into its exclusion set, +//! and the Blacklist Manager's "Recommendations" tab lists / undoes entries. +//! +//! This one is NOT in a shared crate: the reference lives in the Slint frontend +//! crate (`crates/qbz/src/reco_dismiss.rs`), so it is ported here rather than +//! consumed. **The on-disk format is deliberately identical** — same per-user +//! directory, same `reco_dismiss.json` file name, same key names, same +//! `to_vec_pretty` — because the two frontends share the user directory and a +//! dismissal made in one must be honoured by the other. +//! +//! 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_qt` / `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 persisted but never rendered by the +/// manager tab (the row draws a generic avatar); it is stored so a future +/// surface can show a thumbnail — and because a blank first write can never be +/// repaired, [`dismiss`] only backfills an EMPTY field. +#[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_qt` 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_qt::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_qt::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-qt] 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-qt] reco-dismiss write failed: {e}"); + } + } + Err(e) => log::warn!("[qbz-qt] reco-dismiss serialize failed: {e}"), + } +} + +/// Snapshot of the dismissed id set — the reco paint-filter input. Empty when +/// no session is bound. Id 0 (a corrupt row) never matches. +// Consumed by the reco exclusion set (spec 03 §9.2 item F21), which lands with +// the recommendations filter slice. +#[allow(dead_code)] +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 (NEVER sorted — the manager's +/// Recommendations tab deliberately has no `COLLATE NOCASE` ordering, unlike +/// the two blacklist axes). 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); a populated field is never +/// overwritten. +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-qt). 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-qt-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-qt/src/search_qt.rs b/crates/qbz-qt/src/search_qt.rs index b838f6a66..80a382e00 100644 --- a/crates/qbz-qt/src/search_qt.rs +++ b/crates/qbz-qt/src/search_qt.rs @@ -29,9 +29,15 @@ //! - Artist "following" flags are resolved from `fav_cache_qt` (the ported //! favourite-id cache) — they used to be hard-`false`, which made a search //! hit on a followed artist draw "Follow" and un-follow them on click. -//! - The album/artist blacklist filter is passed empty (the blacklist -//! store is not open in this POC, same as earlier phases). +//! +//! Blacklist filtering is live on every one of this module's four fetch paths +//! (spec 03 §9.2 F8): `live` and `submit` hand the snapshot pair INTO +//! `core.search_all` (`search.rs:1045-1059`, `:1095-1115`), and `load_more` / +//! `filter_changed` post-filter their pages because the paged +//! `search_albums` / `search_tracks` / `search_artists` pass-throughs take no +//! snapshot (`search.rs:1616-1666`). +use std::collections::HashSet; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -423,6 +429,30 @@ fn map_playlist(playlist: &Playlist) -> PlaylistRow { } } +// --------------------------------------------------------------------------- +// Blacklist snapshots (spec 03 §9.2 F8) +// --------------------------------------------------------------------------- + +/// The `(artists, albums)` blacklist snapshot pair every fetch path in this +/// module needs, in the reference's exact shape: **one** shared `is_enabled()` +/// gate for both axes, empty sets when the feature is off so the `qbz_core` +/// predicates short-circuit on `bl.is_empty() && album_bl.is_empty()` +/// (`search.rs:1045-1055` / `:1618-1626`, `qbz-core/src/core.rs:128`). +/// +/// Snapshotted per fetch, not cached: the store is mutated from the manager, +/// the artist page and the album page with no change-notify, so a stale +/// snapshot is exactly the leak this closes. +fn blacklist_snapshots() -> (HashSet, HashSet) { + if crate::artist_blacklist::is_enabled() { + ( + crate::artist_blacklist::ids_snapshot(), + crate::artist_blacklist::album_ids_snapshot(), + ) + } else { + Default::default() + } +} + // --------------------------------------------------------------------------- // Artwork (the reload_home pattern: disk hits inline, one background // download + republish) @@ -701,10 +731,9 @@ pub async fn live(runtime: &Arc>, query: &str) { }); let t = std::time::Instant::now(); - let results = runtime - .core() - .search_all(&q, &Default::default(), &Default::default()) - .await; + // Blacklist filtering happens INSIDE search_all (`search.rs:1112-1115`). + let (bl, abl) = blacklist_snapshots(); + let results = runtime.core().search_all(&q, &bl, &abl).await; let results = match results { Ok(r) => r, Err(e) => { @@ -1117,10 +1146,9 @@ pub async fn submit(runtime: &Arc>, query: &str, tab: } let t = std::time::Instant::now(); - let results = runtime - .core() - .search_all(&q, &Default::default(), &Default::default()) - .await; + // Blacklist filtering happens INSIDE search_all (`search.rs:1056-1059`). + let (bl, abl) = blacklist_snapshots(); + let results = runtime.core().search_all(&q, &bl, &abl).await; if !is_current_page_version(version) { return; } @@ -1291,11 +1319,22 @@ pub async fn load_more(runtime: &Arc>, tab: i32) { }; let search_type = search_type_for_filter(filter); let version = next_page_version(); + // Page-2+ is NOT filtered by the API: `search_albums`/`search_tracks`/ + // `search_artists` take no snapshot, so the drop happens here, exactly as + // the reference does it (`search.rs:1616-1666`). `offset` stays the VISIBLE + // row count (`main.rs:9795-9800` reads `row_count()`), so a filtered page + // shortens the batch rather than shifting the cursor — reference-identical. + let (bl, abl) = blacklist_snapshots(); match tab { 1 => match runtime.core().search_albums(&query, PAGE_SIZE, offset, search_type.as_deref()).await { Ok(page) => { - let mut rows: Vec = page.items.iter().map(map_album).collect(); + let mut rows: Vec = page + .items + .iter() + .filter(|a| !qbz_core::core::album_blacklisted(a, &bl, &abl)) + .map(map_album) + .collect(); let missing = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); let total = page.total as i32; let mut guard = PAGE.lock().unwrap(); @@ -1314,7 +1353,12 @@ pub async fn load_more(runtime: &Arc>, tab: i32) { }, 2 => match runtime.core().search_tracks(&query, PAGE_SIZE, offset, search_type.as_deref()).await { Ok(page) => { - let mut rows: Vec = page.items.iter().map(map_track).collect(); + let mut rows: Vec = page + .items + .iter() + .filter(|t| !qbz_core::core::track_blacklisted(t, &bl, &abl)) + .map(map_track) + .collect(); let missing = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); let total = page.total as i32; let mut guard = PAGE.lock().unwrap(); @@ -1333,7 +1377,14 @@ pub async fn load_more(runtime: &Arc>, tab: i32) { }, 3 => match runtime.core().search_artists(&query, PAGE_SIZE, offset, search_type.as_deref()).await { Ok(page) => { - let mut rows: Vec = page.items.iter().map(map_artist).collect(); + // Artist axis ONLY — the reference filters this category on + // `bl` alone (`search.rs:1655`); an artist has no album id. + let mut rows: Vec = page + .items + .iter() + .filter(|a| !bl.contains(&a.id)) + .map(map_artist) + .collect(); let missing = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); let total = page.total as i32; let mut guard = PAGE.lock().unwrap(); @@ -1389,10 +1440,19 @@ pub async fn filter_changed(runtime: &Arc>, index: i3 } let search_type = search_type_for_filter(index); let version = next_page_version(); + // The reference implements the filter radios by calling `load_more(.., 0)` + // per category and `replace_category` (`main.rs:9843-9856`), so these three + // re-queries carry the SAME post-filter as `load_more` above. + let (bl, abl) = blacklist_snapshots(); // Albums. if let Ok(page) = runtime.core().search_albums(&query, PAGE_SIZE, 0, search_type.as_deref()).await { - let mut rows: Vec = page.items.iter().map(map_album).collect(); + let mut rows: Vec = page + .items + .iter() + .filter(|a| !qbz_core::core::album_blacklisted(a, &bl, &abl)) + .map(map_album) + .collect(); let _ = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); let total = page.total as i32; let mut guard = PAGE.lock().unwrap(); @@ -1408,7 +1468,12 @@ pub async fn filter_changed(runtime: &Arc>, index: i3 } // Tracks. if let Ok(page) = runtime.core().search_tracks(&query, PAGE_SIZE, 0, search_type.as_deref()).await { - let mut rows: Vec = page.items.iter().map(map_track).collect(); + let mut rows: Vec = page + .items + .iter() + .filter(|t| !qbz_core::core::track_blacklisted(t, &bl, &abl)) + .map(map_track) + .collect(); let _ = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); let total = page.total as i32; let mut guard = PAGE.lock().unwrap(); @@ -1424,7 +1489,12 @@ pub async fn filter_changed(runtime: &Arc>, index: i3 } // Artists (the API takes the search_type too, 1:1 the Slint filter). if let Ok(page) = runtime.core().search_artists(&query, PAGE_SIZE, 0, search_type.as_deref()).await { - let mut rows: Vec = page.items.iter().map(map_artist).collect(); + let mut rows: Vec = page + .items + .iter() + .filter(|a| !bl.contains(&a.id)) + .map(map_artist) + .collect(); let _ = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); let total = page.total as i32; let mut guard = PAGE.lock().unwrap(); diff --git a/crates/qbz-qt/src/settings_qt/devtools.rs b/crates/qbz-qt/src/settings_qt/devtools.rs index f592ca49f..38fa16cd6 100644 --- a/crates/qbz-qt/src/settings_qt/devtools.rs +++ b/crates/qbz-qt/src/settings_qt/devtools.rs @@ -3,7 +3,9 @@ //! ADR-006 again: the log file is `qbz_log::install::log_file_path()` (the //! sink `main.rs` installs), the export bundle is the shared //! `qbz_app::settings::bundle` engine (the same one `qbzd` uses), and the -//! blacklist counters come from the shared `BlacklistService` store. +//! blacklist counters come from the per-user `artist_blacklist` singleton — +//! the same store the Blacklist Manager view mutates, so the row cannot go +//! stale behind it. //! //! Deltas vs the Slint (reported, not hidden): //! - Developer > "Connect diagnostics" is not shipped: the port has no live @@ -13,9 +15,6 @@ //! - Developer > export writes the bundle straight to disk with auth //! EXCLUDED. The Slint's `SettingsExportModal` (the include-auth gate, //! default OFF) is not ported, so the safe default is the only behaviour. -//! - Blacklist > "Manage" is not shipped: there is no Blacklist Manager view -//! in this port, so the chevron would open nothing. The status line is the -//! real store's counters. use qbz_app::settings::bundle::{self, ExportOptions, ExportSource}; use serde::Serialize; @@ -53,28 +52,21 @@ fn install_method() -> String { String::new() } -/// Blacklist counters, read-only. The store file is NOT created when it does -/// not exist yet — an empty blacklist reads as zeros. +/// Blacklist counters, read-only, straight off the per-user singleton bound at +/// session activation (`auth_qt::bind_per_user_stores`). +/// +/// This used to open a throwaway `BlacklistService` on every settings publish. +/// It no longer can: the manager view mutates the SAME store, so a second +/// handle would serve stale counts the moment the user removed an artist. +/// +/// Fail-open is unchanged. `artist_blacklist`'s accessors return +/// `(true, 0, 0)` for exactly the three cases the deleted early-returns +/// covered — no session bound, no store file, store open failed — so the +/// no-session and fresh-account behaviours do not move. The counts +/// deliberately ignore the enabled flag: the row reports what is stored, and +/// the flag is rendered separately. fn blacklist_counts() -> (bool, i32, i32) { - let Some(path) = crate::sidebar_qt::user_dir() - .map(|d| d.join(qbz_app::settings::artist_blacklist::DB_FILE_NAME)) - else { - return (true, 0, 0); - }; - if !path.exists() { - return (true, 0, 0); - } - match qbz_app::settings::artist_blacklist::BlacklistService::new(&path) { - Ok(service) => ( - service.is_enabled(), - service.count() as i32, - service.album_count() as i32, - ), - Err(e) => { - log::warn!("[qbz-qt] blacklist store open failed: {e}"); - (true, 0, 0) - } - } + crate::blacklist_qt::counts() } pub fn snapshot() -> Snapshot { 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 1fce9e59a..8a1deb870 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4867,3 +4867,25 @@ msgstr "{} Titel aus Favoriten entfernt" msgid "Removing {} tracks from favorites…" msgstr "{} Titel werden aus Favoriten entfernt…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Album \"{}\" blockiert" + +msgid "Album \"{}\" unblocked" +msgstr "Album \"{}\" freigegeben" + +msgid "Failed to block album" +msgstr "Album konnte nicht blockiert werden" + +msgid "Failed to unblock album" +msgstr "Album konnte nicht freigegeben werden" + +msgid "Failed to clear album blacklist" +msgstr "Album-Sperrliste konnte nicht geleert werden" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "{} Album aus der Sperrliste entfernt" +msgstr[1] "{} Alben aus der Sperrliste entfernt" 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 9ab520f11..1fd1270a4 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -663,3 +663,25 @@ msgstr "Removed {} tracks from favorites" msgid "Removing {} tracks from favorites…" msgstr "Removing {} tracks from favorites…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Album \"{}\" blocked" + +msgid "Album \"{}\" unblocked" +msgstr "Album \"{}\" unblocked" + +msgid "Failed to block album" +msgstr "Failed to block album" + +msgid "Failed to unblock album" +msgstr "Failed to unblock album" + +msgid "Failed to clear album blacklist" +msgstr "Failed to clear album blacklist" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "Removed {} album from blacklist" +msgstr[1] "Removed {} albums from blacklist" 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 ebe90f00d..7f2644584 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4867,3 +4867,25 @@ msgstr "{} pistes retirées des favoris" msgid "Removing {} tracks from favorites…" msgstr "Suppression de {} pistes des favoris…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Album \"{}\" bloqué" + +msgid "Album \"{}\" unblocked" +msgstr "Album \"{}\" débloqué" + +msgid "Failed to block album" +msgstr "Échec du blocage de l'album" + +msgid "Failed to unblock album" +msgstr "Échec du déblocage de l'album" + +msgid "Failed to clear album blacklist" +msgstr "Échec de l'effacement de la liste noire des albums" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "{} album retiré de la liste noire" +msgstr[1] "{} albums retirés de la liste noire" 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 fad638f33..30c966650 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4855,3 +4855,24 @@ msgstr "{} トラックをお気に入りから削除しました" msgid "Removing {} tracks from favorites…" msgstr "{} トラックをお気に入りから削除中…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "アルバム「{}」をブロックしました" + +msgid "Album \"{}\" unblocked" +msgstr "アルバム「{}」のブロックを解除しました" + +msgid "Failed to block album" +msgstr "アルバムのブロックに失敗しました" + +msgid "Failed to unblock album" +msgstr "アルバムのブロック解除に失敗しました" + +msgid "Failed to clear album blacklist" +msgstr "アルバムのブラックリストのクリアに失敗しました" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "ブラックリストから{}枚のアルバムを削除しました" 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 98ca5dd0e..6d08b2f1c 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4867,3 +4867,25 @@ msgstr "{} faixas removidas dos favoritos" msgid "Removing {} tracks from favorites…" msgstr "Removendo {} faixas dos favoritos…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Álbum \"{}\" bloqueado" + +msgid "Album \"{}\" unblocked" +msgstr "Álbum \"{}\" desbloqueado" + +msgid "Failed to block album" +msgstr "Falha ao bloquear o álbum" + +msgid "Failed to unblock album" +msgstr "Falha ao desbloquear o álbum" + +msgid "Failed to clear album blacklist" +msgstr "Falha ao limpar a lista negra de álbuns" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "{} álbum removido da lista negra" +msgstr[1] "{} álbuns removidos da lista negra" 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 c55dfcdb9..0816084e6 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4881,3 +4881,26 @@ msgstr "{} треков удалено из избранного" msgid "Removing {} tracks from favorites…" msgstr "Удаление {} треков из избранного…" + +# Blacklist album-axis toasts (msgids from crates/qbz/src/blacklist_manager.rs:287-330) + +msgid "Album \"{}\" blocked" +msgstr "Альбом «{}» заблокирован" + +msgid "Album \"{}\" unblocked" +msgstr "Альбом «{}» разблокирован" + +msgid "Failed to block album" +msgstr "Не удалось заблокировать альбом" + +msgid "Failed to unblock album" +msgstr "Не удалось разблокировать альбом" + +msgid "Failed to clear album blacklist" +msgstr "Не удалось очистить чёрный список альбомов" + +msgid "Removed {} album from blacklist" +msgid_plural "Removed {} albums from blacklist" +msgstr[0] "Удалён {} альбом из чёрного списка" +msgstr[1] "Удалено {} альбома из чёрного списка" +msgstr[2] "Удалено {} альбомов из чёрного списка"