diff --git a/crates/qbz-qt/qml/settings/OfflineSettings.qml b/crates/qbz-qt/qml/settings/OfflineSettings.qml index 3d023553c..fa05d5ae0 100644 --- a/crates/qbz-qt/qml/settings/OfflineSettings.qml +++ b/crates/qbz-qt/qml/settings/OfflineSettings.qml @@ -8,11 +8,13 @@ // `OfflineModeEngine::set_induced` (which also takes the #279 stream-first // snapshot). // -// Not shipped (no backend seam in this port — see settings_qt/offline.rs): -// - "Check now": the connectivity actor is private to offline_fwd.rs, so a -// re-probe cannot be triggered from here. The status itself is live. -// - The whole OFFLINE CACHE group (Open manager / Open folder / Clear all): -// the port never brings up OfflineCacheState, so all three would be dead. +// "Check now" IS shipped (`offline-recheck`); so is the live tri-state status +// line (QbzSession.offlineMode / captivePortal) and the lyrics-cache row. +// +// Still missing: the whole OFFLINE CACHE group (Open manager / Open folder / +// Clear all). What blocks it is the MANAGER VIEW, which has no Qt counterpart +// yet — not the engine: `offline_cache_qt.rs` already runs the downloads from +// the album page and the track row. import QtQuick import com.blitzfc.qbz @@ -24,6 +26,10 @@ Column { property var doc: ({}) readonly property var off: doc.offline || ({}) + /// The view-level SettingsConfirmHost (SettingsView.qml). Null in previews + /// — the Clear-all row guards, so a preview degrades to the unconfirmed + /// call rather than swallowing the click. + property var confirmHost: null QbzTheme { id: theme } @@ -72,6 +78,56 @@ Column { SettingsDivider { } SettingsSpacer { } + // ========================== OFFLINE CACHE ============================ + // The downloads half: the manager view plus the two whole-cache actions. + // All three ride QbzOffline, the same bridge the manager view uses — + // "Open folder" and "Clear all" are the manager's own stats-bar buttons, + // offered here too exactly as the reference offers them + // (OfflineSettings.slint:135-167). + GroupHeader { text: QbzSession.tr("OFFLINE CACHE", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Manage offline cache", QbzSession.trRev) + description: QbzSession.tr("Browse and manage your downloaded tracks and albums.", QbzSession.trRev) + SettingsButton { + text: QbzSession.tr("Open manager", QbzSession.trRev) + onClicked: QbzOffline.openManager() + } + } + SettingRow { + label: QbzSession.tr("Cache folder", QbzSession.trRev) + description: QbzSession.tr("Open the folder where offline tracks are stored on disk.", QbzSession.trRev) + SettingsButton { + text: QbzSession.tr("Open folder", QbzSession.trRev) + onClicked: QbzOffline.openFolder() + } + } + SettingRow { + label: QbzSession.tr("Clear cache", QbzSession.trRev) + description: QbzSession.tr("Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above.", QbzSession.trRev) + SettingsButton { + danger: true + text: QbzSession.tr("Clear all", QbzSession.trRev) + // ONE prompt before the purge. The reference fires straight from + // the button; this port confirms every destructive settings row, + // and undoing this one means re-downloading the whole cache. + onClicked: { + if (!root.confirmHost) { + QbzOffline.clearAll() + return + } + root.confirmHost.ask( + QbzSession.tr("Clear cache", QbzSession.trRev), + QbzSession.tr("Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above.", QbzSession.trRev), + QbzSession.tr("Clear all", QbzSession.trRev), + function () { QbzOffline.clearAll() }) + } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + // ============================= LYRICS ================================ GroupHeader { text: QbzSession.tr("LYRICS", QbzSession.trRev) } SettingRow { diff --git a/crates/qbz-qt/qml/settings/SettingsView.qml b/crates/qbz-qt/qml/settings/SettingsView.qml index 5a63a5a88..02373756e 100644 --- a/crates/qbz-qt/qml/settings/SettingsView.qml +++ b/crates/qbz-qt/qml/settings/SettingsView.qml @@ -276,6 +276,10 @@ Item { visible: root.section === 3 width: parent.width doc: root.doc + // "Clear all" purges every downloaded track and + // there is no undo — same treatment as the Local + // Library danger zone below. + confirmHost: confirmHost } LocalLibrarySettings { visible: root.section === 4 diff --git a/crates/qbz-qt/qml/views/OfflineManagerView.qml b/crates/qbz-qt/qml/views/OfflineManagerView.qml new file mode 100644 index 000000000..e54f115bc --- /dev/null +++ b/crates/qbz-qt/qml/views/OfflineManagerView.qml @@ -0,0 +1,641 @@ +// Offline Cache Manager — QML port of crates/qbz-ui/ui/offline/ +// OfflineManagerView.slint. A FULL-PAGE content view (route "offlinemanager"), +// reached only from Settings > Offline > "Open manager", exactly like the +// Blacklist manager next door. +// +// Layout, 1:1 with the reference: a stats bar (totals · usage bar · GB limit +// field · open-folder · clear-all) over a toolbar (sort · failed-only) over a +// two-pane body — a 210px A-Z artist rail on the left, the album-header + +// track rows on the right. +// +// Everything comes out of ONE document, QbzOffline.managerJson. The rollup, +// the ordering, the sizes and the status integers are all Rust-side +// (src/offline_manager_qt.rs); QML never filters and never sorts. +// +// Deltas vs the Slint, each deliberate: +// +// - NO per-view Back chrome (the Slint's NavButtons row, :171). Nav history is +// the global HeaderBar in this port — the same treatment +// views/BlacklistManagerView.qml already documents for the identical case. +// ADR-004 is satisfied by the HeaderBar. +// - The bulk actions are the SHARED controls/QbzMultiSelectBar.qml, not the +// reference's inline `BulkIconBtn` row plus a separate select-all toggle. +// The Slint's own comment (:115-118) says that component was "homologated +// with MultiSelectBar's BulkButton ... so the offline-cache track rows share +// the app-wide bulk-bar style" — this port HAS that bar, it already carries +// both "Select all" and "Clear" as actions, and every other multi-select +// surface here uses it. +// - Selection is QML-local (`selected` map below), the port's convention +// (LocalLibraryView.qml:100) rather than the reference's in-place edits on +// the Slint model. The rebuild is then a pure function of the DB, so a +// download finishing mid-selection cannot fight the user's checkboxes. +// - Covers are file paths in the document, loaded by RoundedImage with +// sourceSize; the reference pre-decodes pixels on its worker because +// slint::Image is not Send. Qt has no such constraint. +// +// Live progress does NOT wait for a rebuild: QbzShell.trackCacheStatusChanged +// is the app-wide row-status channel and this view patches its own rows from +// it, the same way the album page and the track rows do. A rebuild only +// happens on a terminal mutation (offline_manager_qt::refresh_if_open). +// +// Nav re-entry: nav_qt::back()/forward() republish `currentView` and run NO +// per-view load, so this view calls QbzOffline.reload() in +// Component.onCompleted. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +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). + readonly property bool ambientOn: theme.ambientOn + color: root.ambientOn ? "transparent" : theme.surfaceMain + // Round to the AppShell content-frame bezel (Radius.md): QML clips are + // rectangular, so the frame's rounding never reaches the view. + radius: 12 + + QbzTheme { id: theme } + + // ============================== data ================================== + + readonly property var doc: { + try { + return JSON.parse(QbzOffline.managerJson) + } catch (e) { + return ({}) + } + } + readonly property var rows: root.doc.rows || [] + readonly property var artists: root.doc.artists || [] + readonly property int tracksCount: root.doc.tracksCount || 0 + + /// Checked TRACK rows, keyed by track id. Cleared on every publish: the + /// rows may have been re-filtered or removed underneath, and a checkbox + /// pointing at a row that is gone is a bulk action on nothing. + property var selected: ({}) + readonly property int selectedCount: Object.keys(root.selected).length + onDocChanged: root.selected = ({}) + + /// Live per-row STATUS patches (id -> {status}) merged over the document. + /// Kept separate so the next publish drops them wholesale rather than + /// leaving a stale spinner behind. + /// + /// The signal also carries a progress fraction and the document carries + /// `progress` per row, but neither is drawn — the reference does not draw + /// it either: its StatusIcon spins, it does not fill. Both are kept in the + /// payload because the row-status channel is app-wide and its shape is not + /// this view's to trim. + property var livePatch: ({}) + + Component.onCompleted: QbzOffline.reload() + + Connections { + target: QbzShell + function onTrackCacheStatusChanged(trackId, status, progress) { + var p = Object.assign({}, root.livePatch) + p[trackId] = { "status": status, "progress": progress } + root.livePatch = p + } + } + + function rowStatus(row) { + var p = root.livePatch[row.trackId] + return (row.kind === "track" && p !== undefined) ? p.status : row.status + } + function toggleSelect(trackId) { + var s = Object.assign({}, root.selected) + if (s[trackId] === true) + delete s[trackId] + else + s[trackId] = true + root.selected = s + } + + function bulkAction(id) { + if (id === "clear") { + root.selected = ({}) + return + } + if (id === "select-all") { + var s = ({}) + for (var i = 0; i < root.rows.length; i++) + if (root.rows[i].kind === "track") s[root.rows[i].trackId] = true + root.selected = s + return + } + var ids = Object.keys(root.selected) + if (ids.length === 0) + return + if (id === "redownload") QbzOffline.bulkRedownload(JSON.stringify(ids)) + else if (id === "remove") QbzOffline.bulkRemove(JSON.stringify(ids)) + root.selected = ({}) + } + + // ============================== chrome ================================ + + // The cache-status glyph. Status 2 (downloading) is the only animated one + // and it rides the SHARED shell pulse — never a local Timer or + // NumberAnimation (the repaint-pulse law: every continuous animator ticks + // on QbzShell.pulseMs or the whole window presents at display rate). + component StatusGlyph: Item { + id: glyph + property int status: 0 + width: 18 + height: 18 + + property real spin: 0 + Connections { + target: QbzShell + enabled: glyph.status === 2 && glyph.visible + function onPulseMsChanged() { glyph.spin = (glyph.spin + 12) % 360 } + } + + QbzIcon { + anchors.centerIn: parent + width: 16 + height: 16 + rotation: glyph.status === 2 ? glyph.spin : 0 + name: glyph.status === 2 ? "loader-circle" + : glyph.status === 3 ? "circle-check-big" + : glyph.status === 4 ? "triangle-alert" : "cloud-download" + tintName: glyph.status === 2 ? "accent" + : glyph.status === 3 ? "accent" + : glyph.status === 4 ? "warning" : "muted" + } + } + + // A 30px ghost icon button (the reference's IconBtn). + component GhostBtn: Rectangle { + id: gb + property string name: "" + property string tint: "muted" + signal clicked() + width: 30 + height: 30 + radius: theme.radiusSm + color: gbArea.containsMouse ? theme.surfaceElevated : "transparent" + QbzIcon { + anchors.centerIn: parent + width: 16 + height: 16 + name: gb.name + tintName: gbArea.containsMouse ? "textPrimary" : gb.tint + } + MouseArea { + id: gbArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: gb.clicked() + } + } + + // ============================== layout ================================ + + Column { + anchors.fill: parent + anchors.margins: 24 + spacing: 14 + + // ---- Header ------------------------------------------------------ + Text { + text: QbzSession.tr("Offline Cache", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightBold + } + + // ---- Stats bar --------------------------------------------------- + Rectangle { + width: parent.width + height: 76 + radius: theme.radiusMd + color: theme.surfaceElevated + + // Anchored, NOT a Row: the usage bar has to take exactly the + // slack between the two fixed clusters, and expressing that as a + // width subtraction inside a Row needs a named constant for the + // left cluster — which is how a phantom spacer item ends up in the + // layout adding its own width. + Column { + id: statsLeft + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + text: root.doc.tracksText || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Text { + text: (root.doc.sizeText || "") + " " + (root.doc.limitText || "") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + + Row { + id: statsRight + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + QbzLineEdit { + id: limitField + anchors.verticalCenter: parent.verticalCenter + width: 58 + // Seeded from the document, then owned locally while the + // user types — binding it to `limitGb` would put the + // persisted number back under the cursor on every + // republish (the re-seed rule in QbzLineEdit.qml:67). + text: (root.doc.limitGb || 5).toString() + onAccepted: function (v) { QbzOffline.setLimit(parseInt(v) || 0) } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "GB" + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "check" + onClicked: QbzOffline.setLimit(parseInt(limitField.text) || 0) + } + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "folder" + onClicked: QbzOffline.openFolder() + } + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "trash-2" + tint: "warning" + onClicked: clearConfirm.open() + } + } + + Rectangle { + anchors.left: statsLeft.right + anchors.right: statsRight.left + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.verticalCenter: parent.verticalCenter + height: 8 + radius: 4 + color: theme.surfaceMain + Rectangle { + width: parent.width * Math.max(0, Math.min(1, root.doc.usage || 0)) + height: parent.height + radius: 4 + color: (root.doc.usage || 0) >= 1.0 ? theme.danger : theme.accent + } + } + } + + // ---- Toolbar ----------------------------------------------------- + Row { + width: parent.width + height: 32 + spacing: 12 + + QbzSelect { + anchors.verticalCenter: parent.verticalCenter + menuWidth: 160 + sm: true + options: [ + QbzSession.tr("Sort: A-Z", QbzSession.trRev), + QbzSession.tr("Sort: Recent", QbzSession.trRev), + QbzSession.tr("Sort: Largest", QbzSession.trRev), + QbzSession.tr("Sort: Smallest", QbzSession.trRev) + ] + currentIndex: root.doc.sortIndex || 0 + onSelected: function (i) { QbzOffline.setSort(i) } + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: failedLabel.implicitWidth + 28 + height: 32 + radius: theme.radiusSm + border.width: 1 + border.color: root.doc.showOnlyFailed === true ? theme.accent : theme.borderSubtle + color: root.doc.showOnlyFailed === true + ? theme.dangerBg + : (failedArea.containsMouse ? theme.surfaceElevated : "transparent") + Text { + id: failedLabel + x: 14 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Failed only", QbzSession.trRev) + color: root.doc.showOnlyFailed === true ? theme.danger : theme.textSecondary + font.pixelSize: theme.fontLegal + } + MouseArea { + id: failedArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzOffline.toggleFailed() + } + } + } + + // ---- Body -------------------------------------------------------- + Item { + width: parent.width + height: parent.height - y + + Row { + anchors.fill: parent + anchors.bottomMargin: root.selectedCount > 0 ? 58 : 0 + spacing: 14 + + // Left A-Z artist rail. + Rectangle { + width: 210 + height: parent.height + radius: theme.radiusMd + color: theme.surfaceCard + clip: true + + ListView { + id: railList + anchors.fill: parent + anchors.margins: 6 + spacing: 2 + // +1 for the "All artists" row, which is not in the + // document (it is the absence of a filter). + model: root.artists.length + 1 + boundsBehavior: Flickable.StopAtBounds + // Row 0 is "All artists" — the ABSENCE of a filter, so + // it is not in the document and the model is one longer + // than the artist list. Every child reads the delegate + // through its own id: a `parent.parent` chain here + // silently resolves to the Column once anything is + // nested one level deeper. + delegate: Rectangle { + id: railRow + required property int index + readonly property bool isAll: index === 0 + readonly property var a: isAll ? null : root.artists[index - 1] + readonly property bool active: isAll + ? (root.doc.selectedArtist || "") === "" + : (a !== null && a.selected === true) + width: railList.width + height: 48 + radius: 8 + color: railRow.active ? theme.surfaceElevated + : (railArea.containsMouse ? theme.surfaceHover : "transparent") + Column { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 8 + spacing: 2 + Text { + width: parent.width + text: railRow.isAll + ? QbzSession.tr("All artists", QbzSession.trRev) + : railRow.a.name + color: railRow.active ? theme.accent : theme.textPrimary + font.pixelSize: theme.fontLegal + font.weight: railRow.active + ? theme.weightSemibold : theme.weightRegular + elide: Text.ElideRight + } + Text { + width: parent.width + text: railRow.isAll + ? (root.doc.tracksText || "") + : railRow.a.meta + color: theme.textMuted + font.pixelSize: 11 + elide: Text.ElideRight + } + } + MouseArea { + id: railArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzOffline.selectArtist( + railRow.isAll ? "" : railRow.a.name) + } + } + QbzScrollBar { + target: railList + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + } + + // Right list — the three mutually-exclusive body branches, in + // the reference's order: loading · empty · the list. + Item { + width: parent.width - 210 - 14 + height: parent.height + + QbzSpinner { + anchors.centerIn: parent + visible: QbzOffline.managerLoading + } + + Text { + anchors.centerIn: parent + visible: !QbzOffline.managerLoading && root.rows.length === 0 + text: QbzSession.tr("No offline tracks yet", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + + ListView { + id: rowList + anchors.fill: parent + visible: !QbzOffline.managerLoading && root.rows.length > 0 + spacing: 2 + clip: true + model: root.rows + boundsBehavior: Flickable.StopAtBounds + // A cache that can hold thousands of tracks is born + // windowed (TRACK-RULES §6) — never a Flickable over + // every row. + cacheBuffer: 400 + + delegate: Rectangle { + id: rowItem + required property var modelData + readonly property bool isAlbum: modelData.kind === "album" + readonly property bool checked: + root.selected[modelData.trackId] === true + width: rowList.width + height: isAlbum ? 60 : 44 + radius: 8 + color: isAlbum ? theme.surfaceCard + : (rowArea.containsMouse ? theme.surfaceHover : "transparent") + + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: rowItem.isAlbum + ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: if (!rowItem.isAlbum) + QbzOffline.playTrack(rowItem.modelData.trackId) + } + + Row { + anchors.fill: parent + anchors.leftMargin: rowItem.isAlbum ? 10 : 16 + anchors.rightMargin: 12 + spacing: 12 + + // Checkbox (track rows) / spacer (albums). + Item { + width: 16 + height: parent.height + QbzCheckbox { + anchors.centerIn: parent + visible: !rowItem.isAlbum + checked: rowItem.checked + onToggled: root.toggleSelect(rowItem.modelData.trackId) + } + } + + // Leading — album cover or track number. + Item { + width: 40 + height: parent.height + Rectangle { + anchors.centerIn: parent + visible: rowItem.isAlbum + width: 40 + height: 40 + radius: 4 + color: theme.surfaceMain + RoundedImage { + anchors.fill: parent + radius: 4 + source: (rowItem.modelData.cover || "") !== "" + ? "file://" + rowItem.modelData.cover : "" + } + } + Text { + anchors.centerIn: parent + visible: !rowItem.isAlbum + text: rowItem.modelData.number || "" + color: theme.textMuted + font.pixelSize: theme.fontBody + } + } + + // Title + subtitle. + Column { + width: parent.width - 16 - 40 - metaText.width + - 30 * 2 - 18 - 12 * 6 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: rowItem.modelData.title || "" + color: theme.textPrimary + font.pixelSize: rowItem.isAlbum + ? theme.fontBody : theme.fontLegal + font.weight: rowItem.isAlbum + ? theme.weightSemibold : theme.weightRegular + elide: Text.ElideRight + } + Text { + width: parent.width + text: rowItem.modelData.subtitle || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + + Text { + id: metaText + anchors.verticalCenter: parent.verticalCenter + text: rowItem.modelData.meta || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignRight + } + + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "refresh-cw" + onClicked: rowItem.isAlbum + ? QbzOffline.redownloadAlbum(rowItem.modelData.albumId) + : QbzOffline.redownloadTrack(rowItem.modelData.trackId) + } + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "trash-2" + onClicked: rowItem.isAlbum + ? QbzOffline.removeAlbum(rowItem.modelData.albumId) + : QbzOffline.removeTrack(rowItem.modelData.trackId) + } + StatusGlyph { + anchors.verticalCenter: parent.verticalCenter + status: root.rowStatus(rowItem.modelData) + } + } + } + + QbzScrollBar { + target: rowList + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + } + } + + // Bulk bar — the shared component, mounted the way every other + // multi-select surface in this port mounts it. + QbzMultiSelectBar { + visible: root.selectedCount > 0 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + selectedCount: root.selectedCount + actions: [ + { "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false }, + { "id": "redownload", "label": QbzSession.tr("Re-download", QbzSession.trRev), "icon": "refresh-cw", "danger": false, "needsSelection": true }, + { "id": "remove", "label": QbzSession.tr("Remove", QbzSession.trRev), "icon": "trash-2", "danger": true, "needsSelection": true }, + { "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true } + ] + onAction: function (id) { root.bulkAction(id) } + } + } + } + + // Clear-all confirmation. The Slint fires it straight from the trash glyph; + // this port asks first, the way every other destructive row in Settings + // does (SettingsConfirmHost). Purging the cache can be an hours-long + // re-download and there is no undo. + QbzConfirmModal { + id: clearConfirm + // Fills the VIEW, like BlacklistManagerView.qml:523 — without it the + // modal has no size and its scrim covers nothing. + anchors.fill: parent + title: QbzSession.tr("Clear cache", QbzSession.trRev) + body: QbzSession.tr("Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above.", QbzSession.trRev) + confirmLabel: QbzSession.tr("Clear all", QbzSession.trRev) + danger: true + onConfirmed: QbzOffline.clearAll() + } +} diff --git a/crates/qbz-qt/src/offline_cache_qt.rs b/crates/qbz-qt/src/offline_cache_qt.rs index 2cb39b537..e30c94228 100644 --- a/crates/qbz-qt/src/offline_cache_qt.rs +++ b/crates/qbz-qt/src/offline_cache_qt.rs @@ -77,7 +77,33 @@ pub fn row_sink() -> CacheEventSink { } /// Build the DB row metadata from a catalog track (Slint `track_cache_info`). -fn track_cache_info(track: &qbz_models::Track) -> TrackCacheInfo { +/// +/// `album_fallback` is `(id, title)` of the album this batch came FROM, used +/// only when the track carries no nested album of its own. +/// +/// THAT IS NOT AN EDGE CASE — it is the album path's normal state. A `Track` +/// inside an `/album/get` payload has no nested `album` object (the envelope +/// would be repeating itself), so `cache_album` produced rows with +/// `album = NULL, album_id = NULL` for every track it ever queued. Measured on +/// a real index: 34 of 46 rows, three complete albums, all NULL — everything +/// downloaded through the album button since the CMAF path landed. +/// +/// The damage is not cosmetic. `album_id` is the key `remove_album`, +/// `redownload_album` and the downloaded-album registry all look rows up by, +/// so an album cached this way could not be removed, refreshed or recognised +/// as downloaded, and the Offline Manager could not group it. The reference +/// has the identical bug (`offline_cache.rs:149-150` + `:311`). +fn track_cache_info( + track: &qbz_models::Track, + album_fallback: Option<&(String, String)>, +) -> TrackCacheInfo { + let (album, album_id) = match track.album.as_ref() { + Some(a) => (Some(a.title.clone()), Some(a.id.clone())), + None => match album_fallback { + Some((id, title)) => (Some(title.clone()), Some(id.clone())), + None => (None, None), + }, + }; TrackCacheInfo { track_id: track.id, title: track.title.clone(), @@ -86,8 +112,8 @@ fn track_cache_info(track: &qbz_models::Track) -> TrackCacheInfo { .as_ref() .map(|p| p.name.clone()) .unwrap_or_default(), - album: track.album.as_ref().map(|a| a.title.clone()), - album_id: track.album.as_ref().map(|a| a.id.clone()), + album, + album_id, duration_secs: track.duration as u64, quality: "UltraHiRes".to_string(), bit_depth: track.maximum_bit_depth, @@ -126,7 +152,8 @@ pub fn cache_track(id: u64) { return; } }; - let info = track_cache_info(&track); + // A `/track/get` payload DOES carry its album, so no fallback here. + let info = track_cache_info(&track, None); let file_path = off.track_file_path(id, "flac"); let file_path_str = file_path.to_string_lossy().to_string(); @@ -159,6 +186,16 @@ pub fn cache_track(id: u64) { /// Cache a batch of already-fetched catalog tracks (album flow, multi-select /// bulk "Make available offline"; Slint `cache_tracks`). pub fn cache_tracks(tracks: Vec) { + cache_tracks_with_album(tracks, None) +} + +/// [`cache_tracks`] for a batch that came out of ONE album document, whose +/// `(album_id, album_title)` stamps every row that has no nested album of its +/// own — see `track_cache_info`. +pub fn cache_tracks_with_album( + tracks: Vec, + album_fallback: Option<(String, String)>, +) { if tracks.is_empty() { return; } @@ -186,7 +223,7 @@ pub fn cache_tracks(tracks: Vec) { let count = tracks.len(); for track in &tracks { let id = track.id; - let info = track_cache_info(track); + let info = track_cache_info(track, album_fallback.as_ref()); let file_path = off.track_file_path(id, "flac"); let file_path_str = file_path.to_string_lossy().to_string(); { @@ -231,7 +268,10 @@ pub fn cache_album(album_id: String) { crate::toast_qt::error(qbz_i18n::t("This album has no playable tracks")); return; } - cache_tracks(tracks); + // Stamp the album identity from the document we just fetched. Without + // it every row lands with a NULL album_id and the album becomes + // unremovable, unrefreshable and ungroupable — see `track_cache_info`. + cache_tracks_with_album(tracks, Some((album.id.clone(), album.title.clone()))); }); } @@ -266,6 +306,122 @@ pub fn redownload_album(album_id: String, failed_only: bool) { push_status(id, 1, 0.0); spawn_download(&off, id); } + crate::offline_manager_qt::refresh_if_open().await; + }); +} + +/// Re-download ONE track (Slint `redownload_track`): reset its row and spawn +/// the download, skipping a copy that is already in flight. +/// +/// Distinct from `refresh_cached` below, which DELETES the copy first: this +/// keeps the row (and its place in the index) and re-fetches into it, which is +/// what the manager's per-row refresh and its bulk arm want — a failed row has +/// nothing on disk to delete, and deleting a ready one would drop the user's +/// only copy if the network is gone by the time the fetch runs. +pub fn redownload_track(id: u64) { + crate::spawn(async move { + let Some(off) = offline_qt::get().await else { + return; + }; + { + let guard = off.db.lock().await; + let Some(db) = guard.as_ref() else { + return; + }; + if let Ok(Some(t)) = db.get_track(id) { + if matches!(t.status, qbz_offline_cache::OfflineCacheStatus::Downloading) { + return; + } + } + let _ = db.reset_track_for_redownload(id); + } + push_status(id, 1, 0.0); + spawn_download(&off, id); + crate::offline_manager_qt::refresh_if_open().await; + }); +} + +/// Remove EVERY offline copy of an album (Slint `remove_album`): the shared +/// maintenance sweep does the DB rows + the on-disk bundles, then the library +/// rows and the session set follow it. +pub fn remove_album(album_id: String) { + crate::spawn(async move { + let Some(off) = offline_qt::get().await else { + return; + }; + let report = { + let guard = off.db.lock().await; + let Some(db) = guard.as_ref() else { + return; + }; + let root = std::path::PathBuf::from(off.get_cache_path()); + qbz_offline_cache::maintenance::remove_album_cached_tracks(db, &root, &album_id) + }; + let report = match report { + Ok(r) => r, + Err(e) => { + log::error!("[qbz-qt] remove album {album_id} failed: {e}"); + return; + } + }; + { + let guard = off.library_db.lock().await; + if let Some(db) = guard.as_ref() { + for id in &report.removed_track_ids { + let _ = db.remove_qobuz_cached_track(*id); + } + } + } + for id in &report.removed_track_ids { + offline_qt::mark_cached(*id, false); + push_status(*id, 0, 0.0); + } + crate::toast_qt::success(qbz_i18n::t("Removed album from offline")); + crate::offline_manager_qt::refresh_if_open().await; + }); +} + +/// Open the cache directory in the desktop file manager (Slint `open_folder`). +/// +/// `xdg-open` / `open` by hand rather than the `rfd` used elsewhere in the +/// port: rfd opens a PICKER, and this row is "show me the folder". Same two +/// binaries the reference shells out to. +pub fn open_folder() { + crate::spawn(async move { + let Some(off) = offline_qt::get().await else { + return; + }; + let path = off.get_cache_path(); + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + if let Err(e) = std::process::Command::new(opener).arg(&path).spawn() { + log::warn!("[qbz-qt] open offline folder failed: {e}"); + } + }); +} + +/// Clear the WHOLE offline cache — DB rows, on-disk bundles and the library +/// rows (Slint `clear_all`). +/// +/// The Settings copy is precise about the blast radius and so is this: it +/// removes the cached AUDIO. Purchased downloads live in the user's own music +/// folder and are not touched by `purge_all_cached_files`. +pub fn clear_all() { + crate::spawn(async move { + let Some(off) = offline_qt::get().await else { + return; + }; + if let Err(e) = qbz_offline_cache::purge_all_cached_files(&off, &off.library_db).await { + log::error!("[qbz-qt] clear offline cache failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Couldn't clear the cache")); + return; + } + offline_qt::clear_cached_ids(); + crate::toast_qt::success(qbz_i18n::t("Cache cleared")); + crate::offline_manager_qt::refresh_if_open().await; }); } @@ -273,6 +429,7 @@ pub fn redownload_album(album_id: String, failed_only: bool) { pub fn remove_cached(id: u64) { crate::spawn(async move { remove_cached_inner(id, true).await; + crate::offline_manager_qt::refresh_if_open().await; }); } diff --git a/crates/qbz-qt/src/offline_manager_bridge.rs b/crates/qbz-qt/src/offline_manager_bridge.rs new file mode 100644 index 000000000..aff75bfbf --- /dev/null +++ b/crates/qbz-qt/src/offline_manager_bridge.rs @@ -0,0 +1,247 @@ +//! QbzOffline — Offline Cache Manager domain bridge (phase-23 per-domain +//! pattern; the pattern is documented in main.rs). +//! +//! The module is `qbz_offline_manager_bridge`, not `qbz_offline`: 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 (`QbzOffline`), not from the module. +//! +//! Props: the ONE manager document + its loading flag. +//! Invokables: the view's own toolbar and per-row actions. Every body is a +//! one-line forward into `crate::offline_manager_qt` (the data) or +//! `crate::offline_cache_qt` (the mutations) — the same split the reference +//! makes between `offline_manager.rs` and `offline_cache.rs`. +//! +//! NOT here: the checkbox state. Selection is QML-local in this port +//! (`offline_manager_qt`'s header explains why), so the two bulk arms take the +//! checked ids as a JSON array instead of asking Rust what is ticked. +//! +//! A new bridge is SEVEN artefacts: this file · its `build.rs` `rust_files` +//! entry (which IS the QML singleton registration — `#[qml_element] +//! #[qml_singleton]` only takes effect for files listed there) · the `main.rs` +//! `mod` line · `Main.qml`'s `boot()` call · the `ContentRouter.qml` arm · the +//! view in `build.rs`'s `qml_files` · the entry point that opens it. A bridge +//! missing from `build.rs` does NOT fail the build — its singleton simply does +//! not exist and every `QbzOffline.foo()` becomes a runtime ReferenceError +//! that `cargo check` cannot see. `qml_singleton_xref.py` is the gate. + +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_offline_manager_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 (`offline_manager_qt::ManagerDoc`: the stats bar, + /// the toolbar state, the artist rail and the flat album+track row + /// list); QML `JSON.parse`s it once per publish. + /// + /// `"{}"` and never `""` — the view parses it on its first frame and + /// an empty string throws. + #[qproperty(QString, manager_json)] + /// Defaults to TRUE, like `QbzBlacklist.blacklistLoading` and for the + /// same reason: the Loader mounts the view BEFORE + /// `Component.onCompleted` fires `reload()`, so a `false` default + /// flashes the empty state ("No offline tracks yet") before the + /// spinner — on the one screen whose whole job is to show you that you + /// DO have downloads. + #[qproperty(bool, manager_loading)] + + type QbzOffline = super::QbzOfflineRust; + + /// 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 QbzOffline>); + + /// Settings > Offline > "Open manager": push the route and load. + #[qinvokable] + fn open_manager(self: Pin<&mut QbzOffline>); + /// Re-read the index and re-publish. The view calls this from + /// `Component.onCompleted` — nav back/forward runs no per-view load. + #[qinvokable] + fn reload(self: Pin<&mut QbzOffline>); + + // --- Toolbar ------------------------------------------------------ + /// Artist rail click. `""` = All artists. + #[qinvokable] + fn select_artist(self: Pin<&mut QbzOffline>, name: QString); + /// 0 A-Z · 1 Recent · 2 Largest · 3 Smallest. + #[qinvokable] + fn set_sort(self: Pin<&mut QbzOffline>, index: i32); + /// "Failed only" chip. + #[qinvokable] + fn toggle_failed(self: Pin<&mut QbzOffline>); + /// The GB field: persist the cache ceiling and refresh. + #[qinvokable] + fn set_limit(self: Pin<&mut QbzOffline>, gb: i32); + + // --- Whole-cache actions (also the two Settings rows) ------------- + /// Open the cache directory in the desktop file manager. + #[qinvokable] + fn open_folder(self: Pin<&mut QbzOffline>); + /// Purge every cached copy. The CALLER confirms — this does not. + #[qinvokable] + fn clear_all(self: Pin<&mut QbzOffline>); + + // --- Per-row ------------------------------------------------------ + #[qinvokable] + fn remove_track(self: Pin<&mut QbzOffline>, track_id: QString); + #[qinvokable] + fn remove_album(self: Pin<&mut QbzOffline>, album_id: QString); + #[qinvokable] + fn redownload_track(self: Pin<&mut QbzOffline>, track_id: QString); + #[qinvokable] + fn redownload_album(self: Pin<&mut QbzOffline>, album_id: QString); + /// Track row body click — play it now. + #[qinvokable] + fn play_track(self: Pin<&mut QbzOffline>, track_id: QString); + + // --- Bulk (the checked rows; ids come down as a JSON array) ------- + #[qinvokable] + fn bulk_redownload(self: Pin<&mut QbzOffline>, ids_json: QString); + #[qinvokable] + fn bulk_remove(self: Pin<&mut QbzOffline>, ids_json: QString); + } + + impl cxx_qt::Threading for QbzOffline {} +} + +use qbz_offline_manager_bridge::QbzOffline; + +/// Rust side of the offline bridge (plain storage, phase-1 pattern). +pub struct QbzOfflineRust { + manager_json: QString, + manager_loading: bool, +} + +impl Default for QbzOfflineRust { + fn default() -> Self { + Self { + manager_json: QString::from("{}"), + manager_loading: true, + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop (bridges/mod.rs pattern) +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue an offline-bridge mutation onto the Qt event loop (no-op before +/// boot registers the thread). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzOffline>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +/// Parse a JSON array of track-id strings into ids. Anything unparseable is +/// dropped rather than failing the whole bulk action — the array is built by +/// QML from row ids the document itself published, so a bad entry means one +/// stale row, not a broken button. +fn ids_from_json(raw: &str) -> Vec { + serde_json::from_str::>(raw) + .unwrap_or_default() + .iter() + .filter_map(|s| s.parse::().ok()) + .collect() +} + +impl qbz_offline_manager_bridge::QbzOffline { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] offline Qt thread already registered"); + } + } + + pub fn open_manager(self: Pin<&mut Self>) { + crate::offline_manager_qt::open(); + } + pub fn reload(self: Pin<&mut Self>) { + crate::offline_manager_qt::load(); + } + + pub fn select_artist(self: Pin<&mut Self>, name: QString) { + crate::offline_manager_qt::select_artist(name.to_string()); + } + pub fn set_sort(self: Pin<&mut Self>, index: i32) { + crate::offline_manager_qt::set_sort(index); + } + pub fn toggle_failed(self: Pin<&mut Self>) { + crate::offline_manager_qt::toggle_failed(); + } + pub fn set_limit(self: Pin<&mut Self>, gb: i32) { + crate::offline_manager_qt::set_limit(gb); + } + + pub fn open_folder(self: Pin<&mut Self>) { + crate::offline_cache_qt::open_folder(); + } + pub fn clear_all(self: Pin<&mut Self>) { + crate::offline_cache_qt::clear_all(); + } + + pub fn remove_track(self: Pin<&mut Self>, track_id: QString) { + if let Ok(id) = track_id.to_string().parse::() { + crate::offline_cache_qt::remove_cached(id); + } + } + pub fn remove_album(self: Pin<&mut Self>, album_id: QString) { + crate::offline_cache_qt::remove_album(album_id.to_string()); + } + pub fn redownload_track(self: Pin<&mut Self>, track_id: QString) { + if let Ok(id) = track_id.to_string().parse::() { + crate::offline_cache_qt::redownload_track(id); + } + } + pub fn redownload_album(self: Pin<&mut Self>, album_id: QString) { + // `failed_only = false`: the row's refresh button re-fetches the whole + // album, the same arm the album page's "Refresh offline copy" takes. + crate::offline_cache_qt::redownload_album(album_id.to_string(), false); + } + pub fn play_track(self: Pin<&mut Self>, track_id: QString) { + if let Ok(id) = track_id.to_string().parse::() { + crate::play_track(id); + } + } + + pub fn bulk_redownload(self: Pin<&mut Self>, ids_json: QString) { + for id in ids_from_json(&ids_json.to_string()) { + crate::offline_cache_qt::redownload_track(id); + } + } + pub fn bulk_remove(self: Pin<&mut Self>, ids_json: QString) { + for id in ids_from_json(&ids_json.to_string()) { + crate::offline_cache_qt::remove_cached(id); + } + } +} + +#[cfg(test)] +mod tests { + use super::ids_from_json; + + #[test] + fn bulk_ids_parse_and_survive_a_bad_entry() { + assert_eq!(ids_from_json(r#"["1","22","333"]"#), vec![1, 22, 333]); + // One stale row must not cost the other two their action. + assert_eq!(ids_from_json(r#"["1","plex:9","3"]"#), vec![1, 3]); + assert!(ids_from_json("[]").is_empty()); + assert!(ids_from_json("not json").is_empty()); + } +} diff --git a/crates/qbz-qt/src/offline_manager_qt.rs b/crates/qbz-qt/src/offline_manager_qt.rs new file mode 100644 index 000000000..53dc54613 --- /dev/null +++ b/crates/qbz-qt/src/offline_manager_qt.rs @@ -0,0 +1,573 @@ +//! Offline Cache Manager controller — the Qt port of +//! `crates/qbz/src/offline_manager.rs`. +//! +//! Reads `index.db`, rolls the cached tracks up into artist → album → track, +//! applies the toolbar filters and publishes ONE JSON document on +//! `QbzOffline.managerJson`. Per-item actions are NOT here: they reuse +//! `offline_cache_qt::*`, which is the same split the reference makes. +//! +//! ## Two deliberate deltas from the reference, both because Qt is not Slint +//! +//! 1. **Covers are PATHS, not decoded pixels.** The Slint controller decodes +//! each album cover to a 96px `DecodedPixels` on the worker because +//! `slint::Image` is not `Send` and cannot be built off the UI thread. Qt +//! has no such constraint: the document carries the file path and +//! `RoundedImage` loads it with `sourceSize` set, so the decode happens +//! once, in Qt's own image thread, at the size the row draws. +//! 2. **Selection lives in QML.** The reference edits `selected` in place on +//! the Slint model and asks Rust for the checked ids. This port's +//! convention is the opposite and predates this view — the Local Library's +//! multi-select is `property var tracksSelected: ({})` in the view, with +//! the ids handed down as a JSON array on the bulk call +//! (`LocalLibraryView.qml:100,755`). Following it keeps the whole document +//! a pure function of the DB + the filters, so a rebuild can never fight +//! the user's checkboxes. The `selected` field is therefore absent from the +//! published rows. +//! +//! The filters DO live here, for the reference's own reason: a rebuild runs on +//! a worker and cannot read the view's state, so the artist selection, the +//! sort and the failed-only flag are Rust-side truth and travel back out in +//! the document. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use qbz_offline_cache::{CachedTrackInfo, OfflineCacheStatus}; +use serde::Serialize; + +use crate::offline_manager_bridge as bridge; +use cxx_qt_lib::QString; + +const GB: u64 = 1024 * 1024 * 1024; + +/// The route id this view records. `nav_qt` needs no registration for it — +/// a route is `record()` here plus an arm in `ContentRouter.qml`. +pub const ROUTE: &str = "offlinemanager"; + +// --------------------------------------------------------------------------- +// Toolbar filter state (Rust-side truth — see the module header) +// --------------------------------------------------------------------------- + +#[derive(Clone, Default)] +struct Filters { + /// "" = all artists. + selected_artist: String, + /// 0 alpha · 1 recent · 2 largest · 3 smallest. + sort: i32, + show_only_failed: bool, +} + +static FILTERS: Mutex> = Mutex::new(None); + +fn current_filters() -> Filters { + FILTERS + .lock() + .ok() + .and_then(|f| f.clone()) + .unwrap_or_default() +} + +fn edit_filters(f: impl FnOnce(&mut Filters)) { + if let Ok(mut guard) = FILTERS.lock() { + let mut cur = guard.clone().unwrap_or_default(); + f(&mut cur); + *guard = Some(cur); + } +} + +// --------------------------------------------------------------------------- +// The document +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct ArtistRow { + name: String, + /// "3 albums · 41 tracks", pre-formatted (the reference composes it in + /// Rust too — one plural form per locale, not two interpolations in QML). + meta: String, + selected: bool, +} + +#[derive(Serialize)] +struct Row { + /// "album" | "track". + kind: String, + #[serde(rename = "albumId")] + album_id: String, + #[serde(rename = "trackId")] + track_id: String, + title: String, + subtitle: String, + /// Album: "12 tracks · 480 MB". Track: "3:07". + meta: String, + /// 0 none · 2 downloading · 3 ready · 4 failed (the app-wide row-status + /// vocabulary — `offline_cache_qt` pushes the same integers live). + status: i32, + /// 0.0..1.0, only meaningful while `status == 2`. + progress: f32, + /// Absolute path to the album cover on disk, or "" — QML prefixes + /// `file://`. Album rows only. + cover: String, + /// 1-based position within its album. Track rows only. + number: String, +} + +#[derive(Serialize, Default)] +struct ManagerDoc { + #[serde(rename = "tracksCount")] + tracks_count: i32, + /// "41 tracks" — the PLURAL-CORRECT string, formatted here because there + /// is no plural seam in QML: `QbzSession.tr` takes one msgid and applies + /// no count (`session_bridge.rs:99`). Every other plural in the port that + /// tried to do it QML-side lost the singular form. + #[serde(rename = "tracksText")] + tracks_text: String, + /// "3.4 GB". + #[serde(rename = "sizeText")] + size_text: String, + /// "· of 5.0 GB" or "· Unlimited" — the reference keeps the separator + /// inside the string so the two halves concatenate without a conditional. + #[serde(rename = "limitText")] + limit_text: String, + /// 0.0..1.0 for the usage bar; 0.0 when there is no limit. + usage: f32, + /// The number in the GB field. 5 when unlimited (the reference's default + /// suggestion, not a limit that is in force). + #[serde(rename = "limitGb")] + limit_gb: i32, + #[serde(rename = "selectedArtist")] + selected_artist: String, + #[serde(rename = "sortIndex")] + sort_index: i32, + #[serde(rename = "showOnlyFailed")] + show_only_failed: bool, + artists: Vec, + rows: Vec, +} + +// --------------------------------------------------------------------------- +// Formatting (verbatim from the reference) +// --------------------------------------------------------------------------- + +/// `pub(crate)`: the lyrics-cache Settings row formats its size with the same +/// function in the reference (`offline_manager::human_size`). +pub(crate) fn human_size(bytes: u64) -> String { + let b = bytes as f64; + if bytes >= GB { + format!("{:.1} GB", b / GB as f64) + } else if bytes >= 1024 * 1024 { + format!("{:.0} MB", b / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{:.0} KB", b / 1024.0) + } else { + format!("{bytes} B") + } +} + +fn track_status_int(s: &OfflineCacheStatus) -> i32 { + match s { + OfflineCacheStatus::Ready => 3, + OfflineCacheStatus::Failed => 4, + _ => 2, + } +} + +fn fmt_duration(secs: u64) -> String { + format!("{}:{:02}", secs / 60, secs % 60) +} + +fn album_size(group: &[CachedTrackInfo]) -> u64 { + group.iter().map(|t| t.file_size_bytes).sum() +} + +// --------------------------------------------------------------------------- +// Publish +// --------------------------------------------------------------------------- + +fn publish(doc: &ManagerDoc, loading: bool) { + let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into()); + bridge::ui(move |mut b| { + b.as_mut().set_manager_json(QString::from(json.as_str())); + b.as_mut().set_manager_loading(loading); + }); +} + +/// Read the index, roll it up, publish. `pub` so the cache mutators can +/// refresh the view after their DB write (`refresh_if_open`). +pub async fn rebuild() { + let f = current_filters(); + let (tracks, limit, cache_path, healed): ( + Vec, + Option, + String, + std::collections::HashMap, + ) = match crate::offline_qt::get().await { + Some(off) => { + let limit = *off.limit_bytes.lock().await; + let cp = off.get_cache_path(); + let tracks = { + let guard = off.db.lock().await; + guard + .as_ref() + .and_then(|db| db.get_all_tracks().ok()) + .unwrap_or_default() + }; + // HEAL, only when something needs it. Rows queued through the + // album button before `track_cache_info` learned to stamp the + // album carry a NULL album title, and no re-download will fix the + // ones already on disk. library.db DOES know their album (the + // downloader writes a `qobuz_download` row per track), so the + // title is recovered from there by qobuz track id. + // + // Titles only — library.db keys albums by `"|<artist>"`, + // not by the Qobuz album id, so `album_id` stays NULL for those + // rows and the grouping below falls to its (artist, title) key. + // That is enough to render them correctly; it is NOT enough for + // `remove_album` / `redownload_album`, which look up BY id — those + // stay per-track for pre-existing rows, and the album buttons on + // such a group act on nothing. Re-downloading the album restamps + // it properly. + let needs_heal = tracks + .iter() + .any(|t| t.album.as_deref().unwrap_or("").is_empty()); + let healed = if needs_heal { + let guard = off.library_db.lock().await; + guard + .as_ref() + .and_then(|db| db.get_qobuz_download_tracks().ok()) + .map(|rows| { + rows.into_iter() + .filter_map(|r| { + let id = r.qobuz_track_id?; + let album = r.album.trim(); + (!album.is_empty()).then(|| (id as u64, album.to_string())) + }) + .collect() + }) + .unwrap_or_default() + } else { + std::collections::HashMap::new() + }; + (tracks, limit, cp, healed) + } + // Logged out: an empty manager, not a spinner that never ends. + None => (Vec::new(), None, String::new(), Default::default()), + }; + + let total_size: u64 = tracks.iter().map(|t| t.file_size_bytes).sum(); + let tracks_count = tracks.len() as i32; + + // group key -> (artist, album_title, tracks) in FIRST-SEEN order. The DB + // returns rows most-recently-accessed first, so that order IS the + // "recent" sort — which is why the sort below leaves it alone for index 1. + // + // THE KEY IS NOT `album_id` ALONE. It used to be, with every row missing + // one collapsing into a single `"__singles__"` bucket — so three complete + // albums by three different artists rendered as ONE album header carrying + // the first artist's name, and the artist rail credited all 34 tracks to + // that one artist while the other two vanished from it (owner smoke, + // 2026-08-16; reproduced against the real index). The reference has the + // same key and the same bug (`offline_manager.rs:142`). + // + // The ladder, most authoritative first: + // 1. the Qobuz album id — an album is an album, even a compilation + // whose tracks each name a different artist; + // 2. otherwise (artist, album title) — two artists can ship an album + // with the same title, so the artist is part of the key; + // 3. otherwise the artist alone: a per-ARTIST "Singles" bucket. Tracks + // with no album at all still group, but they can never again be + // mixed with another artist's. + let mut album_order: Vec<String> = Vec::new(); + let mut albums: BTreeMap<String, (String, String, Vec<CachedTrackInfo>)> = BTreeMap::new(); + for t in tracks { + let title = t + .album + .clone() + .filter(|a| !a.trim().is_empty()) + .or_else(|| healed.get(&t.track_id).cloned()) + .unwrap_or_default(); + let key = match t.album_id.as_deref().filter(|id| !id.trim().is_empty()) { + Some(id) => format!("id:{id}"), + // \x1f (unit separator) is the join: it cannot occur in an artist + // name or an album title, so "A\x1fB C" and "A B\x1fC" can never + // collide the way a "-" or a "|" would. + None if !title.is_empty() => format!("t:{}\x1f{title}", t.artist), + None => format!("s:{}", t.artist), + }; + if !albums.contains_key(&key) { + album_order.push(key.clone()); + } + // Raw literal, NOT a msgid — 1:1 with the reference + // (`offline_manager.rs:146`), and "Singles" is absent from all eight + // catalogs. Translating it here would add a ninth string that only + // this port has, which is exactly the drift the shared catalog exists + // to prevent. + let display_title = if title.is_empty() { + "Singles".to_string() + } else { + title + }; + albums + .entry(key) + .or_insert_with(|| (t.artist.clone(), display_title, Vec::new())) + .2 + .push(t); + } + + // Artist rail: A-Z (BTreeMap order), with per-artist album + track counts. + let mut artist_stats: BTreeMap<String, (usize, usize)> = BTreeMap::new(); + for aid in &album_order { + let (artist, _title, group) = &albums[aid]; + let e = artist_stats.entry(artist.clone()).or_insert((0, 0)); + e.0 += 1; + e.1 += group.len(); + } + let artists: Vec<ArtistRow> = artist_stats + .iter() + .map(|(name, (albums_n, tracks_n))| ArtistRow { + name: name.clone(), + meta: qbz_i18n::t_args( + "{} albums · {} tracks", + &[&albums_n.to_string(), &tracks_n.to_string()], + ), + selected: *name == f.selected_artist, + }) + .collect(); + + let mut order = album_order.clone(); + match f.sort { + 0 => order.sort_by(|a, b| albums[a].1.to_lowercase().cmp(&albums[b].1.to_lowercase())), + 2 => order.sort_by(|a, b| album_size(&albums[b].2).cmp(&album_size(&albums[a].2))), + 3 => order.sort_by(|a, b| album_size(&albums[a].2).cmp(&album_size(&albums[b].2))), + // 1 = recent — the DB's last_accessed_at DESC order, already there. + _ => {} + } + + let mut rows: Vec<Row> = Vec::new(); + for aid in &order { + let (artist, title, group) = &albums[aid]; + if !f.selected_artist.is_empty() && *artist != f.selected_artist { + continue; + } + let any_failed = group + .iter() + .any(|t| matches!(t.status, OfflineCacheStatus::Failed)); + if f.show_only_failed && !any_failed { + continue; + } + let any_active = group.iter().any(|t| { + matches!( + t.status, + OfflineCacheStatus::Queued | OfflineCacheStatus::Downloading + ) + }); + let all_ready = group + .iter() + .all(|t| matches!(t.status, OfflineCacheStatus::Ready)); + let album_status = if any_failed { + 4 + } else if any_active { + 2 + } else if all_ready { + 3 + } else { + 0 + }; + // The FIRST track whose cover resolves — within one album only some + // tracks carry one (per-track CMAF folders, mixed v1/v2 rows). + let cover = group + .iter() + .find_map(|t| t.resolve_cover_path(&cache_path)) + .unwrap_or_default(); + // The row's albumId is the REAL Qobuz id or "" — never the synthetic + // group key. The album buttons pass it to `remove_album` / + // `redownload_album`, which query `album_id` in SQL; handing them + // `t:Artist\u{1}Title` would match nothing while looking like it had. + let real_album_id = group + .first() + .and_then(|t| t.album_id.clone()) + .filter(|id| !id.trim().is_empty()) + .unwrap_or_default(); + rows.push(Row { + kind: "album".into(), + album_id: real_album_id.clone(), + track_id: String::new(), + title: title.clone(), + subtitle: artist.clone(), + meta: qbz_i18n::t_args( + "{} tracks · {}", + &[&group.len().to_string(), &human_size(album_size(group))], + ), + status: album_status, + progress: 0.0, + cover, + number: String::new(), + }); + for (i, t) in group.iter().enumerate() { + if f.show_only_failed && !matches!(t.status, OfflineCacheStatus::Failed) { + continue; + } + rows.push(Row { + kind: "track".into(), + album_id: real_album_id.clone(), + track_id: t.track_id.to_string(), + title: t.title.clone(), + subtitle: t.artist.clone(), + meta: fmt_duration(t.duration_secs), + status: track_status_int(&t.status), + progress: t.progress_percent as f32 / 100.0, + cover: String::new(), + number: (i + 1).to_string(), + }); + } + } + + let (limit_text, usage, limit_gb) = match limit { + Some(l) if l > 0 => ( + qbz_i18n::t_args("· of {}", &[&human_size(l)]), + (total_size as f32 / l as f32).clamp(0.0, 1.0), + (l / GB).max(1) as i32, + ), + _ => (qbz_i18n::t("· Unlimited"), 0.0, 5), + }; + + publish( + &ManagerDoc { + tracks_count, + tracks_text: qbz_i18n::tf( + "{} track", + "{} tracks", + tracks_count as i64, + &[&tracks_count.to_string()], + ), + size_text: human_size(total_size), + limit_text, + usage, + limit_gb, + selected_artist: f.selected_artist, + sort_index: f.sort, + show_only_failed: f.show_only_failed, + artists, + rows, + }, + false, + ); +} + +/// Settings > Offline > "Open manager": push the route, then load. +pub fn open() { + crate::nav_qt::record(ROUTE); + load(); +} + +/// Mark loading and rebuild. The view also calls this from +/// `Component.onCompleted` — nav back/forward runs no per-view load. +pub fn load() { + bridge::ui(|mut b| b.as_mut().set_manager_loading(true)); + crate::spawn(async move { rebuild().await }); +} + +/// Rebuild ONLY when the manager is the route on screen. +/// +/// The reference rebuilds unconditionally after every cache mutation, which is +/// free for it because the same call also refreshes its Slint model. Here the +/// mutators fire from the album page and the track rows far more often than +/// from this view, and a rebuild is a full `get_all_tracks()` — so the gate +/// keeps a download started from an album page off the SQLite file it does not +/// need to touch. Live per-row status still reaches the view either way: it +/// rides `QbzShell.trackCacheStatusChanged`, the same signal every other view +/// patches its rows from. +pub async fn refresh_if_open() { + if crate::nav_qt::current_view() == ROUTE { + rebuild().await; + } +} + +// --------------------------------------------------------------------------- +// Toolbar actions +// --------------------------------------------------------------------------- + +pub fn select_artist(name: String) { + edit_filters(|f| f.selected_artist = name); + crate::spawn(async move { rebuild().await }); +} + +pub fn set_sort(index: i32) { + edit_filters(|f| f.sort = index); + crate::spawn(async move { rebuild().await }); +} + +pub fn toggle_failed() { + edit_filters(|f| f.show_only_failed = !f.show_only_failed); + crate::spawn(async move { rebuild().await }); +} + +/// Set the cache size limit in GB, persist it, refresh. +/// +/// Clamped at 1 GB like the reference: the field is a free-text number input, +/// and a 0 would read as "unlimited" everywhere downstream while the user +/// believed they had just forbidden caching entirely. +pub fn set_limit(gb: i32) { + crate::spawn(async move { + let bytes = (gb.max(1) as u64) * GB; + if let Some(off) = crate::offline_qt::get().await { + *off.limit_bytes.lock().await = Some(bytes); + } + crate::offline_qt::persist_limit(bytes).await; + rebuild().await; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_size_matches_the_reference_thresholds() { + assert_eq!(human_size(0), "0 B"); + assert_eq!(human_size(512), "512 B"); + assert_eq!(human_size(2048), "2 KB"); + assert_eq!(human_size(5 * 1024 * 1024), "5 MB"); + // The GB arm is the only one with a decimal — the stats bar reads + // "3.4 GB", never "3 GB". + assert_eq!(human_size(3 * GB + GB / 2), "3.5 GB"); + } + + /// The status integers are the app-wide row vocabulary that + /// `offline_cache_qt::push_status` also emits. A mismatch here would show + /// a spinner on a ready row (or nothing on a failed one) with no error + /// anywhere — the album/track glyph is the only symptom. + #[test] + fn status_ints_match_the_row_vocabulary() { + assert_eq!(track_status_int(&OfflineCacheStatus::Ready), 3); + assert_eq!(track_status_int(&OfflineCacheStatus::Failed), 4); + assert_eq!(track_status_int(&OfflineCacheStatus::Queued), 2); + assert_eq!(track_status_int(&OfflineCacheStatus::Downloading), 2); + } + + #[test] + fn duration_is_mss_with_a_padded_seconds_field() { + assert_eq!(fmt_duration(0), "0:00"); + assert_eq!(fmt_duration(7), "0:07"); + assert_eq!(fmt_duration(187), "3:07"); + // No hours field, exactly like the reference. + assert_eq!(fmt_duration(3671), "61:11"); + } + + /// The filters are process-global; the actions must round-trip through + /// them or the rebuild reads defaults and the toolbar silently resets. + #[test] + fn filter_edits_round_trip() { + edit_filters(|f| { + f.selected_artist = "Boards of Canada".into(); + f.sort = 2; + f.show_only_failed = true; + }); + let f = current_filters(); + assert_eq!(f.selected_artist, "Boards of Canada"); + assert_eq!(f.sort, 2); + assert!(f.show_only_failed); + // Leave the global as the rest of the suite expects to find it. + edit_filters(|f| *f = Filters::default()); + } +} diff --git a/crates/qbz-qt/src/offline_qt.rs b/crates/qbz-qt/src/offline_qt.rs index de2c93e94..7264ca084 100644 --- a/crates/qbz-qt/src/offline_qt.rs +++ b/crates/qbz-qt/src/offline_qt.rs @@ -83,6 +83,19 @@ pub async fn activate(user_id: u64) { load_cached_ids().await; } +/// Persist the cache size limit (bytes) next to `index.db`, so the manager's +/// edit-limit survives a restart. 1:1 with `crates/qbz/src/offline.rs:89-96` — +/// SAME file name in the SAME per-user directory, which is what lets the two +/// frontends share one limit instead of quietly disagreeing about it. +pub async fn persist_limit(bytes: u64) { + if let Some(off) = get().await { + let path = limit_file(&off.get_cache_path()); + if let Err(e) = std::fs::write(&path, bytes.to_string()) { + log::warn!("[qbz-qt] offline: persist limit failed: {e}"); + } + } +} + /// Tear down the offline cache on logout. pub async fn deactivate() { if let Some(state) = slot().lock().await.take() { @@ -118,6 +131,15 @@ pub fn is_cached(track_id: &str) -> bool { cached_ids().lock().map(|s| s.contains(&id)).unwrap_or(false) } +/// Drop the whole session set — after a cache purge, where marking each id +/// individually would mean walking a list the purge already threw away +/// (Slint `clear_all`: `cached_ids().lock().clear()`). +pub fn clear_cached_ids() { + if let Ok(mut s) = cached_ids().lock() { + s.clear(); + } +} + pub fn mark_cached(track_id: u64, cached: bool) { if let Ok(mut set) = cached_ids().lock() { if cached {