release: 2.1.0 — Rebuild Q (You Can (Not) Redo)
Merge the Qt frontend release (pre-release) into main for 2.1.0.
Conflicts resolved by taking pre-release's versions, verified equivalent:
- .github/workflows/release-windows.yml: main's copy was a verbatim older
snapshot ('No behavior change'); pre-release carries the current workflow.
- README.md: pre-release's Qt rewrite already covers main's Aug-31 additions
(native/not-a-web-player, bit-perfect DAC, no EQ/DSP, TDL/SPTFY/DZR, offline
locks) in its dedicated 'What QBZ is and what it is not' section.
cc
# Conflicts:
# .github/workflows/release-windows.yml
# README.md
This commit is contained in:
2869 files changed
+462973
-309040
No files matched your search
@@ -0,0 +1,16 @@
|
||||
# Shaders: the committed .qsb are binary (NUL in the header) but also carry
|
||||
# raw CR bytes; a future `* text` would mangle them. Text formats are LF so a
|
||||
# Windows checkout with autocrlf cannot turn `2.1.0` into `2.1.0<CR>` in a
|
||||
# version grep, or CRLF the GLSL/QML sources.
|
||||
*.qsb binary
|
||||
*.frag text eol=lf
|
||||
*.vert text eol=lf
|
||||
*.qml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.po text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.ico binary
|
||||
# Not in the original plan list, added deliberately: .rs is the largest text
|
||||
# set in the tree (639 files) and a CRLF checkout of it is the same class of
|
||||
# invisible breakage the .toml line exists to prevent.
|
||||
*.rs text eol=lf
|
||||
@@ -1,32 +1,33 @@
|
||||
name: Bug report
|
||||
description: Report something that is not working as expected
|
||||
title: "[Bug] "
|
||||
description: Tell us about something that is not working as expected
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## ⚠️ Bug reports need logs
|
||||
Without logs I am debugging blind, and the report will almost certainly
|
||||
stall waiting on `needs: info` instead of getting fixed.
|
||||
Thanks for reporting this. Please describe what happened and, if
|
||||
possible, how to reproduce it. Logs help with most reports, but they
|
||||
are not required when the issue is self-explanatory or unavailable.
|
||||
|
||||
**Please attach your logs in the Logs section below** — it is the single
|
||||
most useful thing you can do to get this fixed fast
|
||||
(**Settings → Developer Mode → View Logs → Upload** the Terminal *and*
|
||||
Console tabs, then paste both URLs). If the app crashes before the UI
|
||||
opens, use the terminal fallback described in that section.
|
||||
If you investigated the cause, please include what you found. Patches
|
||||
are welcome, but never required.
|
||||
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Before submitting
|
||||
description: A bug report without these is much harder to act on.
|
||||
label: Area
|
||||
description: Which part of QBZ is affected?
|
||||
options:
|
||||
- label: I have attached my logs below (Terminal + Console), or explained why I cannot get them.
|
||||
required: true
|
||||
- label: I searched the existing issues and this is not a duplicate.
|
||||
required: true
|
||||
- label: I included concrete steps to reproduce.
|
||||
- Startup or crash
|
||||
- Playback or audio
|
||||
- Qobuz login or catalog
|
||||
- Local or offline library
|
||||
- Qobuz Connect or casting
|
||||
- Interface or navigation
|
||||
- qbzd or headless use
|
||||
- Installation, update or packaging
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
@@ -36,41 +37,29 @@ body:
|
||||
options:
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of the problem.
|
||||
label: What happened?
|
||||
description: Tell us what you observed and what you expected instead.
|
||||
placeholder: Describe the problem in your own words.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What actually happened.
|
||||
label: How can we reproduce it?
|
||||
description: If it is intermittent, describe what you were doing before it appeared.
|
||||
placeholder: |
|
||||
1. Open...
|
||||
2. Select...
|
||||
3. Observe...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -78,7 +67,8 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: QBZ version
|
||||
placeholder: e.g. v1.2.0
|
||||
description: Find it under About, or include the package version.
|
||||
placeholder: e.g. 2.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -88,94 +78,51 @@ body:
|
||||
label: Installation method
|
||||
options:
|
||||
- AppImage
|
||||
- .deb
|
||||
- .rpm
|
||||
- APT repository or .deb
|
||||
- RPM repository or .rpm
|
||||
- AUR
|
||||
- Gentoo overlay
|
||||
- Tarball
|
||||
- Nix or NixOS
|
||||
- Flatpak
|
||||
- DMG (macOS)
|
||||
- Snap
|
||||
- Homebrew Cask
|
||||
- Signed macOS DMG
|
||||
- Upstream macOS DMG
|
||||
- Windows MSI or ZIP
|
||||
- Standalone qbzd tarball
|
||||
- Built from source
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: distro
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Distribution / macOS version
|
||||
description: Linux distro or macOS version.
|
||||
placeholder: e.g. Arch Linux, Ubuntu 24.04, Fedora 43, macOS 15.3 Sequoia
|
||||
|
||||
- type: input
|
||||
id: de
|
||||
attributes:
|
||||
label: Desktop environment / WM
|
||||
description: Linux only.
|
||||
placeholder: e.g. KDE Plasma 6, GNOME 46
|
||||
|
||||
- type: dropdown
|
||||
id: display
|
||||
attributes:
|
||||
label: Display server
|
||||
description: Linux only.
|
||||
options:
|
||||
- Wayland
|
||||
- X11
|
||||
- N/A (macOS)
|
||||
|
||||
- type: input
|
||||
id: gpu
|
||||
attributes:
|
||||
label: GPU
|
||||
placeholder: e.g. NVIDIA RTX 3070, AMD RX 6600 XT, Apple M3
|
||||
|
||||
- type: dropdown
|
||||
id: audio_backend
|
||||
attributes:
|
||||
label: Audio backend
|
||||
options:
|
||||
- PipeWire
|
||||
- ALSA Direct
|
||||
- PulseAudio
|
||||
- System Default (macOS)
|
||||
- Auto
|
||||
|
||||
- type: input
|
||||
id: dac
|
||||
attributes:
|
||||
label: Audio device / DAC
|
||||
placeholder: e.g. Schiit Bifrost 2, FiiO K17
|
||||
label: Environment details
|
||||
description: Add only what seems relevant to this report.
|
||||
placeholder: |
|
||||
OS or distribution:
|
||||
Desktop environment / display server:
|
||||
Audio backend / DAC:
|
||||
GPU:
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
**Sharing your logs when you open an issue helps me fix it much faster — please include them whenever you can.**
|
||||
Logs make most bugs much faster to diagnose and often reveal whether
|
||||
the cause is QBZ, packaging, configuration or the surrounding system.
|
||||
|
||||
1. Open **Settings > Developer Mode > View Logs**
|
||||
2. Click **Upload** on both the **Terminal** and **Console** tabs
|
||||
3. Paste both URLs here
|
||||
|
||||
Where to find the logs (click the image to enlarge):
|
||||
|
||||
[](https://raw.githubusercontent.com/vicrodh/qbz/main/docs/release-1.1.15/screenshots/log_access_for_bug_reporting.png)
|
||||
|
||||
**If QBZ crashes on startup**, run from terminal:
|
||||
`flatpak run com.blitzfc.qbz 2>&1 | tee /tmp/qbz-crash.log`
|
||||
Then upload: `curl -F'file=@/tmp/qbz-crash.log' https://0x0.st`
|
||||
value: |
|
||||
- Terminal log URL:
|
||||
- Console log URL:
|
||||
If they are relevant and available, open Settings > Developer Mode >
|
||||
View Logs, upload both Terminal and Console, and paste both links here.
|
||||
For startup failures, terminal output is enough.
|
||||
placeholder: |
|
||||
Terminal log URL:
|
||||
Console log URL:
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
id: attachments
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help illustrate the issue.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Anything else that may help reproduce or understand the issue.
|
||||
label: Screenshots or additional context
|
||||
description: Drag in images or files, or add anything else that may help.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and troubleshooting
|
||||
url: https://github.com/vicrodh/qbz/discussions
|
||||
about: Ask for help or discuss QBZ without opening a bug report.
|
||||
@@ -1,6 +1,5 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea or improvement
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
@@ -12,6 +11,7 @@ body:
|
||||
- All platforms
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
name: Feedback
|
||||
description: General feedback, UX thoughts, or non-bug discussion
|
||||
title: "[Feedback] "
|
||||
labels: ["feedback"]
|
||||
body:
|
||||
- type: dropdown
|
||||
@@ -11,6 +10,7 @@ body:
|
||||
- ""
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
@@ -2,10 +2,40 @@
|
||||
|
||||
<!-- What does this PR do? -->
|
||||
|
||||
## Context and approach
|
||||
|
||||
<!--
|
||||
Bug fix: describe the observed behavior, root cause or best-supported diagnosis,
|
||||
and why this change fixes it.
|
||||
|
||||
Feature: describe the user need, the existing flow/components you inspected,
|
||||
and the constraints or invariants this design preserves.
|
||||
-->
|
||||
|
||||
## Validation
|
||||
|
||||
<!-- Which automated checks and manual tests did you run? -->
|
||||
|
||||
## AI / LLM assistance
|
||||
|
||||
<!--
|
||||
Required disclosure: write "None" if no LLM was used. Otherwise list every
|
||||
provider + exact model/version available to you and what each model did
|
||||
(repository/context mapping, problem definition, root-cause analysis,
|
||||
plan/contract/dependency graph, implementation, tests, code review, etc.). Use
|
||||
one line per model. A product/client/provider name alone is not specific enough
|
||||
when your tool displays a model identifier. There is no predefined model list,
|
||||
and saying only "prompted" or "AI-generated" is not sufficient. Prompt
|
||||
transcripts are not required.
|
||||
-->
|
||||
|
||||
- **Models and roles:**
|
||||
- **Human verification:**
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My change targets the `crates/` workspace. The Svelte `src/` and Tauri
|
||||
`src-tauri/` trees were **removed in 2.0.2** (preserved only at git tag
|
||||
`legacy-tauri-svelte` for reference); PRs against those paths can't be
|
||||
merged. If your fix targets the old tree, open an issue instead and we'll
|
||||
help you port it.
|
||||
- [ ] This PR targets `pre-release`, not `main`.
|
||||
- [ ] I reviewed the complete diff and described how I verified the change.
|
||||
- [ ] My change targets the live Qt/Rust tree. The Slint crates and the deleted
|
||||
Svelte/Tauri trees are historical references and cannot accept fixes.
|
||||
- [ ] If I changed UI text, I updated all eight locales.
|
||||
@@ -0,0 +1,283 @@
|
||||
# Linux build of the Qt frontend — ONE recipe, reused by every Linux release
|
||||
# workflow (release-linux, release-linux-aarch64, release-snap, release-flatpak)
|
||||
# via `workflow_call`, and dispatchable on its own as the pre-flight ("does the
|
||||
# release recipe still build; publish nothing") — the job build-slint.yml used
|
||||
# to do. The 2026-07-04 lesson was two copies of the same recipe drifting apart;
|
||||
# this file exists so there is exactly one.
|
||||
#
|
||||
# WHAT CHANGED VS THE SLINT ERA (all measured 2026-08-25, see
|
||||
# qbz-nix-docs/cicd/2026-08-25-qt-workflows-contracts/):
|
||||
# * No memory wall. `-p qbz-qt` never pulls the 30 GB `qbz-ui` unit: no swap
|
||||
# ceremony, no CARGO_BUILD_JOBS=1 / codegen-units=256 / opt-level 2 (those
|
||||
# were memory tiers and cost runtime performance for nothing now), no
|
||||
# nightly, no mold, no RUSTFLAGS (any of them re-keys the whole cache).
|
||||
# * Qt comes from aqt (official binaries), NOT from apt: ubuntu-24.04 ships
|
||||
# Qt 6.4.2 and has no QtQuick.Effects module at all; the tree needs 6.8.
|
||||
# * The runner is ubuntu-22.04 (glibc 2.35 floor, asserted below), which is
|
||||
# also the platform the official Qt binaries are built for.
|
||||
# * Two outputs per arch: the BARE binary (AUR / Gentoo / Flathub — ecosystems
|
||||
# with their own Qt >= 6.8) and an AppDir with Qt bundled (deb / rpm /
|
||||
# AppImage / snap — so Ubuntu 24.04 keeps working). The AppDir is built by
|
||||
# linuxdeploy + linuxdeploy-plugin-qt and is self-contained (proven by
|
||||
# running it offscreen with the Qt install removed).
|
||||
# * The smoke gate runs against THE ARTIFACT THAT SHIPS (the AppDir), not the
|
||||
# build tree: offscreen boot, zero QML complaints, core initialized. A
|
||||
# `cargo check` sees nothing of the QML — a missing bridge member compiles
|
||||
# clean and throws when the user opens the view.
|
||||
#
|
||||
# CONTRACT: artifact names are consumed downstream by name. Do not rename.
|
||||
# qbz-linux-<arch> bare binary (stripped by the workspace profile)
|
||||
# qbz-linux-<arch>-unstripped symbolication copy (CARGO_PROFILE_RELEASE_STRIP=none)
|
||||
# qbz-appdir-<arch> tar of the self-contained AppDir
|
||||
# <arch> is x86_64 | aarch64.
|
||||
|
||||
name: build-qt-linux
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
arch:
|
||||
description: "x86_64 | aarch64"
|
||||
required: true
|
||||
type: string
|
||||
source_ref:
|
||||
description: "Optional branch, tag or SHA to build"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
outputs:
|
||||
version:
|
||||
description: "Workspace version from crates/Cargo.toml"
|
||||
value: ${{ jobs.build.outputs.version }}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
arch:
|
||||
description: "Architecture to pre-flight"
|
||||
required: true
|
||||
default: x86_64
|
||||
type: choice
|
||||
options: [x86_64, aarch64]
|
||||
source_ref:
|
||||
description: "Optional branch, tag or SHA to build. Empty = dispatched ref."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# The Qt pin. Repository variables win (set QT_VERSION_LINUX in Settings →
|
||||
# Variables to bump without a commit); the literal is the measured floor.
|
||||
# 6.8 is the LTS line and the floor the tree needs once the six macOS-only
|
||||
# SafeArea/padding lines are guarded (contracts §1 / §4 #0). Keep in sync
|
||||
# with the same pin in test-crates.yml.
|
||||
jobs:
|
||||
build:
|
||||
name: build (${{ inputs.arch }})
|
||||
# aarch64 on 24.04-arm: aqt's arm64 Qt host tools (6.8.3 AND 6.9.3 qmake)
|
||||
# SIGSEGV on ubuntu-22.04-arm with a clean ldd (2026-08-29, two runs).
|
||||
# Accepted 2026-08-29: the arm64 DESKTOP floor is glibc 2.39; qbzd keeps
|
||||
# 2.35 (Pi OS bookworm) — see the floor gate below.
|
||||
runs-on: ${{ inputs.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
# x86_64 keeps the 6.8 LTS floor. aarch64 pins its own: aqt's 6.8.3
|
||||
# linux_gcc_arm64 qmake SEGFAULTS on ubuntu-22.04-arm (2026-08-29 run,
|
||||
# exit 139 on `qmake -query`), so the arm line takes 6.9.3 until an
|
||||
# arm64 6.8.x that runs there exists. `vars.QT_VERSION_LINUX_ARM64`
|
||||
# overrides without a commit.
|
||||
QT_VERSION: ${{ inputs.arch == 'aarch64' && (vars.QT_VERSION_LINUX_ARM64 || '6.9.3') || (vars.QT_VERSION_LINUX || '6.8.3') }}
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
|
||||
- name: Workspace version
|
||||
id: version
|
||||
run: |
|
||||
V="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
echo "workspace version: $V"
|
||||
|
||||
# Build-time deps only. fontconfig / freetype / wayland / xcb / mesa dev
|
||||
# headers were Slint's direct needs; Qt links its own. The xcb/wayland
|
||||
# RUNTIME libs are for linuxdeploy-plugin-qt: it resolves libqxcb.so's
|
||||
# dependencies on the runner and dies on a missing libxkbcommon-x11.so.0
|
||||
# (measured). patchelf + file are linuxdeploy's own tools.
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake clang libclang-dev nasm \
|
||||
libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libxkbcommon-dev libwayland-dev \
|
||||
libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 \
|
||||
libxcb-keysyms1 libxcb-render-util0 libxcb-xinerama0 libxcb-xkb1 \
|
||||
libxcb-shape0 libxcb-randr0 libxcb-sync1 libxcb-xfixes0 libxcb-glx0 \
|
||||
libwayland-client0 libwayland-egl1 libwayland-cursor0 libegl1 libgl1 \
|
||||
patchelf file binutils dbus-daemon
|
||||
|
||||
# Official Qt binaries — WITHOUT qtshadertools: with qsb on PATH build.rs
|
||||
# re-bakes shaders by mtime and a fresh checkout is nondeterministic.
|
||||
# The build ships the committed .qsb (build.rs warns and keeps them);
|
||||
# shader health is judged by test-crates.yml's shader-gate. The arm64
|
||||
# host exists in aqt from 6.7 — the first aarch64 dispatch is the proof.
|
||||
- name: Install Qt ${{ env.QT_VERSION }}
|
||||
uses: jurplel/install-qt-action@v4
|
||||
with:
|
||||
version: ${{ env.QT_VERSION }}
|
||||
host: ${{ inputs.arch == 'aarch64' && 'linux_arm64' || 'linux' }}
|
||||
target: desktop
|
||||
arch: ${{ inputs.arch == 'aarch64' && 'linux_gcc_arm64' || 'linux_gcc_64' }}
|
||||
cache: true
|
||||
|
||||
- name: Setup Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
# Cache key split by toolchain/profile and NEVER shared with the old
|
||||
# `slint-*` keys. Trusted manual preflights on pre-release may seed it,
|
||||
# so release-linux -> Flatpak/Snap and ARM -> Snap do not rebuild the
|
||||
# identical graph from zero. PR/fork refs remain read-only.
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: qt-linux-${{ inputs.arch }}-${{ env.QT_VERSION }}
|
||||
cache-on-failure: true
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
|
||||
|
||||
# NEVER `-p qbz`: crates/qbz (the frozen Slint binary) declares the same
|
||||
# bin name in the same target dir and would silently win the file.
|
||||
- name: Build (release)
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Keep symbols in this build: the unstripped copy is archived for
|
||||
# crash symbolication, then stripped for every shipped artifact.
|
||||
CARGO_PROFILE_RELEASE_STRIP: "none"
|
||||
# EXPLICIT qmake, as the macOS/Windows recipes do. On the arm64
|
||||
# runner cxx-qt-build failed with "Could not find Qt installation:
|
||||
# QtMissing" although QT_ROOT_DIR was correct (2026-08-29 run) —
|
||||
# the PATH lookup is not something to rely on across hosts.
|
||||
QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake
|
||||
CMAKE_PREFIX_PATH: ${{ env.QT_ROOT_DIR }}
|
||||
run: |
|
||||
# Diagnostics first: a qmake that cannot run here is the arm64
|
||||
# failure mode (segfault), and cxx-qt-build only says "QtMissing".
|
||||
uname -a; getconf PAGESIZE; ldd --version | head -1
|
||||
file "$QMAKE" || true
|
||||
ldd "$QMAKE" | grep -E 'not found' || true
|
||||
"$QT_ROOT_DIR/libexec/moc" --version || true
|
||||
"$QT_ROOT_DIR/libexec/qmlcachegen" --version || true
|
||||
"$QMAKE" -query QT_VERSION
|
||||
python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbz-qt
|
||||
cp crates/target/release/qbz qbz-unstripped
|
||||
strip -o qbz crates/target/release/qbz
|
||||
ls -la qbz qbz-unstripped
|
||||
|
||||
- name: Slint-free dep-graph gate (qbz-qt)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \
|
||||
| grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true)
|
||||
[ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; }
|
||||
|
||||
# The empirical-verification rule: the README/website say what objdump
|
||||
# says, not what the runner image advertises. Two floors, decided
|
||||
# 2026-08-29: x86_64 2.35 (ubuntu-22.04 runner); aarch64 2.39 — the
|
||||
# arm64 Qt host tools only run on ubuntu-24.04-arm — while qbzd stays at
|
||||
# 2.35 on both (its own job, 22.04-arm) for the 1 GB Pi OS boards.
|
||||
# README "Requires glibc" + the website glibcNote must match these.
|
||||
- name: glibc floor gate (x86_64 <= 2.35, aarch64 <= 2.39)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
floor=$(objdump -T qbz | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -Vu | tail -1)
|
||||
limit=$([ "${{ inputs.arch }}" = aarch64 ] && echo GLIBC_2.39 || echo GLIBC_2.35)
|
||||
echo "glibc floor: $floor (limit $limit)"
|
||||
highest=$(printf '%s\n' "$floor" "$limit" | sort -V | tail -1)
|
||||
[ "$highest" = "$limit" ] || { echo "::error::glibc floor $floor exceeds $limit"; exit 1; }
|
||||
|
||||
# Self-contained AppDir. The plugin scans QML_SOURCES_PATHS for imports
|
||||
# and copies exactly the Qt QML modules the app uses (Effects, labs,
|
||||
# Controls, ...) plus the platform plugins — but xcb ONLY unless told
|
||||
# otherwise: Wayland needs both EXTRA_PLATFORM_PLUGINS and
|
||||
# EXTRA_QT_MODULES=waylandcompositor (libQt6WaylandClient + the
|
||||
# wayland-* plugin dirs). offscreen is what the smoke below runs on.
|
||||
# It writes usr/bin/qt.conf and patches RPATH to $ORIGIN/../lib, which
|
||||
# is why the tree works from any install prefix with no env at all.
|
||||
- name: Build AppDir (linuxdeploy + Qt plugin)
|
||||
env:
|
||||
QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake
|
||||
QML_SOURCES_PATHS: ${{ github.workspace }}/crates/qbz-qt/qml
|
||||
EXTRA_PLATFORM_PLUGINS: "libqoffscreen.so;libqwayland-generic.so;libqwayland-egl.so"
|
||||
EXTRA_QT_MODULES: waylandcompositor
|
||||
LD_LIBRARY_PATH: ${{ env.QT_ROOT_DIR }}/lib
|
||||
ARCH_TAG: ${{ inputs.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o linuxdeploy \
|
||||
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH_TAG}.AppImage"
|
||||
curl -fsSL -o linuxdeploy-plugin-qt \
|
||||
"https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-${ARCH_TAG}.AppImage"
|
||||
chmod +x linuxdeploy linuxdeploy-plugin-qt
|
||||
# The desktop file goes in under the MPRIS DesktopEntry basename
|
||||
# (com.blitzfc.qbz.desktop), so an integrated AppImage resolves the
|
||||
# media-widget icon like the deb/rpm do. Icon=qbz -> qbz.png.
|
||||
cp packaging/linux/qbz.desktop com.blitzfc.qbz.desktop
|
||||
cp packaging/icons/256x256.png qbz.png
|
||||
./linuxdeploy --appimage-extract-and-run --appdir AppDir \
|
||||
-e qbz -d com.blitzfc.qbz.desktop -i qbz.png --plugin qt
|
||||
scripts/packaging/desktop_entry_check.sh --appdir AppDir
|
||||
# Nothing in the app uses Qt SQL / network-information plugins.
|
||||
rm -rf AppDir/usr/plugins/sqldrivers AppDir/usr/plugins/networkinformation
|
||||
mkdir -p AppDir/usr/share/licenses/qbz
|
||||
cp LICENSE AppDir/usr/share/licenses/qbz/LICENSE
|
||||
cp -r licenses AppDir/usr/share/licenses/qbz/third-party
|
||||
echo "platforms: $(find AppDir/usr/plugins/platforms -maxdepth 1 -type f -printf '%f ')"
|
||||
test -f AppDir/usr/plugins/platforms/libqwayland-generic.so
|
||||
test -f AppDir/usr/plugins/platforms/libqoffscreen.so
|
||||
test -f AppDir/usr/qml/QtQuick/Effects/libeffectsplugin.so
|
||||
test -f AppDir/usr/bin/qt.conf
|
||||
test -f AppDir/usr/share/licenses/qbz/third-party/Apache-2.0-dst-decoder.txt
|
||||
du -sh AppDir
|
||||
|
||||
# The shipped AppDir must initialize AND survive the observation window.
|
||||
# Hide the SDK and use a fresh profile/private bus; early exit (even 0)
|
||||
# fails. The helper reaps the launcher and its private process group.
|
||||
- name: Offscreen smoke (the AppDir that ships)
|
||||
run: |
|
||||
python3 scripts/packaging/smoke_release.py ./AppDir/AppRun \
|
||||
--hide-qt "$QT_ROOT_DIR" --seconds 60 --log smoke.log
|
||||
|
||||
- name: Upload deployed startup evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-appdir-smoke-${{ inputs.arch }}
|
||||
path: smoke.log*
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload bare binary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-${{ inputs.arch }}
|
||||
path: qbz
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
|
||||
- name: Upload unstripped binary (symbolication)
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-${{ inputs.arch }}-unstripped
|
||||
path: qbz-unstripped
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
|
||||
# tar keeps the symlinks (AppRun, .DirIcon) and the exec bits that
|
||||
# upload-artifact's zip would drop.
|
||||
- name: Upload AppDir
|
||||
run: tar -cf appdir.tar AppDir
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-appdir-${{ inputs.arch }}
|
||||
path: appdir.tar
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
@@ -1,61 +0,0 @@
|
||||
name: Build Slint (self-hosted)
|
||||
|
||||
# Builds qbz-slint on the self-hosted runner registered on the dev box
|
||||
# (`qbz-box`, labels: self-hosted,linux,x64,qbz-builder). The box CAN compile
|
||||
# qbz-slint (proven locally), so this is the reliable path to a real CI/CD
|
||||
# artifact while the GitHub-hosted private runner (2 vCPU / 8 GB) cannot.
|
||||
#
|
||||
# Self-hosted runners keep their work directory between runs, so `target/`
|
||||
# persists → builds are INCREMENTAL after the first (no `actions/cache` needed).
|
||||
#
|
||||
# MANUAL trigger only (workflow_dispatch): the box shares RAM with the desktop
|
||||
# and earlyoom will SIGTERM the build if RAM runs low. Trigger this when the box
|
||||
# is free (close heavy apps / TTY), and do NOT run a local build at the same
|
||||
# time (one heavy build at a time — the box hard-freezes on double-build OOM).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: selfhosted-slint
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, qbz-builder]
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Free RAM check (earlyoom will kill the build if too low)
|
||||
run: |
|
||||
free -h | head -2
|
||||
avail=$(free -m | awk '/^Mem:/ {print $7}')
|
||||
echo "MemAvailable: ${avail} MB"
|
||||
if [ "${avail}" -lt 16000 ]; then
|
||||
echo "::warning::Only ${avail} MB free — earlyoom may SIGTERM the qbz-slint rustc. Close apps / drop to TTY for headroom."
|
||||
fi
|
||||
|
||||
# Desktop-proof recipe, root-caused 2026-07-04 (ftrace): earlyoom's
|
||||
# RAM-only trigger SIGTERM'd rustc whenever global MemAvailable dipped
|
||||
# to ~3 GB — the cap margin, not the settings, was the bug. With
|
||||
# slint-run.sh anchoring memory.high at avail-9G, this exact recipe
|
||||
# (threads=2, cgu=256, opt=3, capped) completed with a 5.7 GB global
|
||||
# floor and zero signals while the desktop ran. Same flags as the
|
||||
# distribution profile → the runner cache stays warm between runs.
|
||||
- name: Build qbz-slint (desktop-proof — capped, threads=2, opt-3)
|
||||
run: NORUN=1 CAPPED=1 THREADS=2 CODEGEN_UNITS=256 OPT=3 ./scripts/slint-run.sh
|
||||
|
||||
- name: Strip + report size
|
||||
run: |
|
||||
BIN=crates/target/release/qbz
|
||||
echo "unstripped:"; ls -lh "$BIN" | awk '{print $5}'
|
||||
cp "$BIN" qbz-linux-x86_64
|
||||
strip qbz-linux-x86_64 || true
|
||||
echo "stripped:"; ls -lh qbz-linux-x86_64 | awk '{print $5}'
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-x86_64
|
||||
path: qbz-linux-x86_64
|
||||
if-no-files-found: error
|
||||
@@ -1,114 +0,0 @@
|
||||
name: Build Slint (linux)
|
||||
|
||||
# Builds the Slint app on a stock GitHub-hosted runner. The wall is the qbz_ui
|
||||
# rustc (the generated Slint UI = ONE ~1.6M-line module): its peak memory DEMAND
|
||||
# is ~30 GB (measured RSS+Swap, 2026-06) and it is FRONTEND-bound — codegen knobs
|
||||
# (codegen-units, opt-level, CARGO_BUILD_JOBS, -Z threads) barely move it (jobs=1
|
||||
# only shaved ~3 GB vs cargo defaults). So the build is NOT made to fit by tuning;
|
||||
# it is made to fit by giving it enough RAM+SWAP to hold ~30 GB. A stock
|
||||
# `ubuntu-24.04` runner has ~16 GB RAM, so this job adds a large swapfile so
|
||||
# RAM(16) + swap(~4 base + 18) ≈ 38 GB comfortably exceeds the ~30 GB demand: the
|
||||
# resident working set stays near RAM size while cold pages page out (slow but it
|
||||
# completes). The MIN-tier rustc env below keeps the resident set as low as
|
||||
# possible. rust-cache (added below) makes this heavy compile happen ONCE per
|
||||
# qbz_ui change, not on every push — that, not the peak, is the minute saver.
|
||||
# Build-only for now (no signing/bundling).
|
||||
|
||||
on:
|
||||
# Manual trigger ONLY (pick the ref in the Actions UI). Repo policy: public
|
||||
# workflows fire on release tags, never on plain pushes — a push to main IS
|
||||
# a release merge, and the tag that follows immediately fires the real
|
||||
# release builds, so an auto-build here would just double-burn ~2h of
|
||||
# runner time for nothing.
|
||||
workflow_dispatch:
|
||||
|
||||
# A new push to slint-mvp cancels the older in-flight build — only the latest
|
||||
# commit matters, and stacked ~20-min builds are the main minute-burn.
|
||||
concurrency:
|
||||
group: build-slint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Free disk + swap
|
||||
run: |
|
||||
echo "::group::before"; df -h /; free -h; echo "::endgroup::"
|
||||
# On ubuntu-24.04 there is no separate large /mnt disk — everything is
|
||||
# on the 72 GB root (~16 GB free after the image). Reclaim the big
|
||||
# preinstalled toolchains we don't need (~25-30 GB) so the cargo target
|
||||
# fits, then add a modest swap cushion for the qbz-slint rustc peak.
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
||||
/usr/local/lib/node_modules || true
|
||||
sudo docker image prune --all --force || true
|
||||
# The runner ships an active /swapfile (~4 GB); add a LARGE extra swap
|
||||
# so RAM(16) + swap(~4 + 18) ≈ 38 GB holds the measured ~30 GB qbz_ui
|
||||
# demand (frontend-bound; not tunable away). 8 GB was NOT enough
|
||||
# (16+4+8 ≈ 28 GB < 30 GB → OOM risk). Guarded so a swap hiccup never
|
||||
# fails the job.
|
||||
( sudo fallocate -l 18G /swap_extra \
|
||||
&& sudo chmod 600 /swap_extra \
|
||||
&& sudo mkswap /swap_extra \
|
||||
&& sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)"
|
||||
echo "::group::after"; df -h /; free -h; echo "::endgroup::"
|
||||
|
||||
- name: System dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake clang libclang-dev mold nasm \
|
||||
libasound2-dev libjack-jackd2-dev \
|
||||
libfontconfig1-dev libfreetype-dev \
|
||||
libxkbcommon-dev libwayland-dev libxcb1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev \
|
||||
libdbus-1-dev libssl-dev \
|
||||
libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
|
||||
libjpeg-dev zlib1g-dev libgumbo-dev
|
||||
|
||||
- name: Rust toolchain (pinned dated nightly — build uses -Z threads)
|
||||
# Pinned (not floating @nightly) so rustc -V is byte-stable across runs:
|
||||
# rust-cache embeds the rustc version in its key, and a floating nightly
|
||||
# re-keys the cache + invalidates every Cargo fingerprint daily (the
|
||||
# from-scratch-every-run waste). Bump this date deliberately when a newer
|
||||
# nightly feature is actually needed.
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: nightly-2026-06-23
|
||||
|
||||
- name: Cargo cache (Swatinem/rust-cache)
|
||||
# THE biggest minute-saver: caches ~/.cargo + crates/target so a push
|
||||
# that only touches controllers restores the prebuilt qbz-ui rlib (the
|
||||
# ~20 GB / ~20-min compilation unit) instead of recompiling it; only the
|
||||
# cheap qbz bin then rebuilds + links. `workspaces` MUST point at crates/
|
||||
# (manifest is crates/Cargo.toml, artifacts land in crates/target — not
|
||||
# the default `. -> target`). save-if gates cache WRITES to slint-mvp so
|
||||
# dispatch/PR runs read but don't churn the 10 GB Actions cache budget.
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: slint-linux
|
||||
cache-on-failure: true
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/slint-mvp' }}
|
||||
|
||||
- name: Build qbz-slint (memory-tuned, MIN tier)
|
||||
env:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_BUILD_JOBS: '1'
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256'
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: '2'
|
||||
RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1'
|
||||
run: |
|
||||
cargo build --release --manifest-path crates/Cargo.toml -p qbz
|
||||
echo "::group::result"; ls -lh crates/target/release/qbz; df -h /; free -h; echo "::endgroup::"
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-x86_64
|
||||
path: crates/target/release/qbz
|
||||
if-no-files-found: error
|
||||
@@ -52,3 +52,49 @@ jobs:
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: ['platform:linux']
|
||||
});
|
||||
|
||||
- name: Welcome and label Windows reports
|
||||
if: steps.platform.outputs.value == 'Windows'
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue.number;
|
||||
const label = 'platform:windows';
|
||||
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: '0078D4',
|
||||
description: 'Issues specific to Windows'
|
||||
});
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue,
|
||||
labels: [label]
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue,
|
||||
body: [
|
||||
'<!-- qbz-windows-report-note -->',
|
||||
'Thanks for reporting this on Windows — Windows reports are welcome.',
|
||||
'',
|
||||
'The Windows build comes from the same shared QBZ codebase, so it receives the features and fixes developed for the project as a whole. At the moment, however, Windows does not have a dedicated maintainer or regular testing coverage. Windows-specific problems may therefore take longer to reproduce and resolve.',
|
||||
'',
|
||||
'We are looking for someone who uses Windows regularly and would like to help test releases or maintain the platform. If that sounds like you, we would love to hear from you.'
|
||||
].join('\n')
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
# The Windows sibling of test-crates.yml's `qt-gate` job.
|
||||
#
|
||||
# Same five gates, same complaint regex, same "did it reach QbzCore" check, so
|
||||
# a Windows regression is caught by the same shape of evidence as a Linux one.
|
||||
#
|
||||
# ── TRIGGERS ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The repository rule, and the same one every release-*.yml follows:
|
||||
# AUTOMATIC RUNS ON A TAG ONLY, and a manual dispatch is always available.
|
||||
# No branch pushes and no pull-request trigger.
|
||||
#
|
||||
# That also settles why this is not a job inside test-crates.yml, which runs on
|
||||
# every PR to pre-release: a Qt build on a Windows runner is roughly fifteen
|
||||
# minutes of a runner billing at twice the Linux rate, and Windows is
|
||||
# explicitly not a supported QBZ platform. Gating every Linux contributor's PR
|
||||
# on it would block work on a platform nobody promised.
|
||||
#
|
||||
# On a `v*` tag this runs beside release-windows.yml and answers a different
|
||||
# question: the release job proves the artifact BUILDS and boots, this one
|
||||
# proves the QML types resolve, the audits pass, the dependency graph is
|
||||
# Slint-free and the tests are green. A tag that fails here should not ship.
|
||||
#
|
||||
# ── WHAT THIS CATCHES THAT NOTHING ELSE DOES ──────────────────────────────
|
||||
#
|
||||
# `cargo check` sees nothing of QML: a wrong type name or a property that does
|
||||
# not exist compiles perfectly and fails at RUNTIME, and because the failing
|
||||
# component hangs off AppShell the whole shell fails to instantiate -- the app
|
||||
# opens to a GREY SCREEN. That happened during the port and reached the owner.
|
||||
# `qmllint` finds it in seconds. The shared qmllint_gate.sh now gives Linux and
|
||||
# Windows the same native-type metadata and import checks.
|
||||
name: qt-gate-windows
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Optional ref to gate (branch, tag or SHA). Empty = the default checkout."
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: qt-gate-windows-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# The WINDOWS pin, which is ahead of the Linux floor on purpose: the port
|
||||
# needs Qt 6.9's ExpandedClientAreaHint for the custom title bar.
|
||||
QT_VERSION: ${{ vars.QT_VERSION_WINDOWS || '6.9.3' }}
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
qt-gate-windows:
|
||||
name: qt gate (windows)
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
# cxx-qt deep-copies include trees and qmlcachegen writes ~340 .cpp files
|
||||
# under OUT_DIR; the paths get long enough to hit MAX_PATH without this.
|
||||
- name: Enable long paths
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
|
||||
-Name LongPathsEnabled -Value 1
|
||||
git config --system core.longpaths true
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# Empty falls back to the default ref, which is what a tag push wants.
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install Qt ${{ env.QT_VERSION }}
|
||||
uses: jurplel/install-qt-action@v4
|
||||
with:
|
||||
version: ${{ env.QT_VERSION }}
|
||||
host: windows
|
||||
target: desktop
|
||||
arch: win64_msvc2022_64
|
||||
modules: qtshadertools
|
||||
cache: true
|
||||
|
||||
- name: MSVC dev environment
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
# Git for Windows also ships a POSIX `link.exe`; force rustc to use the
|
||||
# MSVC linker selected above instead of trusting PATH ordering.
|
||||
- name: Pin MSVC linker
|
||||
shell: pwsh
|
||||
run: |
|
||||
$linker = Join-Path $env:VCToolsInstallDir "bin\Hostx64\x64\link.exe"
|
||||
if (-not (Test-Path $linker)) { throw "MSVC linker not found at $linker" }
|
||||
"CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER=$linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
Write-Host "Cargo linker: $linker"
|
||||
|
||||
# aws-lc-sys (via rustls) ASSEMBLES on Windows x86_64. Its shipped
|
||||
# prebuilt objects are used only when nasm is ABSENT, and without either
|
||||
# the build fails hard with "Missing dependency: nasm" -- after the whole
|
||||
# compile, which makes it an expensive way to find out.
|
||||
- name: NASM (aws-lc-sys)
|
||||
uses: ilammy/setup-nasm@v1
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: qt-gate-windows-${{ env.QT_VERSION }}
|
||||
cache-on-failure: true
|
||||
# Tags and the trusted integration branch may seed the cache. A
|
||||
# manual dispatch on any feature/fork ref remains read-only.
|
||||
save-if: ${{ startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/pre-release' }}
|
||||
|
||||
# QML is not compile-checked. A bad type name or property is a grey
|
||||
# screen at runtime, not an error here, so this runs FIRST: it is the
|
||||
# cheapest gate and it catches the most expensive failure.
|
||||
- name: qmllint (QML type check)
|
||||
shell: bash
|
||||
run: scripts/qml-audits/qmllint_gate.sh "$QT_ROOT_DIR"
|
||||
|
||||
# The same five the Linux gate runs, and they need Qt's own
|
||||
# plugins.qmltypes to resolve QtQuick types -- hence after the install.
|
||||
- name: QML audits
|
||||
shell: bash
|
||||
env:
|
||||
QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for a in qml_resolution_audit qml_singleton_xref qml_eager_tab_audit qml_module_registration_audit qml_icon_bake_audit qml_on_prefixed_property_audit; do
|
||||
echo "::group::$a"
|
||||
python "scripts/qml-audits/$a.py" "$PWD/crates/qbz-qt"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
- name: Slint-free dep-graph gate (qbz-qt)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \
|
||||
| grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true)
|
||||
[ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; }
|
||||
echo "dep graph is Slint-free"
|
||||
|
||||
- name: cargo test + build (qbz-qt)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/qt-cargo.py test --manifest-path crates/Cargo.toml -j 4 -p qbz-qt --no-fail-fast
|
||||
python3 scripts/qt-cargo.py build --manifest-path crates/Cargo.toml -j 4 -p qbz-qt
|
||||
|
||||
# The Linux gate isolates with private XDG dirs and a private session
|
||||
# bus. Windows needs neither a bus (the single instance is a named mutex)
|
||||
# nor XDG, but it DOES need its own %APPDATA%/%LOCALAPPDATA%: every store
|
||||
# QBZ opens is derived from them, and a smoke that writes into the
|
||||
# runner's real profile is a smoke that can pass because of what a
|
||||
# previous run left behind.
|
||||
- name: Offscreen smoke
|
||||
shell: pwsh
|
||||
run: |
|
||||
$iso = Join-Path $env:RUNNER_TEMP "qbz-smoke"
|
||||
New-Item -ItemType Directory -Force -Path "$iso\roaming", "$iso\local" | Out-Null
|
||||
$env:APPDATA = "$iso\roaming"
|
||||
$env:LOCALAPPDATA = "$iso\local"
|
||||
$env:QT_QPA_PLATFORM = "offscreen"
|
||||
$env:RUST_LOG = "info"
|
||||
$exe = "crates\target\debug\qbz.exe"
|
||||
$p = Start-Process -FilePath $exe -RedirectStandardError smoke.log `
|
||||
-RedirectStandardOutput smoke.out -PassThru
|
||||
if (-not $p.WaitForExit(75000)) { $p.Kill(); Start-Sleep -Seconds 2 }
|
||||
|
||||
$lines = (Get-Content smoke.log -ErrorAction SilentlyContinue | Measure-Object -Line).Lines
|
||||
Write-Host "smoke log: $lines lines"
|
||||
if ($lines -lt 10) {
|
||||
Get-Content smoke.log -ErrorAction SilentlyContinue
|
||||
Write-Host "::error::the app did not start"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The same complaint set the Linux gate uses, verbatim.
|
||||
$pat = 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|non-existent property|failed to load component|is not installed'
|
||||
$errs = Select-String -Path smoke.log -Pattern $pat |
|
||||
Where-Object { $_.Line -notmatch 'propertyCache' }
|
||||
if ($errs) {
|
||||
$errs | Select-Object -First 20 | ForEach-Object { $_.Line }
|
||||
Write-Host "::error::$($errs.Count) QML complaint(s)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Select-String -Path smoke.log -Pattern 'QbzCore initialized' -Quiet)) {
|
||||
Get-Content smoke.log -Tail 20
|
||||
Write-Host "::error::never reached QbzCore init"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "smoke OK (0 QML complaints, core initialized)"
|
||||
|
||||
- name: Upload the smoke log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: smoke-log-windows
|
||||
path: smoke.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -1,7 +1,10 @@
|
||||
name: Redirect PRs to pre-release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# No contributor code is checked out or executed. pull_request_target gives
|
||||
# this API-only workflow the write token it needs for PRs opened from forks;
|
||||
# pull_request downgrades GITHUB_TOKEN to read-only for those PRs.
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened]
|
||||
|
||||
|
||||
+229
-117
@@ -1,24 +1,28 @@
|
||||
name: release-aur
|
||||
|
||||
on:
|
||||
# Trigger after release-linux completes (it uploads amd64 tarball)
|
||||
workflow_run:
|
||||
workflows: ["release-linux"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 1.0.0)"
|
||||
description: "Version to prepare (e.g. 2.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
pkgrel:
|
||||
description: "Package release number (bump for same-version rebuilds)"
|
||||
description: "Package release number"
|
||||
required: false
|
||||
default: "1"
|
||||
type: string
|
||||
publish:
|
||||
description: "Push all four generated packages to AUR"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: aur-publish
|
||||
group: aur-prepare-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
@@ -26,160 +30,268 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-aur:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on manual dispatch, or successful stable tag releases (no -rc)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v') && !contains(github.event.workflow_run.head_branch, '-rc')) }}
|
||||
runs-on: ubuntu-24.04
|
||||
# workflow_run prepares verified metadata only. Publishing is deliberately
|
||||
# manual because the maintainer's AUR key requires an interactive passkey.
|
||||
# Accept only a successful tag push produced inside this repository; the
|
||||
# exact tag and main ancestry are checked again after checkout.
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v') &&
|
||||
!contains(github.event.workflow_run.head_branch, '-'))
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout app repo
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
- name: Resolve and verify release
|
||||
id: release
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
DISPATCH_VERSION: ${{ inputs.version }}
|
||||
DISPATCH_PUBLISH: ${{ inputs.publish }}
|
||||
DISPATCH_PKGREL: ${{ inputs.pkgrel }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||
# Extract version from the triggering workflow's head branch (tag)
|
||||
REF="$WORKFLOW_HEAD_BRANCH"
|
||||
if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid workflow_run head branch for release: $REF"
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EVENT_NAME" == workflow_run ]]; then
|
||||
tag="$RUN_HEAD_BRANCH"
|
||||
version="${tag#v}"
|
||||
pkgrel=1
|
||||
should_publish=false
|
||||
else
|
||||
version="$DISPATCH_VERSION"
|
||||
tag="v${version}"
|
||||
pkgrel="${DISPATCH_PKGREL:-1}"
|
||||
should_publish="${DISPATCH_PUBLISH:-false}"
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid release version: $version"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${REF#v}"
|
||||
else
|
||||
VERSION="$DISPATCH_VERSION"
|
||||
if [[ ! "$pkgrel" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::Invalid pkgrel: $pkgrel"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected version: $VERSION"
|
||||
if [[ "$should_publish" == true && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::AUR publishing is limited to stable x.y.z releases"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$EVENT_NAME" == workflow_run || "$should_publish" == true ]]; then
|
||||
git fetch origin main --tags
|
||||
if ! git show-ref --verify --quiet "refs/tags/${tag}"; then
|
||||
echo "::error::Release tag ${tag} does not exist"
|
||||
exit 1
|
||||
fi
|
||||
tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")"
|
||||
if [[ "$EVENT_NAME" == workflow_run && "$tag_sha" != "$RUN_HEAD_SHA" ]]; then
|
||||
echo "::error::workflow_run SHA does not match ${tag}"
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
|
||||
echo "::error::${tag} is not on main"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "tag=$tag"
|
||||
echo "pkgrel=$pkgrel"
|
||||
echo "should_publish=$should_publish"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for all release assets
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
echo "Waiting for release assets to be available..."
|
||||
echo "Note: amd64 should be ready (release-linux completed), waiting for aarch64..."
|
||||
MAX_WAIT=1800
|
||||
INTERVAL=30
|
||||
ELAPSED=0
|
||||
set -euo pipefail
|
||||
assets=(
|
||||
"qbz_${VERSION}_amd64.tar.gz"
|
||||
"qbz_${VERSION}_aarch64.tar.gz"
|
||||
"qbzd-${VERSION}-linux-amd64.tar.gz"
|
||||
"qbzd-${VERSION}-linux-aarch64.tar.gz"
|
||||
"qbz-${VERSION}-cargo-vendor.tar.xz"
|
||||
)
|
||||
|
||||
while [ $ELAPSED -lt $MAX_WAIT ]; do
|
||||
echo "Checking for release assets (${ELAPSED}s elapsed)..."
|
||||
|
||||
# Check if both tarballs exist (follow redirects, GitHub returns 302)
|
||||
AMD64_CODE=$(curl -sL -o /dev/null -w "%{http_code}" "https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz")
|
||||
AARCH64_CODE=$(curl -sL -o /dev/null -w "%{http_code}" "https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz")
|
||||
AMD64_EXISTS=$([[ "$AMD64_CODE" == "200" ]] && echo "true" || echo "false")
|
||||
AARCH64_EXISTS=$([[ "$AARCH64_CODE" == "200" ]] && echo "true" || echo "false")
|
||||
|
||||
if [ "$AMD64_EXISTS" = "true" ] && [ "$AARCH64_EXISTS" = "true" ]; then
|
||||
echo "Both release assets are available!"
|
||||
for attempt in $(seq 1 60); do
|
||||
missing=()
|
||||
for asset in "${assets[@]}"; do
|
||||
url="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/${asset}"
|
||||
code="$(curl -sL -o /dev/null -w '%{http_code}' "$url")"
|
||||
[[ "$code" == 200 ]] || missing+=("$asset")
|
||||
done
|
||||
if [[ ${#missing[@]} -eq 0 ]]; then
|
||||
echo "All AUR assets are available"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Waiting for assets... (amd64: $AMD64_EXISTS, aarch64: $AARCH64_EXISTS)"
|
||||
sleep $INTERVAL
|
||||
ELAPSED=$((ELAPSED + INTERVAL))
|
||||
done
|
||||
|
||||
if [ $ELAPSED -ge $MAX_WAIT ]; then
|
||||
echo "Timeout waiting for release assets after 30 minutes"
|
||||
if [[ "$attempt" -eq 60 ]]; then
|
||||
printf '::error::Timed out waiting for %s\n' "${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt ${attempt}/60; waiting for: ${missing[*]}"
|
||||
sleep 30
|
||||
done
|
||||
|
||||
- name: Download release tarballs and get checksums
|
||||
- name: Download sources and calculate checksums
|
||||
id: checksums
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
# Download x86_64 tarball
|
||||
AMD64_URL="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz"
|
||||
echo "Downloading: $AMD64_URL"
|
||||
curl -fsSL -o /tmp/qbz_amd64.tar.gz "$AMD64_URL"
|
||||
SHA256_AMD64=$(sha256sum /tmp/qbz_amd64.tar.gz | cut -d' ' -f1)
|
||||
echo "sha256_amd64=$SHA256_AMD64" >> "$GITHUB_OUTPUT"
|
||||
echo "x86_64 Checksum: $SHA256_AMD64"
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/aur-sources
|
||||
|
||||
# Download aarch64 tarball
|
||||
AARCH64_URL="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz"
|
||||
echo "Downloading: $AARCH64_URL"
|
||||
curl -fsSL -o /tmp/qbz_aarch64.tar.gz "$AARCH64_URL"
|
||||
SHA256_AARCH64=$(sha256sum /tmp/qbz_aarch64.tar.gz | cut -d' ' -f1)
|
||||
echo "sha256_aarch64=$SHA256_AARCH64" >> "$GITHUB_OUTPUT"
|
||||
echo "aarch64 Checksum: $SHA256_AARCH64"
|
||||
download_hash() {
|
||||
local output_name="$1" url="$2" file="$3" sum
|
||||
curl -fsSL "$url" -o "/tmp/aur-sources/$file"
|
||||
sum="$(sha256sum "/tmp/aur-sources/$file" | cut -d' ' -f1)"
|
||||
echo "${output_name}=${sum}" >> "$GITHUB_OUTPUT"
|
||||
echo "${file}: ${sum}"
|
||||
}
|
||||
|
||||
- name: Setup SSH for AUR
|
||||
base="https://github.com/vicrodh/qbz/releases/download/v${VERSION}"
|
||||
download_hash qbz_amd64 "$base/qbz_${VERSION}_amd64.tar.gz" qbz-amd64.tar.gz
|
||||
download_hash qbz_aarch64 "$base/qbz_${VERSION}_aarch64.tar.gz" qbz-aarch64.tar.gz
|
||||
download_hash qbzd_amd64 "$base/qbzd-${VERSION}-linux-amd64.tar.gz" qbzd-amd64.tar.gz
|
||||
download_hash qbzd_aarch64 "$base/qbzd-${VERSION}-linux-aarch64.tar.gz" qbzd-aarch64.tar.gz
|
||||
download_hash vendor "$base/qbz-${VERSION}-cargo-vendor.tar.xz" vendor.tar.xz
|
||||
download_hash source \
|
||||
"https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz" \
|
||||
source.tar.gz
|
||||
|
||||
- name: Configure AUR SSH
|
||||
if: steps.release.outputs.should_publish == 'true'
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
echo "Host aur.archlinux.org" >> ~/.ssh/config
|
||||
echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config
|
||||
echo " User aur" >> ~/.ssh/config
|
||||
set -euo pipefail
|
||||
if [[ -z "$AUR_SSH_PRIVATE_KEY" ]]; then
|
||||
echo "::error::AUR_SSH_PRIVATE_KEY is required for publishing"
|
||||
exit 1
|
||||
fi
|
||||
install -d -m 700 ~/.ssh
|
||||
install -m 600 /dev/null ~/.ssh/aur
|
||||
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
|
||||
{
|
||||
echo "Host aur.archlinux.org"
|
||||
echo " IdentityFile ~/.ssh/aur"
|
||||
echo " IdentitiesOnly yes"
|
||||
echo " User aur"
|
||||
} >> ~/.ssh/config
|
||||
ssh-keyscan -t ed25519 aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Clone AUR repo
|
||||
- name: Clone AUR package repos
|
||||
env:
|
||||
SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
|
||||
run: |
|
||||
git clone ssh://aur@aur.archlinux.org/qbz-bin.git /tmp/aur-qbz-bin
|
||||
cd /tmp/aur-qbz-bin
|
||||
# Ensure we are on master (recover from detached HEAD if remote branch was corrupted)
|
||||
git checkout master 2>/dev/null || git checkout -b master
|
||||
|
||||
- name: Determine pkgrel
|
||||
id: pkgrel
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||
echo "pkgrel=1" >> "$GITHUB_OUTPUT"
|
||||
set -euo pipefail
|
||||
for package in qbz-bin qbz qbzd-bin qbzd; do
|
||||
destination="/tmp/aur-${package}"
|
||||
if [[ "$SHOULD_PUBLISH" == true ]]; then
|
||||
url="ssh://aur@aur.archlinux.org/${package}.git"
|
||||
else
|
||||
echo "pkgrel=${{ inputs.pkgrel || '1' }}" >> "$GITHUB_OUTPUT"
|
||||
url="https://aur.archlinux.org/${package}.git"
|
||||
fi
|
||||
|
||||
- name: Update PKGBUILD
|
||||
if ! git clone "$url" "$destination"; then
|
||||
if [[ "$SHOULD_PUBLISH" == true ]]; then
|
||||
echo "::error::Could not clone AUR repository for ${package}"
|
||||
exit 1
|
||||
fi
|
||||
# qbzd packages do not exist until their first AUR push.
|
||||
mkdir -p "$destination"
|
||||
git -C "$destination" init -b master
|
||||
git -C "$destination" remote add origin "$url"
|
||||
fi
|
||||
git -C "$destination" checkout master 2>/dev/null || \
|
||||
git -C "$destination" checkout -b master
|
||||
done
|
||||
|
||||
- name: Stamp PKGBUILDs
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
PKGREL: ${{ steps.pkgrel.outputs.pkgrel }}
|
||||
SHA256_AMD64: ${{ steps.checksums.outputs.sha256_amd64 }}
|
||||
SHA256_AARCH64: ${{ steps.checksums.outputs.sha256_aarch64 }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
PKGREL: ${{ steps.release.outputs.pkgrel }}
|
||||
QBZ_AMD64: ${{ steps.checksums.outputs.qbz_amd64 }}
|
||||
QBZ_AARCH64: ${{ steps.checksums.outputs.qbz_aarch64 }}
|
||||
QBZD_AMD64: ${{ steps.checksums.outputs.qbzd_amd64 }}
|
||||
QBZD_AARCH64: ${{ steps.checksums.outputs.qbzd_aarch64 }}
|
||||
SOURCE_SHA: ${{ steps.checksums.outputs.source }}
|
||||
VENDOR_SHA: ${{ steps.checksums.outputs.vendor }}
|
||||
run: |
|
||||
cd /tmp/aur-qbz-bin
|
||||
set -euo pipefail
|
||||
for package in qbz-bin qbz qbzd-bin qbzd; do
|
||||
cp "packaging/aur/${package}/"* "/tmp/aur-${package}/"
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "/tmp/aur-${package}/PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" "/tmp/aur-${package}/PKGBUILD"
|
||||
done
|
||||
|
||||
# Copy PKGBUILD from main repo (source of truth)
|
||||
cp ${{ github.workspace }}/packaging/aur/PKGBUILD .
|
||||
sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${QBZ_AMD64}')/" /tmp/aur-qbz-bin/PKGBUILD
|
||||
sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${QBZ_AARCH64}')/" /tmp/aur-qbz-bin/PKGBUILD
|
||||
sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${QBZD_AMD64}')/" /tmp/aur-qbzd-bin/PKGBUILD
|
||||
sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${QBZD_AARCH64}')/" /tmp/aur-qbzd-bin/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SOURCE_SHA}' '${VENDOR_SHA}')/" /tmp/aur-qbz/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SOURCE_SHA}' '${VENDOR_SHA}')/" /tmp/aur-qbzd/PKGBUILD
|
||||
|
||||
# Update version and pkgrel
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" PKGBUILD
|
||||
sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" PKGBUILD
|
||||
|
||||
# Update checksums for both architectures
|
||||
sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${SHA256_AMD64}')/" PKGBUILD
|
||||
sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${SHA256_AARCH64}')/" PKGBUILD
|
||||
|
||||
echo "=== Updated PKGBUILD ==="
|
||||
cat PKGBUILD
|
||||
|
||||
- name: Generate .SRCINFO
|
||||
- name: Generate and validate .SRCINFO files
|
||||
run: |
|
||||
cd /tmp/aur-qbz-bin
|
||||
docker run --rm -v "$PWD":/pkg -w /pkg -e HOST_UID=$(id -u) -e HOST_GID=$(id -g) archlinux:latest bash -c "
|
||||
pacman -Sy --noconfirm pacman-contrib sudo
|
||||
set -euo pipefail
|
||||
for package in qbz-bin qbz qbzd-bin qbzd; do
|
||||
docker run --rm \
|
||||
-e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
|
||||
-v "/tmp/aur-${package}:/pkg" archlinux:latest bash -euc '
|
||||
useradd -m builder
|
||||
chown -R builder:builder /pkg
|
||||
sudo -u builder makepkg --printsrcinfo > .SRCINFO
|
||||
chown -R \$HOST_UID:\$HOST_GID /pkg
|
||||
"
|
||||
cat .SRCINFO
|
||||
runuser -u builder -- bash -c \
|
||||
"cd /pkg && makepkg --printsrcinfo > .SRCINFO"
|
||||
chown -R "${HOST_UID}:${HOST_GID}" /pkg
|
||||
'
|
||||
done
|
||||
|
||||
- name: Commit and push to AUR
|
||||
- name: Commit and push all AUR packages
|
||||
if: steps.release.outputs.should_publish == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
cd /tmp/aur-qbz-bin
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to v${VERSION}"
|
||||
git push
|
||||
set -euo pipefail
|
||||
for package in qbz-bin qbz qbzd-bin qbzd; do
|
||||
directory="/tmp/aur-${package}"
|
||||
git -C "$directory" config user.name "GitHub Actions"
|
||||
git -C "$directory" config user.email "actions@github.com"
|
||||
files=(PKGBUILD .SRCINFO)
|
||||
[[ -f "$directory/qbzd.install" ]] && files+=(qbzd.install)
|
||||
git -C "$directory" add "${files[@]}"
|
||||
if git -C "$directory" diff --cached --quiet; then
|
||||
echo "${package}: no metadata changes"
|
||||
continue
|
||||
fi
|
||||
git -C "$directory" commit -m "Update to v${VERSION}"
|
||||
git -C "$directory" push origin master
|
||||
done
|
||||
|
||||
- name: Upload dry-run package metadata
|
||||
if: steps.release.outputs.should_publish != 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: aur-four-package-metadata
|
||||
retention-days: 7
|
||||
path: |
|
||||
/tmp/aur-qbz-bin/PKGBUILD
|
||||
/tmp/aur-qbz-bin/.SRCINFO
|
||||
/tmp/aur-qbz/PKGBUILD
|
||||
/tmp/aur-qbz/.SRCINFO
|
||||
/tmp/aur-qbzd-bin/PKGBUILD
|
||||
/tmp/aur-qbzd-bin/.SRCINFO
|
||||
/tmp/aur-qbzd-bin/qbzd.install
|
||||
/tmp/aur-qbzd/PKGBUILD
|
||||
/tmp/aur-qbzd/.SRCINFO
|
||||
/tmp/aur-qbzd/qbzd.install
|
||||
if-no-files-found: error
|
||||
@@ -1,14 +1,10 @@
|
||||
# Flatpak bundle for GitHub Releases — Slint binary (crates/ workspace).
|
||||
# Flatpak bundle for GitHub Releases — Qt frontend.
|
||||
#
|
||||
# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Builds
|
||||
# packaging/flatpak/com.blitzfc.qbz.yml (network manifest) with
|
||||
# flatpak-builder and attaches the .flatpak bundle to the Release.
|
||||
# Flathub proper is a separate repo/manifest updated at tag time via
|
||||
# scripts/update-flathub.sh (offline sources).
|
||||
#
|
||||
# The cargo build runs on the HOST's resources even though it happens
|
||||
# inside the flatpak sandbox, so this runner needs the same swap headroom
|
||||
# as release-linux.yml (qbz_ui rustc peak ~30 GB).
|
||||
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch: the bundle is a
|
||||
# REPACK of the bare binary from the shared build-qt-linux.yml on the KDE
|
||||
# runtime (org.kde.Platform 6.9 provides Qt). No cargo inside flatpak-builder,
|
||||
# no swap ceremony. Same shape as the Flathub manifest, which consumes the
|
||||
# published release tarball instead.
|
||||
|
||||
name: release-flatpak
|
||||
|
||||
@@ -16,7 +12,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD, no Release publish."
|
||||
description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD, no Release publish."
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
@@ -28,15 +24,46 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build-qt-linux.yml
|
||||
with:
|
||||
arch: x86_64
|
||||
source_ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
# qbzd rides in the bundle too (same as deb/rpm). Slint-free, minutes-class.
|
||||
build-qbzd:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: qbzd-linux-x86_64
|
||||
cache-on-failure: true
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
|
||||
- run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev
|
||||
- run: cargo build --release --manifest-path crates/Cargo.toml -p qbzd
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbzd-linux-x86_64-flatpak
|
||||
path: crates/target/release/qbzd
|
||||
retention-days: 7
|
||||
|
||||
build-flatpak:
|
||||
needs: [build, build-qbzd]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 300
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
@@ -65,6 +92,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Assert Cargo.toml version matches requested tag
|
||||
if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
CARGO_VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = "\(.*\)"/\1/')"
|
||||
if [[ "$CARGO_VERSION" != "$VERSION" ]]; then
|
||||
echo "::error::crates/Cargo.toml workspace version ($CARGO_VERSION) != requested version ($VERSION)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Keep the metainfo's top <release> entry honest (version + date).
|
||||
- name: Sync metainfo release entry
|
||||
if: steps.release.outputs.version != ''
|
||||
@@ -86,35 +124,40 @@ jobs:
|
||||
p.write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
- name: Free disk space and add swap
|
||||
- name: Download bare binary
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbz-linux-x86_64
|
||||
path: packaging/flatpak/payload
|
||||
- name: Download qbzd
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbzd-linux-x86_64-flatpak
|
||||
path: packaging/flatpak/payload
|
||||
|
||||
- name: Stage payload
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
||||
/usr/local/lib/node_modules || true
|
||||
sudo docker system prune -af || true
|
||||
if sudo fallocate -l 18G /swap_extra; then
|
||||
sudo chmod 600 /swap_extra
|
||||
sudo mkswap /swap_extra
|
||||
sudo swapon /swap_extra
|
||||
else
|
||||
echo "::warning::could not allocate extra swap; build may OOM"
|
||||
fi
|
||||
free -h
|
||||
set -euo pipefail
|
||||
P=packaging/flatpak/payload
|
||||
chmod +x "$P/qbz" "$P/qbzd"
|
||||
cp packaging/flatpak/com.blitzfc.qbz.desktop "$P/qbz.desktop"
|
||||
cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "$P/"
|
||||
cp LICENSE "$P/LICENSE"
|
||||
cp -r licenses "$P/licenses"
|
||||
mkdir -p "$P/icons"; cp packaging/icons/{32x32,48x48,64x64,128x128,256x256,512x512}.png "$P/icons/"
|
||||
ls -la "$P"
|
||||
|
||||
- name: Install flatpak tooling
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
sudo apt-get install -y flatpak flatpak-builder dbus-daemon
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Install runtimes
|
||||
run: |
|
||||
flatpak install --user -y --noninteractive flathub \
|
||||
org.gnome.Platform//49 \
|
||||
org.gnome.Sdk//49
|
||||
flatpak install --user -y --noninteractive flathub \
|
||||
org.freedesktop.Sdk.Extension.rust-stable//25.08 \
|
||||
org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||
org.kde.Platform//6.9 \
|
||||
org.kde.Sdk//6.9
|
||||
|
||||
- name: Build Flatpak
|
||||
id: build_flatpak
|
||||
@@ -132,11 +175,31 @@ jobs:
|
||||
echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
ls -la dist
|
||||
|
||||
# Observe flatpak run until the deadline, with a fresh sandbox profile
|
||||
# and private bus. A crash/early exit after core init must still fail.
|
||||
- name: Offscreen smoke (inside the sandbox)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
flatpak install --user -y --noninteractive --bundle "dist/${{ steps.build_flatpak.outputs.bundle_name }}" || \
|
||||
flatpak install --user -y --noninteractive "dist/${{ steps.build_flatpak.outputs.bundle_name }}"
|
||||
python3 scripts/packaging/smoke_release.py com.blitzfc.qbz \
|
||||
--flatpak --seconds 60 --log smoke.log
|
||||
|
||||
- name: Upload deployed startup evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-flatpak-smoke
|
||||
path: smoke.log*
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload Flatpak bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-flatpak
|
||||
path: dist/${{ steps.build_flatpak.outputs.bundle_name }}
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
|
||||
- name: Publish GitHub Release Asset
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
name: release-gentoo
|
||||
|
||||
# The overlay repo (vicrodh/qbz-overlay) must NEVER be edited by hand: any
|
||||
# ebuild change without a Manifest regeneration bricks emerge with a digest
|
||||
# mismatch (issue #557 — the 2.0.0 RESTRICT edit was pushed manually and
|
||||
# skipped the Manifest). Template changes between releases are published by
|
||||
# re-running THIS workflow via workflow_dispatch with the released version.
|
||||
# qbz-overlay is automation-owned because every ebuild or metadata change must
|
||||
# regenerate its Manifest. workflow_dispatch is the supported way to publish
|
||||
# a template-only fix for an already released version.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -13,9 +11,14 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 1.2.0)"
|
||||
description: "Version to prepare (e.g. 2.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
publish:
|
||||
description: "Push all four packages and Manifests to qbz-overlay"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: gentoo-overlay
|
||||
@@ -26,160 +29,206 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-overlay:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v') && !contains(github.event.workflow_run.head_branch, '-rc')) }}
|
||||
runs-on: ubuntu-24.04
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v') &&
|
||||
!contains(github.event.workflow_run.head_branch, '-'))
|
||||
steps:
|
||||
- name: Checkout app repo
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
- name: Resolve and verify release
|
||||
id: release
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
DISPATCH_VERSION: ${{ inputs.version }}
|
||||
DISPATCH_PUBLISH: ${{ inputs.publish }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||
REF="$WORKFLOW_HEAD_BRANCH"
|
||||
if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid tag: $REF"
|
||||
set -euo pipefail
|
||||
if [[ "$EVENT_NAME" == workflow_run ]]; then
|
||||
tag="$RUN_HEAD_BRANCH"
|
||||
version="${tag#v}"
|
||||
should_publish=true
|
||||
else
|
||||
version="$DISPATCH_VERSION"
|
||||
tag="v${version}"
|
||||
should_publish="${DISPATCH_PUBLISH:-false}"
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid release version: $version"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${REF#v}"
|
||||
else
|
||||
VERSION="$DISPATCH_VERSION"
|
||||
if [[ "$should_publish" == true && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Overlay publishing is limited to stable x.y.z releases"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
if [[ "$should_publish" == true ]]; then
|
||||
git fetch origin main --tags
|
||||
if ! git show-ref --verify --quiet "refs/tags/${tag}"; then
|
||||
echo "::error::Release tag ${tag} does not exist"
|
||||
exit 1
|
||||
fi
|
||||
tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")"
|
||||
if [[ "$EVENT_NAME" == workflow_run && "$tag_sha" != "$RUN_HEAD_SHA" ]]; then
|
||||
echo "::error::workflow_run SHA does not match ${tag}"
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
|
||||
echo "::error::${tag} is not on main"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Clone overlay repo
|
||||
env:
|
||||
OVERLAY_PAT: ${{ secrets.APT_REPO_TOKEN }}
|
||||
SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
|
||||
run: |
|
||||
git clone "https://x-access-token:${OVERLAY_PAT}@github.com/vicrodh/qbz-overlay.git" /tmp/qbz-overlay
|
||||
cd /tmp/qbz-overlay
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
set -euo pipefail
|
||||
if [[ "$SHOULD_PUBLISH" == true ]]; then
|
||||
if [[ -z "$OVERLAY_PAT" ]]; then
|
||||
echo "::error::APT_REPO_TOKEN is required for publishing"
|
||||
exit 1
|
||||
fi
|
||||
url="https://x-access-token:${OVERLAY_PAT}@github.com/vicrodh/qbz-overlay.git"
|
||||
else
|
||||
url="https://github.com/vicrodh/qbz-overlay.git"
|
||||
fi
|
||||
git clone "$url" /tmp/qbz-overlay
|
||||
git -C /tmp/qbz-overlay config user.name "GitHub Actions"
|
||||
git -C /tmp/qbz-overlay config user.email "actions@github.com"
|
||||
|
||||
- name: Update binary ebuild
|
||||
- name: Stamp all ebuilds and metadata
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
cd /tmp/qbz-overlay/media-sound/qbz-bin
|
||||
set -euo pipefail
|
||||
for package in qbz-bin qbz qbzd-bin qbzd; do
|
||||
directory="/tmp/qbz-overlay/media-sound/${package}"
|
||||
mkdir -p "$directory"
|
||||
find "$directory" -maxdepth 1 -type f -name "${package}-*.ebuild" -delete
|
||||
cp "packaging/gentoo/${package}.ebuild.template" \
|
||||
"${directory}/${package}-${VERSION}.ebuild"
|
||||
cp "packaging/gentoo/${package}.metadata.xml" \
|
||||
"${directory}/metadata.xml"
|
||||
done
|
||||
|
||||
# Remove old version ebuilds (keep metadata.xml)
|
||||
rm -f qbz-bin-*.ebuild
|
||||
|
||||
# Copy template from app repo and update version
|
||||
cp "${{ github.workspace }}/packaging/gentoo/qbz-bin.ebuild.template" "qbz-bin-${VERSION}.ebuild"
|
||||
|
||||
echo "=== qbz-bin-${VERSION}.ebuild ==="
|
||||
cat "qbz-bin-${VERSION}.ebuild"
|
||||
|
||||
- name: Update source ebuild
|
||||
- name: Download distfiles
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
cd /tmp/qbz-overlay/media-sound/qbz
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/qbz-distfiles
|
||||
declare -A urls=(
|
||||
["qbz_${VERSION}_amd64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz"
|
||||
["qbz_${VERSION}_aarch64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz"
|
||||
["qbzd-${VERSION}-linux-amd64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbzd-${VERSION}-linux-amd64.tar.gz"
|
||||
["qbzd-${VERSION}-linux-aarch64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbzd-${VERSION}-linux-aarch64.tar.gz"
|
||||
["qbz-${VERSION}-cargo-vendor.tar.xz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz-${VERSION}-cargo-vendor.tar.xz"
|
||||
["qbz-${VERSION}.tar.gz"]="https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz"
|
||||
)
|
||||
|
||||
# Remove old version ebuilds (keep metadata.xml)
|
||||
rm -f qbz-*.ebuild
|
||||
|
||||
# Copy template from app repo and update version
|
||||
cp "${{ github.workspace }}/packaging/gentoo/qbz.ebuild.template" "qbz-${VERSION}.ebuild"
|
||||
|
||||
echo "=== qbz-${VERSION}.ebuild ==="
|
||||
cat "qbz-${VERSION}.ebuild"
|
||||
|
||||
- name: Generate Manifests
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# Helper: compute BLAKE2B and SHA512 for a file, output Manifest line
|
||||
manifest_line() {
|
||||
local type="$1" name="$2" file="$3"
|
||||
local size blake2b sha512
|
||||
size=$(stat -c%s "$file")
|
||||
blake2b=$(b2sum "$file" | awk '{print $1}')
|
||||
sha512=$(sha512sum "$file" | awk '{print $1}')
|
||||
echo "${type} ${name} ${size} BLAKE2B ${blake2b} SHA512 ${sha512}"
|
||||
}
|
||||
|
||||
# --- qbz-bin Manifest ---
|
||||
cd /tmp/qbz-overlay/media-sound/qbz-bin
|
||||
MANIFEST=""
|
||||
|
||||
# Download DIST files and hash them. amd64 is REQUIRED: publishing a
|
||||
# Manifest without its DIST entry bricks emerge for every amd64 user
|
||||
# (digest missing), which is worse than failing this job. A short
|
||||
# retry absorbs release-asset propagation lag right after the tag.
|
||||
for arch_pair in "amd64:amd64" "aarch64:arm64"; do
|
||||
arch="${arch_pair%%:*}"
|
||||
dist_name="qbz_${VERSION}_${arch}.tar.gz"
|
||||
url="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/${dist_name}"
|
||||
echo "Downloading ${dist_name}..."
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if curl -sLf "$url" -o "/tmp/${dist_name}"; then
|
||||
ok=1
|
||||
for filename in "${!urls[@]}"; do
|
||||
ok=false
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl -fsSL "${urls[$filename]}" -o "/tmp/qbz-distfiles/${filename}"; then
|
||||
ok=true
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed for ${dist_name}, retrying in 30s..."
|
||||
echo "Attempt ${attempt}/60 failed for ${filename}"
|
||||
sleep 30
|
||||
done
|
||||
if [[ "$ok" == 1 ]]; then
|
||||
MANIFEST+="$(manifest_line DIST "$dist_name" "/tmp/${dist_name}")"$'\n'
|
||||
elif [[ "$arch" == "amd64" ]]; then
|
||||
echo "ERROR: required asset ${dist_name} not found at ${url}"
|
||||
if [[ "$ok" != true ]]; then
|
||||
echo "::error::Required distfile unavailable: ${filename}"
|
||||
exit 1
|
||||
else
|
||||
echo "WARNING: ${dist_name} not found at ${url}, skipping"
|
||||
fi
|
||||
done
|
||||
|
||||
# Hash the ebuild itself
|
||||
MANIFEST+="$(manifest_line EBUILD "qbz-bin-${VERSION}.ebuild" "qbz-bin-${VERSION}.ebuild")"$'\n'
|
||||
- name: Generate all Manifests
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest_line() {
|
||||
local type="$1" name="$2" file="$3" size blake2b sha512
|
||||
size="$(stat -c%s "$file")"
|
||||
blake2b="$(b2sum "$file" | awk '{print $1}')"
|
||||
sha512="$(sha512sum "$file" | awk '{print $1}')"
|
||||
printf '%s %s %s BLAKE2B %s SHA512 %s\n' \
|
||||
"$type" "$name" "$size" "$blake2b" "$sha512"
|
||||
}
|
||||
|
||||
# Hash metadata.xml
|
||||
MANIFEST+="$(manifest_line MISC "metadata.xml" "metadata.xml")"$'\n'
|
||||
write_manifest() {
|
||||
local package="$1"
|
||||
shift
|
||||
local directory="/tmp/qbz-overlay/media-sound/${package}"
|
||||
{
|
||||
local distfile
|
||||
for distfile in "$@"; do
|
||||
manifest_line DIST "$distfile" "/tmp/qbz-distfiles/${distfile}"
|
||||
done
|
||||
manifest_line EBUILD "${package}-${VERSION}.ebuild" \
|
||||
"${directory}/${package}-${VERSION}.ebuild"
|
||||
manifest_line MISC metadata.xml "${directory}/metadata.xml"
|
||||
} > "${directory}/Manifest"
|
||||
}
|
||||
|
||||
echo "$MANIFEST" > Manifest
|
||||
echo "=== qbz-bin Manifest ==="
|
||||
cat Manifest
|
||||
write_manifest qbz-bin \
|
||||
"qbz_${VERSION}_amd64.tar.gz" \
|
||||
"qbz_${VERSION}_aarch64.tar.gz"
|
||||
write_manifest qbz \
|
||||
"qbz-${VERSION}.tar.gz" \
|
||||
"qbz-${VERSION}-cargo-vendor.tar.xz"
|
||||
write_manifest qbzd-bin \
|
||||
"qbzd-${VERSION}-linux-amd64.tar.gz" \
|
||||
"qbzd-${VERSION}-linux-aarch64.tar.gz"
|
||||
write_manifest qbzd \
|
||||
"qbz-${VERSION}.tar.gz" \
|
||||
"qbz-${VERSION}-cargo-vendor.tar.xz"
|
||||
|
||||
# --- qbz (source) Manifest ---
|
||||
cd /tmp/qbz-overlay/media-sound/qbz
|
||||
MANIFEST=""
|
||||
|
||||
# Download source tarball
|
||||
dist_name="qbz-${VERSION}.tar.gz"
|
||||
url="https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz"
|
||||
echo "Downloading source tarball..."
|
||||
if curl -sLf "$url" -o "/tmp/${dist_name}"; then
|
||||
MANIFEST+="$(manifest_line DIST "$dist_name" "/tmp/${dist_name}")"$'\n'
|
||||
else
|
||||
echo "WARNING: source tarball not found, skipping"
|
||||
fi
|
||||
|
||||
# Hash the ebuild
|
||||
MANIFEST+="$(manifest_line EBUILD "qbz-${VERSION}.ebuild" "qbz-${VERSION}.ebuild")"$'\n'
|
||||
|
||||
# Hash metadata.xml
|
||||
MANIFEST+="$(manifest_line MISC "metadata.xml" "metadata.xml")"$'\n'
|
||||
|
||||
echo "$MANIFEST" > Manifest
|
||||
echo "=== qbz Manifest ==="
|
||||
cat Manifest
|
||||
- name: Validate generated overlay metadata
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for package in qbz-bin qbz qbzd-bin qbzd; do
|
||||
directory="/tmp/qbz-overlay/media-sound/${package}"
|
||||
bash -n "${directory}"/*.ebuild
|
||||
xmllint --noout "${directory}/metadata.xml"
|
||||
test "$(wc -l < "${directory}/Manifest")" -ge 4
|
||||
done
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.release.outputs.should_publish == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
cd /tmp/qbz-overlay
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
set -euo pipefail
|
||||
git -C /tmp/qbz-overlay add -A
|
||||
if git -C /tmp/qbz-overlay diff --cached --quiet; then
|
||||
echo "No overlay changes to publish"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update to v${VERSION}"
|
||||
git push
|
||||
git -C /tmp/qbz-overlay commit -m "Update to v${VERSION}"
|
||||
git -C /tmp/qbz-overlay push
|
||||
|
||||
- name: Upload dry-run overlay metadata
|
||||
if: steps.release.outputs.should_publish != 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gentoo-four-package-dry-run
|
||||
retention-days: 7
|
||||
path: /tmp/qbz-overlay/media-sound/
|
||||
if-no-files-found: error
|
||||
@@ -1,14 +1,28 @@
|
||||
# Linux aarch64 release — Slint binary (crates/ workspace).
|
||||
# Linux aarch64 release — Qt frontend (crates/ workspace, package `qbz-qt`,
|
||||
# binary `qbz`).
|
||||
#
|
||||
# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Same
|
||||
# build-once-package-many shape as release-linux.yml (read that file's
|
||||
# header); differences: native arm64 runner, no x86 target-features, its
|
||||
# own rust-cache key (this workflow is the only writer), and it ATTACHES
|
||||
# to the Release that release-linux.yml creates.
|
||||
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Pattern is still
|
||||
# build once, package many — but the build is the shared build-qt-linux.yml
|
||||
# (workflow_call) and it yields TWO payloads:
|
||||
# * the bare binary -> qbz_${VERSION}_arm64.tar.gz (AUR / Gentoo / Flathub
|
||||
# repack: ecosystems that provide Qt >= 6.8 themselves)
|
||||
# * the AppDir (Qt bundled, self-contained) -> deb / rpm / AppImage, so
|
||||
# distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working.
|
||||
#
|
||||
# CONTRACT: keeps publishing qbz_${VERSION}_aarch64.tar.gz,
|
||||
# QBZ_${VERSION}_arm64.deb, QBZ-${VERSION}-1.aarch64.rpm,
|
||||
# QBZ_${VERSION}_aarch64.AppImage — AUR/Gentoo/APT/website names.
|
||||
# CONTRACT (do not break): the release must keep publishing
|
||||
# qbz_${VERSION}_arm64.tar.gz (dir qbz_${VERSION}_arm64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop,
|
||||
# com.blitzfc.qbz.metainfo.xml, qbzd.service,
|
||||
# completions/, icons/hicolor/...})
|
||||
# QBZ_${VERSION}_arm64.deb / QBZ-${VERSION}-1.aarch64.rpm / QBZ_${VERSION}_arm64.AppImage
|
||||
# qbzd_${VERSION}_arm64.deb / qbzd-${VERSION}-1.aarch64.rpm / qbzd-${VERSION}-linux-arm64.tar.gz
|
||||
# — release-aur.yml, release-gentoo.yml, qbz-apt, qbz-rpm, the updater
|
||||
# manifest and the website consume these names.
|
||||
#
|
||||
# release-linux.yml is this file with the four ARCH_* values changed.
|
||||
# Keep them structurally identical.
|
||||
#
|
||||
# Triggers: version tags (cut from main — enforced by the ancestry guard)
|
||||
# + manual dispatch (guard skipped, for testing on any branch).
|
||||
|
||||
name: "Linux ARM64 (aarch64) Build"
|
||||
|
||||
@@ -16,7 +30,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
|
||||
description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
@@ -27,18 +41,23 @@ concurrency:
|
||||
group: release-linux-aarch64-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
ARCH: aarch64 # build-qt-linux input + artifact suffix
|
||||
DEB_ARCH: arm64 # QBZ_${V}_arm64.deb, qbz_${V}_arm64.tar.gz
|
||||
RPM_ARCH: aarch64 # QBZ-${V}-1.aarch64.rpm
|
||||
APPIMAGE_ARCH: aarch64 # QBZ_${V}_aarch64.AppImage
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Release bookkeeping first so a bad tag fails in seconds, not after a build.
|
||||
version:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 300
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
prerelease: ${{ steps.release.outputs.prerelease }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
@@ -51,6 +70,7 @@ jobs:
|
||||
VERSION="${{ inputs.tag }}"
|
||||
VERSION="${VERSION#v}"
|
||||
else
|
||||
# Manual dispatch without a tag: test build at the Cargo.toml version.
|
||||
VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
|
||||
fi
|
||||
PRERELEASE=false
|
||||
@@ -59,103 +79,72 @@ jobs:
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Building version $VERSION (prerelease=$PRERELEASE)"
|
||||
|
||||
# Release tags must be cut from main. A tag on any other commit fires
|
||||
# this workflow too (tags are not branch-scoped in Actions), so verify
|
||||
# ancestry instead of trusting convention. Manual dispatch skips this.
|
||||
- name: Verify tag is on main
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
git fetch origin main
|
||||
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
|
||||
echo "::error::Tag ${GITHUB_REF#refs/tags/} is not on main. Releases are cut from main only."
|
||||
echo "::error::Tag ${GITHUB_REF#refs/tags/} points to a commit that is NOT on main. Releases are cut from main only."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Free disk space and add swap
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
||||
/usr/local/lib/node_modules || true
|
||||
sudo docker system prune -af || true
|
||||
if sudo fallocate -l 18G /swap_extra; then
|
||||
sudo chmod 600 /swap_extra
|
||||
sudo mkswap /swap_extra
|
||||
sudo swapon /swap_extra
|
||||
else
|
||||
echo "::warning::could not allocate extra swap; build may OOM"
|
||||
fi
|
||||
free -h
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
mold nasm \
|
||||
libasound2-dev libjack-jackd2-dev \
|
||||
libfontconfig1-dev libfreetype-dev \
|
||||
libxkbcommon-dev libwayland-dev libxcb1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev \
|
||||
libdbus-1-dev libssl-dev \
|
||||
libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
|
||||
libjpeg-dev zlib1g-dev libgumbo-dev
|
||||
|
||||
# Pinned nightly: matches build-slint.yml / release-linux.yml.
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: nightly-2026-06-23
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: slint-linux-aarch64
|
||||
cache-on-failure: true
|
||||
# This workflow is the only aarch64 builder, so it owns its cache.
|
||||
save-if: true
|
||||
|
||||
- name: Build (release, MIN memory tier)
|
||||
# The binary embeds the workspace version (About, diagnostics); a
|
||||
# mismatched tag would ship a binary that reports the wrong version.
|
||||
- name: Assert Cargo.toml version matches tag
|
||||
if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_BUILD_JOBS: "1"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256"
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: "2"
|
||||
CARGO_PROFILE_RELEASE_STRIP: "none"
|
||||
# No x86 target-features here (aarch64 baseline has NEON).
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold -Z threads=1"
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
cargo build --release --manifest-path crates/Cargo.toml -p qbz
|
||||
cp crates/target/release/qbz qbz-unstripped
|
||||
strip -o qbz crates/target/release/qbz
|
||||
ls -la qbz qbz-unstripped
|
||||
CARGO_VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
|
||||
if [[ "$CARGO_VERSION" != "$VERSION" ]]; then
|
||||
echo "::error::crates/Cargo.toml workspace version ($CARGO_VERSION) != tag version ($VERSION). Bump it before tagging (see RELEASE_CHECKLIST)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v7
|
||||
# The media-widget icon invariant (DesktopEntry == installed .desktop
|
||||
# basename, Icon= installed) — regressed more than once, so it is a
|
||||
# gate at the recipe level here and on the built packages below.
|
||||
- name: Gate — desktop entry / icon invariant (recipes)
|
||||
run: scripts/packaging/desktop_entry_check.sh
|
||||
|
||||
# No workflow may build the frozen Slint binary: same bin name, same
|
||||
# target dir, last build wins the file — a smoke against "the wrong
|
||||
# qbz" is silent.
|
||||
- name: Gate — no workflow builds `-p qbz`
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=$(grep -rnE 'cargo (build|check|test|run|install).* -p qbz([^-a-z]|$)' .github/workflows/ || true)
|
||||
[ -z "$hits" ] || { echo "::error::a workflow still builds the Slint crate:"; echo "$hits"; exit 1; }
|
||||
|
||||
build:
|
||||
needs: version
|
||||
uses: ./.github/workflows/build-qt-linux.yml
|
||||
with:
|
||||
name: qbz-linux-aarch64
|
||||
path: qbz
|
||||
if-no-files-found: error
|
||||
arch: aarch64
|
||||
source_ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Upload unstripped binary (symbolication)
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-aarch64-unstripped
|
||||
path: qbz-unstripped
|
||||
retention-days: 90
|
||||
|
||||
# T15: aarch64 mirror of release-linux.yml's build-qbzd job — see that
|
||||
# file's header comment for the glibc-floor rationale. Uses the native
|
||||
# ubuntu-22.04-arm runner label rather than cross-compiling.
|
||||
#
|
||||
# Fallback note (NOT implemented — flagged for pre-flight per the T15
|
||||
# brief): if ubuntu-22.04-arm is ever unavailable to this repo, cross-build
|
||||
# instead via the existing Cross.toml pattern (crates/Cross.toml,
|
||||
# scripts/build-aarch64-linux.sh's CROSS mode) targeting a 2.35-floor
|
||||
# image; the objdump gate below stays the acceptance test either way.
|
||||
# qbzd is minutes-class and Slint-free. Runs on ubuntu-22.04 so the shipped
|
||||
# binary's glibc floor stays <= Pi OS bookworm's 2.36 (the honest-Pi-story
|
||||
# gate); the objdump step verifies that empirically.
|
||||
build-qbzd:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: qbzd-linux-aarch64
|
||||
cache-on-failure: true
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
|
||||
- name: system deps
|
||||
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev
|
||||
- name: slint-free dep-graph gate
|
||||
@@ -174,37 +163,45 @@ jobs:
|
||||
echo "glibc floor: $floor"
|
||||
highest=$(printf '%s\n' "$floor" "GLIBC_2.35" | sort -V | tail -1)
|
||||
[ "$highest" = "GLIBC_2.35" ] || { echo "::error::floor $floor exceeds 2.35"; exit 1; }
|
||||
# NOTE: the T15 brief's literal YAML names this artifact
|
||||
# `qbzd-linux-arm64`, but T14 (39eb12fc, already merged into this
|
||||
# branch) wired the package job's Download-qbzd step below to expect
|
||||
# `qbzd-linux-aarch64` — matching what already landed wins over the
|
||||
# brief text (see task-15-report.md for the full note).
|
||||
- uses: actions/upload-artifact@v7
|
||||
with: { name: qbzd-linux-aarch64, path: crates/target/release/qbzd }
|
||||
with:
|
||||
name: qbzd-linux-aarch64
|
||||
path: crates/target/release/qbzd
|
||||
retention-days: 7
|
||||
|
||||
package:
|
||||
needs: [build, build-qbzd]
|
||||
needs: [version, build, build-qbzd]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Download binary
|
||||
- name: Download bare binary
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbz-linux-aarch64
|
||||
name: qbz-linux-${{ env.ARCH }}
|
||||
|
||||
- name: Download AppDir
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbz-appdir-${{ env.ARCH }}
|
||||
|
||||
# T14: the slint-free qbzd binary, produced by T15's build-qbzd job — see
|
||||
# release-linux.yml's matching step for the full rationale (one
|
||||
# artifact feeds nfpm, the main tarball, and the standalone tarball).
|
||||
- name: Download qbzd binary
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbzd-linux-aarch64
|
||||
name: qbzd-linux-${{ env.ARCH }}
|
||||
|
||||
- name: Unpack AppDir
|
||||
run: |
|
||||
tar -xf appdir.tar
|
||||
chmod +x qbz qbzd
|
||||
ls AppDir; ls AppDir/usr/plugins/platforms
|
||||
|
||||
- name: Install nfpm
|
||||
run: |
|
||||
@@ -214,37 +211,55 @@ jobs:
|
||||
sudo apt-get install -y nfpm
|
||||
nfpm --version
|
||||
|
||||
# deb/rpm carry the AppDir tree under /opt/qbz (bin, lib, plugins, qml,
|
||||
# qt.conf) and a /usr/bin/qbz symlink. RPATH ($ORIGIN/../lib, patched by
|
||||
# linuxdeploy) resolves through /proc/self/exe, so the symlink needs no
|
||||
# wrapper — and a wrapper exporting LD_LIBRARY_PATH would leak our Qt
|
||||
# into every process qbz spawns (the login browser, xdg-open).
|
||||
- name: Build deb + rpm (nfpm)
|
||||
env:
|
||||
QBZ_VERSION: ${{ env.VERSION }}
|
||||
QBZ_ARCH: arm64
|
||||
QBZ_BINARY: ./qbz
|
||||
QBZ_ARCH: ${{ env.DEB_ARCH }}
|
||||
run: |
|
||||
chmod +x qbz
|
||||
chmod +x qbzd
|
||||
mkdir -p dist
|
||||
./qbzd completions bash > qbzd.bash
|
||||
./qbzd completions zsh > qbzd.zsh
|
||||
./qbzd completions fish > qbzd.fish
|
||||
nfpm package -f packaging/nfpm/nfpm.yaml -p deb \
|
||||
-t "dist/QBZ_${VERSION}_arm64.deb"
|
||||
-t "dist/QBZ_${VERSION}_${DEB_ARCH}.deb"
|
||||
nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \
|
||||
-t "dist/QBZ-${VERSION}-1.aarch64.rpm"
|
||||
-t "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm"
|
||||
# Standalone qbzd packages (daemon-only), same staged ./qbzd.
|
||||
nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p deb \
|
||||
-t "dist/qbzd_${VERSION}_${DEB_ARCH}.deb"
|
||||
nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p rpm \
|
||||
-t "dist/qbzd-${VERSION}-1.${RPM_ARCH}.rpm"
|
||||
ls -la dist
|
||||
|
||||
# The same invariant on the ARTIFACTS: the deb/rpm must install
|
||||
# /usr/share/applications/<DesktopEntry>.desktop and its Icon=.
|
||||
- name: Gate — desktop entry / icon invariant (packages)
|
||||
run: |
|
||||
sudo apt-get install -y --no-install-recommends rpm
|
||||
scripts/packaging/desktop_entry_check.sh \
|
||||
--deb "dist/QBZ_${VERSION}_${DEB_ARCH}.deb" \
|
||||
--rpm "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm" \
|
||||
--appdir AppDir
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
APPDIR="QBZ.AppDir"
|
||||
mkdir -p "$APPDIR/usr/bin"
|
||||
cp qbz "$APPDIR/usr/bin/qbz"
|
||||
cp packaging/linux/qbz.desktop "$APPDIR/qbz.desktop"
|
||||
cp packaging/icons/256x256.png "$APPDIR/qbz.png"
|
||||
cp packaging/icons/256x256.png "$APPDIR/.DirIcon"
|
||||
ln -s usr/bin/qbz "$APPDIR/AppRun"
|
||||
|
||||
curl -fsSL -o appimagetool \
|
||||
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-aarch64.AppImage
|
||||
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
|
||||
chmod +x appimagetool
|
||||
ARCH=aarch64 ./appimagetool --appimage-extract-and-run "$APPDIR" \
|
||||
"dist/QBZ_${VERSION}_aarch64.AppImage"
|
||||
ARCH="${ARCH}" ./appimagetool --appimage-extract-and-run AppDir \
|
||||
"dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
|
||||
ls -la dist
|
||||
|
||||
# Tauri-updater bridge — see release-linux.yml for the rationale.
|
||||
# Tauri-updater bridge: v1.2.x AppImage installs poll latest.json and
|
||||
# verify artifacts with the Tauri minisign pubkey baked into the old
|
||||
# binaries. Signing with the SAME private key lets them auto-update
|
||||
# straight into this AppImage. release-updater-manifest.yml collects
|
||||
# the .sig. The app itself has no auto-updater.
|
||||
- name: Sign AppImage (Tauri-updater bridge)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
@@ -254,14 +269,16 @@ jobs:
|
||||
npx --yes @tauri-apps/cli@^2 signer sign \
|
||||
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
|
||||
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
|
||||
"dist/QBZ_${VERSION}_aarch64.AppImage"
|
||||
"dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
|
||||
ls -la dist
|
||||
|
||||
# The BARE binary. Its consumers (AUR, Gentoo, Flathub) provide Qt.
|
||||
- name: Build AUR/Gentoo tarball
|
||||
run: |
|
||||
# CONTRACT: same name + internal layout as the Tauri-era tarball.
|
||||
TARBALL_DIR="qbz_${VERSION}_aarch64"
|
||||
mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256}/apps
|
||||
# CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and
|
||||
# the website hard-code this name and internal layout.
|
||||
TARBALL_DIR="qbz_${VERSION}_${DEB_ARCH}"
|
||||
mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256,512x512}/apps
|
||||
cp qbz "tarball-staging/${TARBALL_DIR}/qbz"
|
||||
chmod +x "tarball-staging/${TARBALL_DIR}/qbz"
|
||||
cp packaging/icons/32x32.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/32x32/apps/qbz.png"
|
||||
@@ -269,57 +286,52 @@ jobs:
|
||||
cp packaging/icons/64x64.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/64x64/apps/qbz.png"
|
||||
cp packaging/icons/128x128.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/128x128/apps/qbz.png"
|
||||
cp packaging/icons/256x256.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/256x256/apps/qbz.png"
|
||||
cp packaging/icons/512x512.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/512x512/apps/qbz.png"
|
||||
cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop"
|
||||
# Additive (T14) — see release-linux.yml's matching block for the
|
||||
# layout-contract rationale.
|
||||
cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml"
|
||||
cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE"
|
||||
cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses"
|
||||
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
|
||||
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
|
||||
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service"
|
||||
mkdir -p "tarball-staging/${TARBALL_DIR}/completions"
|
||||
./qbzd completions bash > "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
|
||||
./qbzd completions zsh > "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
|
||||
./qbzd completions fish > "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
|
||||
tar -czvf "dist/qbz_${VERSION}_aarch64.tar.gz" -C tarball-staging "${TARBALL_DIR}"
|
||||
cp qbzd.bash "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
|
||||
cp qbzd.zsh "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
|
||||
cp qbzd.fish "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
|
||||
tar -czvf "dist/qbz_${VERSION}_${DEB_ARCH}.tar.gz" -C tarball-staging "${TARBALL_DIR}"
|
||||
ls -la dist
|
||||
|
||||
# T15 Step 2b (OD3, G0) — see release-linux.yml's matching step for
|
||||
# the rationale. Never touches snap/Flathub/AppImage/dmg.
|
||||
- name: qbzd-tarball
|
||||
run: |
|
||||
QBZD_DIR="qbzd-${VERSION}-linux-aarch64"
|
||||
QBZD_DIR="qbzd-${VERSION}-linux-${DEB_ARCH}"
|
||||
mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions"
|
||||
cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
|
||||
chmod +x "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
|
||||
cp crates/qbzd/service/qbzd.service "qbzd-tarball-staging/${QBZD_DIR}/qbzd.service"
|
||||
./qbzd completions bash > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
|
||||
./qbzd completions zsh > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
|
||||
./qbzd completions fish > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
|
||||
cp qbzd.bash "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
|
||||
cp qbzd.zsh "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
|
||||
cp qbzd.fish "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
|
||||
cp packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md"
|
||||
tar -czvf "dist/qbzd-${VERSION}-linux-aarch64.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
|
||||
cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE"
|
||||
cp -r licenses "qbzd-tarball-staging/${QBZD_DIR}/licenses"
|
||||
tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
|
||||
ls -la dist
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-aarch64-packages
|
||||
name: qbz-linux-packages-${{ env.ARCH }}
|
||||
path: dist/*
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
|
||||
# Attaches to the Release created by release-linux.yml.
|
||||
- name: Publish GitHub Release Assets
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/*
|
||||
prerelease: ${{ needs.build.outputs.prerelease }}
|
||||
make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
prerelease: ${{ needs.version.outputs.prerelease }}
|
||||
make_latest: ${{ needs.version.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
|
||||
# #648: the apt dispatch MUST also fire from here — this workflow is the
|
||||
# slow one (native arm64 build), so release-linux.yml's dispatch ran
|
||||
# hours earlier, when the Release still held only the x86_64 .deb; the
|
||||
# arm64 .deb landed after and nothing re-indexed (apt arm64 stuck at
|
||||
# 1.2.15). update-repo.yml is idempotent (re-downloads every *.deb,
|
||||
# overwrites the pool, rebuilds both indices), so a second run simply
|
||||
# completes whichever arch the first one missed.
|
||||
- name: Trigger APT repo update
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
@@ -328,3 +340,14 @@ jobs:
|
||||
repository: vicrodh/qbz-apt
|
||||
event-type: new-release
|
||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||
|
||||
# Our own signed RPM repo. This is intentionally required: a bad token
|
||||
# must not leave the stable RPM channel stale without failing release CI.
|
||||
- name: Trigger RPM repo update
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.APT_REPO_TOKEN }}
|
||||
repository: vicrodh/qbz-rpm
|
||||
event-type: new-release
|
||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||
+162
-173
@@ -1,17 +1,26 @@
|
||||
# Linux x86_64 release — Slint binary (crates/ workspace).
|
||||
# Linux x86_64 release — Qt frontend (crates/ workspace, package `qbz-qt`,
|
||||
# binary `qbz`).
|
||||
#
|
||||
# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Pattern:
|
||||
# build once, package many. The build job produces the binary with the
|
||||
# proven public-runner recipe from build-slint.yml (16 GB RAM + 18 GB extra
|
||||
# swap covers the ~30 GB qbz_ui rustc peak, MIN memory tier, mold, pinned
|
||||
# nightly); the package job turns it into deb/rpm (nfpm), AppImage, and the
|
||||
# AUR/Gentoo/APT tarball, then creates the GitHub Release.
|
||||
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Pattern is still
|
||||
# build once, package many — but the build is the shared build-qt-linux.yml
|
||||
# (workflow_call) and it yields TWO payloads:
|
||||
# * the bare binary -> qbz_${VERSION}_amd64.tar.gz (AUR / Gentoo / Flathub
|
||||
# repack: ecosystems that provide Qt >= 6.8 themselves)
|
||||
# * the AppDir (Qt bundled, self-contained) -> deb / rpm / AppImage, so
|
||||
# distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working.
|
||||
#
|
||||
# CONTRACT (do not break): the release must keep publishing
|
||||
# qbz_${VERSION}_amd64.tar.gz (dir qbz_${VERSION}_amd64/{qbz, qbz.desktop, icons/hicolor/...})
|
||||
# qbz_${VERSION}_amd64.tar.gz (dir qbz_${VERSION}_amd64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop,
|
||||
# com.blitzfc.qbz.metainfo.xml, qbzd.service,
|
||||
# completions/, icons/hicolor/...})
|
||||
# QBZ_${VERSION}_amd64.deb / QBZ-${VERSION}-1.x86_64.rpm / QBZ_${VERSION}_amd64.AppImage
|
||||
# — release-aur.yml, release-gentoo.yml, qbz-apt and the website consume
|
||||
# these names.
|
||||
# qbzd_${VERSION}_amd64.deb / qbzd-${VERSION}-1.x86_64.rpm / qbzd-${VERSION}-linux-amd64.tar.gz
|
||||
# qbz-${VERSION}-cargo-vendor.tar.xz (architecture-neutral, x86_64 workflow only)
|
||||
# — release-aur.yml, release-gentoo.yml, qbz-apt, qbz-rpm, the updater
|
||||
# manifest and the website consume these names.
|
||||
#
|
||||
# release-linux-aarch64.yml is this file with the four ARCH_* values changed,
|
||||
# except that x86_64 publishes the architecture-neutral Cargo vendor asset.
|
||||
#
|
||||
# Triggers: version tags (cut from main — enforced by the ancestry guard)
|
||||
# + manual dispatch (guard skipped, for testing on any branch).
|
||||
@@ -22,7 +31,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
|
||||
description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
@@ -33,17 +42,23 @@ concurrency:
|
||||
group: release-linux-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
ARCH: x86_64 # build-qt-linux input + artifact suffix
|
||||
DEB_ARCH: amd64 # QBZ_${V}_amd64.deb, qbz_${V}_amd64.tar.gz
|
||||
RPM_ARCH: x86_64 # QBZ-${V}-1.x86_64.rpm
|
||||
APPIMAGE_ARCH: amd64 # QBZ_${V}_amd64.AppImage
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Release bookkeeping first so a bad tag fails in seconds, not after a build.
|
||||
version:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 300
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
prerelease: ${{ steps.release.outputs.prerelease }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
@@ -69,8 +84,7 @@ jobs:
|
||||
|
||||
# Release tags must be cut from main. A tag on any other commit fires
|
||||
# this workflow too (tags are not branch-scoped in Actions), so verify
|
||||
# ancestry instead of trusting convention. Manual dispatch skips this
|
||||
# (that is the testing path).
|
||||
# ancestry instead of trusting convention. Manual dispatch skips this.
|
||||
- name: Verify tag is on main
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
@@ -80,11 +94,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail fast if the version bump was forgotten: the binary embeds the
|
||||
# workspace version (About dialog, diagnostics), so a mismatched tag
|
||||
# would ship a binary that reports the wrong version.
|
||||
# The binary embeds the workspace version (About, diagnostics); a
|
||||
# mismatched tag would ship a binary that reports the wrong version.
|
||||
- name: Assert Cargo.toml version matches tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
@@ -94,100 +107,45 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The qbz_ui rustc needs ~30 GB (RSS+swap). Public runner: 16 GB RAM
|
||||
# + ~4 GB stock swap; free disk by dropping preinstalled toolchains,
|
||||
# then add 18 GB swap -> ~38 GB total. Same recipe as build-slint.yml.
|
||||
- name: Free disk space and add swap
|
||||
# The media-widget icon invariant (DesktopEntry == installed .desktop
|
||||
# basename, Icon= installed) — regressed more than once, so it is a
|
||||
# gate at the recipe level here and on the built packages below.
|
||||
- name: Gate — desktop entry / icon invariant (recipes)
|
||||
run: scripts/packaging/desktop_entry_check.sh
|
||||
|
||||
# No workflow may build the frozen Slint binary: same bin name, same
|
||||
# target dir, last build wins the file — a smoke against "the wrong
|
||||
# qbz" is silent.
|
||||
- name: Gate — no workflow builds `-p qbz`
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
||||
/usr/local/lib/node_modules || true
|
||||
sudo docker system prune -af || true
|
||||
if sudo fallocate -l 18G /swap_extra; then
|
||||
sudo chmod 600 /swap_extra
|
||||
sudo mkswap /swap_extra
|
||||
sudo swapon /swap_extra
|
||||
else
|
||||
echo "::warning::could not allocate extra swap; build may OOM"
|
||||
fi
|
||||
free -h
|
||||
set -euo pipefail
|
||||
hits=$(grep -rnE 'cargo (build|check|test|run|install).* -p qbz([^-a-z]|$)' .github/workflows/ || true)
|
||||
[ -z "$hits" ] || { echo "::error::a workflow still builds the Slint crate:"; echo "$hits"; exit 1; }
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
mold nasm \
|
||||
libasound2-dev libjack-jackd2-dev \
|
||||
libfontconfig1-dev libfreetype-dev \
|
||||
libxkbcommon-dev libwayland-dev libxcb1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev \
|
||||
libdbus-1-dev libssl-dev \
|
||||
libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
|
||||
libjpeg-dev zlib1g-dev libgumbo-dev
|
||||
|
||||
# Pinned nightly: matches build-slint.yml (same measured memory
|
||||
# behavior, -Z threads support). Bump both files together.
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
build:
|
||||
needs: version
|
||||
uses: ./.github/workflows/build-qt-linux.yml
|
||||
with:
|
||||
toolchain: nightly-2026-06-23
|
||||
arch: x86_64
|
||||
source_ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: slint-linux
|
||||
cache-on-failure: true
|
||||
# Own the amd64 `slint-linux` cache: save on main so pre-flights warm
|
||||
# it and tag/PR runs restore it. build-slint.yml no longer runs, so
|
||||
# deferring to it (save-if: false) left the amd64 dep cache EMPTY and
|
||||
# every release/snap amd64 build fully cold. Mirrors release-linux-aarch64.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build (release, MIN memory tier)
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_BUILD_JOBS: "1"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256"
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: "2"
|
||||
# Keep symbols in this build: the unstripped binary is archived for
|
||||
# crash symbolication, then stripped for every shipped artifact.
|
||||
CARGO_PROFILE_RELEASE_STRIP: "none"
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold -Z threads=1"
|
||||
run: |
|
||||
cargo build --release --manifest-path crates/Cargo.toml -p qbz
|
||||
cp crates/target/release/qbz qbz-unstripped
|
||||
strip -o qbz crates/target/release/qbz
|
||||
ls -la qbz qbz-unstripped
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-x86_64
|
||||
path: qbz
|
||||
if-no-files-found: error
|
||||
|
||||
# Symbolication artifact: panic backtraces in shipped binaries are
|
||||
# address-only (profile strips symbols); this copy resolves them.
|
||||
- name: Upload unstripped binary (symbolication)
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-x86_64-unstripped
|
||||
path: qbz-unstripped
|
||||
retention-days: 90
|
||||
|
||||
# T15: qbzd is minutes-class (no qbz_ui compilation unit), so none of the
|
||||
# desktop build's swap/tier ceremony applies. Runs on ubuntu-22.04 (not
|
||||
# 24.04 like the desktop `build` job above) so the shipped binary's glibc
|
||||
# floor stays <= Pi OS bookworm's 2.36 (D13, the honest-Pi-story gate) —
|
||||
# the objdump step below verifies that empirically rather than trusting
|
||||
# the runner's advertised glibc version.
|
||||
# qbzd is minutes-class and Slint-free. Runs on ubuntu-22.04 so the shipped
|
||||
# binary's glibc floor stays <= Pi OS bookworm's 2.36 (the honest-Pi-story
|
||||
# gate); the objdump step verifies that empirically.
|
||||
build-qbzd:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: qbzd-linux-x86_64
|
||||
cache-on-failure: true
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
|
||||
- name: system deps
|
||||
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev
|
||||
- name: slint-free dep-graph gate
|
||||
@@ -206,40 +164,48 @@ jobs:
|
||||
echo "glibc floor: $floor"
|
||||
highest=$(printf '%s\n' "$floor" "GLIBC_2.35" | sort -V | tail -1)
|
||||
[ "$highest" = "GLIBC_2.35" ] || { echo "::error::floor $floor exceeds 2.35"; exit 1; }
|
||||
# NOTE: the T15 brief's literal YAML names this artifact
|
||||
# `qbzd-linux-amd64`, but T14 (39eb12fc, already merged into this
|
||||
# branch) wired the package job's Download-qbzd step below to expect
|
||||
# `qbzd-linux-x86_64` — matching what already landed wins over the
|
||||
# brief text (see task-15-report.md for the full note).
|
||||
- uses: actions/upload-artifact@v7
|
||||
with: { name: qbzd-linux-x86_64, path: crates/target/release/qbzd }
|
||||
with:
|
||||
name: qbzd-linux-x86_64
|
||||
path: crates/target/release/qbzd
|
||||
retention-days: 7
|
||||
|
||||
package:
|
||||
needs: [build, build-qbzd]
|
||||
needs: [version, build, build-qbzd]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Download binary
|
||||
- name: Install Rust toolchain for source-package vendor archive
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Download bare binary
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbz-linux-x86_64
|
||||
name: qbz-linux-${{ env.ARCH }}
|
||||
|
||||
- name: Download AppDir
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbz-appdir-${{ env.ARCH }}
|
||||
|
||||
# T14: the slint-free qbzd binary, produced by T15's build-qbzd job
|
||||
# (a separate ubuntu-22.04 job, not an extension of `build` above —
|
||||
# see that job for the glibc-floor rationale). This ONE artifact is
|
||||
# consumed for deb/rpm (nfpm), the main AUR/Gentoo tarball below, AND
|
||||
# the standalone qbzd tarball (T15 Step 2b) further down — all three
|
||||
# ship the same 2.35-glibc-floor binary.
|
||||
- name: Download qbzd binary
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbzd-linux-x86_64
|
||||
name: qbzd-linux-${{ env.ARCH }}
|
||||
|
||||
- name: Unpack AppDir
|
||||
run: |
|
||||
tar -xf appdir.tar
|
||||
chmod +x qbz qbzd
|
||||
ls AppDir; ls AppDir/usr/plugins/platforms
|
||||
|
||||
- name: Install nfpm
|
||||
run: |
|
||||
@@ -249,46 +215,55 @@ jobs:
|
||||
sudo apt-get install -y nfpm
|
||||
nfpm --version
|
||||
|
||||
# deb/rpm carry the AppDir tree under /opt/qbz (bin, lib, plugins, qml,
|
||||
# qt.conf) and a /usr/bin/qbz symlink. RPATH ($ORIGIN/../lib, patched by
|
||||
# linuxdeploy) resolves through /proc/self/exe, so the symlink needs no
|
||||
# wrapper — and a wrapper exporting LD_LIBRARY_PATH would leak our Qt
|
||||
# into every process qbz spawns (the login browser, xdg-open).
|
||||
- name: Build deb + rpm (nfpm)
|
||||
env:
|
||||
QBZ_VERSION: ${{ env.VERSION }}
|
||||
QBZ_ARCH: amd64
|
||||
QBZ_BINARY: ./qbz
|
||||
QBZ_ARCH: ${{ env.DEB_ARCH }}
|
||||
run: |
|
||||
chmod +x qbz
|
||||
chmod +x qbzd
|
||||
mkdir -p dist
|
||||
./qbzd completions bash > qbzd.bash
|
||||
./qbzd completions zsh > qbzd.zsh
|
||||
./qbzd completions fish > qbzd.fish
|
||||
nfpm package -f packaging/nfpm/nfpm.yaml -p deb \
|
||||
-t "dist/QBZ_${VERSION}_amd64.deb"
|
||||
-t "dist/QBZ_${VERSION}_${DEB_ARCH}.deb"
|
||||
nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \
|
||||
-t "dist/QBZ-${VERSION}-1.x86_64.rpm"
|
||||
-t "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm"
|
||||
# Standalone qbzd packages (daemon-only), same staged ./qbzd.
|
||||
nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p deb \
|
||||
-t "dist/qbzd_${VERSION}_${DEB_ARCH}.deb"
|
||||
nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p rpm \
|
||||
-t "dist/qbzd-${VERSION}-1.${RPM_ARCH}.rpm"
|
||||
ls -la dist
|
||||
|
||||
# The same invariant on the ARTIFACTS: the deb/rpm must install
|
||||
# /usr/share/applications/<DesktopEntry>.desktop and its Icon=.
|
||||
- name: Gate — desktop entry / icon invariant (packages)
|
||||
run: |
|
||||
sudo apt-get install -y --no-install-recommends rpm
|
||||
scripts/packaging/desktop_entry_check.sh \
|
||||
--deb "dist/QBZ_${VERSION}_${DEB_ARCH}.deb" \
|
||||
--rpm "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm" \
|
||||
--appdir AppDir
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
# Minimal AppDir: the measured runtime deps (alsa/fontconfig/
|
||||
# freetype/png/bz2/expat/zlib) are base-system libraries on every
|
||||
# target distro, so nothing is bundled beyond the binary.
|
||||
APPDIR="QBZ.AppDir"
|
||||
mkdir -p "$APPDIR/usr/bin"
|
||||
cp qbz "$APPDIR/usr/bin/qbz"
|
||||
cp packaging/linux/qbz.desktop "$APPDIR/qbz.desktop"
|
||||
cp packaging/icons/256x256.png "$APPDIR/qbz.png"
|
||||
cp packaging/icons/256x256.png "$APPDIR/.DirIcon"
|
||||
ln -s usr/bin/qbz "$APPDIR/AppRun"
|
||||
|
||||
curl -fsSL -o appimagetool \
|
||||
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
|
||||
chmod +x appimagetool
|
||||
ARCH=x86_64 ./appimagetool --appimage-extract-and-run "$APPDIR" \
|
||||
"dist/QBZ_${VERSION}_amd64.AppImage"
|
||||
ARCH="${ARCH}" ./appimagetool --appimage-extract-and-run AppDir \
|
||||
"dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
|
||||
ls -la dist
|
||||
|
||||
# Tauri-updater bridge: v1.2.x AppImage installs poll latest.json and
|
||||
# verify artifacts with the Tauri minisign pubkey baked into the old
|
||||
# binaries. Signing the Slint AppImage with the SAME private key lets
|
||||
# them auto-update straight into v2 (the updater just swaps the
|
||||
# AppImage file). release-updater-manifest.yml collects the .sig into
|
||||
# latest.json. The Slint app itself has no auto-updater — this exists
|
||||
# only to migrate old installs.
|
||||
# binaries. Signing with the SAME private key lets them auto-update
|
||||
# straight into this AppImage. release-updater-manifest.yml collects
|
||||
# the .sig. The app itself has no auto-updater.
|
||||
- name: Sign AppImage (Tauri-updater bridge)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
@@ -298,15 +273,16 @@ jobs:
|
||||
npx --yes @tauri-apps/cli@^2 signer sign \
|
||||
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
|
||||
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
|
||||
"dist/QBZ_${VERSION}_amd64.AppImage"
|
||||
"dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
|
||||
ls -la dist
|
||||
|
||||
# The BARE binary. Its consumers (AUR, Gentoo, Flathub) provide Qt.
|
||||
- name: Build AUR/Gentoo tarball
|
||||
run: |
|
||||
# CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and
|
||||
# the website hard-code this name and internal layout.
|
||||
TARBALL_DIR="qbz_${VERSION}_amd64"
|
||||
mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256}/apps
|
||||
TARBALL_DIR="qbz_${VERSION}_${DEB_ARCH}"
|
||||
mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256,512x512}/apps
|
||||
cp qbz "tarball-staging/${TARBALL_DIR}/qbz"
|
||||
chmod +x "tarball-staging/${TARBALL_DIR}/qbz"
|
||||
cp packaging/icons/32x32.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/32x32/apps/qbz.png"
|
||||
@@ -314,55 +290,57 @@ jobs:
|
||||
cp packaging/icons/64x64.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/64x64/apps/qbz.png"
|
||||
cp packaging/icons/128x128.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/128x128/apps/qbz.png"
|
||||
cp packaging/icons/256x256.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/256x256/apps/qbz.png"
|
||||
cp packaging/icons/512x512.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/512x512/apps/qbz.png"
|
||||
cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop"
|
||||
# Additive (T14): qbzd + its user unit + shell completions. Every
|
||||
# entry above is untouched — the layout contract (header lines
|
||||
# 10-14) forbids renaming/moving existing entries. AUR/Gentoo/flake
|
||||
# install-line updates to pick these up are P1 (T22, per D13); the
|
||||
# files are inert there until then.
|
||||
cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml"
|
||||
cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE"
|
||||
cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses"
|
||||
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
|
||||
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
|
||||
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service"
|
||||
mkdir -p "tarball-staging/${TARBALL_DIR}/completions"
|
||||
./qbzd completions bash > "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
|
||||
./qbzd completions zsh > "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
|
||||
./qbzd completions fish > "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
|
||||
tar -czvf "dist/qbz_${VERSION}_amd64.tar.gz" -C tarball-staging "${TARBALL_DIR}"
|
||||
cp qbzd.bash "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
|
||||
cp qbzd.zsh "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
|
||||
cp qbzd.fish "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
|
||||
tar -czvf "dist/qbz_${VERSION}_${DEB_ARCH}.tar.gz" -C tarball-staging "${TARBALL_DIR}"
|
||||
ls -la dist
|
||||
|
||||
# T15 Step 2b (OD3, G0): the standalone qbzd tarball — the independent
|
||||
# daemon download the owner asked for, separate from the main qbz
|
||||
# tarball above. Never touches snap/Flathub/AppImage/dmg (owner
|
||||
# constraint) — this only adds one more file into dist/, which the
|
||||
# existing Upload-artifacts and Publish-Release-Assets steps below
|
||||
# already pick up unmodified via their dist/* globs.
|
||||
- name: qbzd-tarball
|
||||
run: |
|
||||
QBZD_DIR="qbzd-${VERSION}-linux-amd64"
|
||||
QBZD_DIR="qbzd-${VERSION}-linux-${DEB_ARCH}"
|
||||
mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions"
|
||||
cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
|
||||
chmod +x "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
|
||||
cp crates/qbzd/service/qbzd.service "qbzd-tarball-staging/${QBZD_DIR}/qbzd.service"
|
||||
./qbzd completions bash > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
|
||||
./qbzd completions zsh > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
|
||||
./qbzd completions fish > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
|
||||
cp qbzd.bash "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
|
||||
cp qbzd.zsh "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
|
||||
cp qbzd.fish "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
|
||||
cp packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md"
|
||||
tar -czvf "dist/qbzd-${VERSION}-linux-amd64.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
|
||||
cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE"
|
||||
cp -r licenses "qbzd-tarball-staging/${QBZD_DIR}/licenses"
|
||||
tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
|
||||
ls -la dist
|
||||
|
||||
# One architecture-neutral, locked dependency archive serves the qbz
|
||||
# and qbzd source packages in AUR and Gentoo. It is generated only here,
|
||||
# never duplicated by the arm64 release workflow.
|
||||
- name: Build Cargo vendor archive for source packages
|
||||
run: scripts/packaging/cargo_vendor_release.sh "$VERSION" dist
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-linux-packages
|
||||
name: qbz-linux-packages-${{ env.ARCH }}
|
||||
path: dist/*
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
|
||||
- name: Publish GitHub Release Assets
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/*
|
||||
prerelease: ${{ needs.build.outputs.prerelease }}
|
||||
make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
prerelease: ${{ needs.version.outputs.prerelease }}
|
||||
make_latest: ${{ needs.version.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
|
||||
- name: Trigger APT repo update
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
@@ -372,3 +350,14 @@ jobs:
|
||||
repository: vicrodh/qbz-apt
|
||||
event-type: new-release
|
||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||
|
||||
# Our own signed RPM repo. This is intentionally required: a bad token
|
||||
# must not leave the stable RPM channel stale without failing release CI.
|
||||
- name: Trigger RPM repo update
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.APT_REPO_TOKEN }}
|
||||
repository: vicrodh/qbz-rpm
|
||||
event-type: new-release
|
||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||
@@ -1,16 +1,26 @@
|
||||
# macOS release — Slint binary (crates/ workspace).
|
||||
# macOS release — Qt frontend (crates/ workspace, package `qbz-qt`, binary `qbz`).
|
||||
#
|
||||
# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Builds the
|
||||
# qbz binary per target, assembles a minimal QBZ.app bundle (Info.plist +
|
||||
# icns + binary), ad-hoc signs it (same trust level as the unsigned Tauri
|
||||
# builds), and packs a dmg with hdiutil. No Tauri updater: the .app.tar.gz
|
||||
# + .sig updater assets are gone, dmg is the distribution.
|
||||
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Recipe measured on
|
||||
# the Mac mini M2 (qbz-nix-docs/cicd/2026-08-25-qt-workflows-contracts/ V5b):
|
||||
# * Qt from aqt (official, UNIVERSAL frameworks, min macOS 12) — NOT Homebrew:
|
||||
# Homebrew splits Qt across formulae and macdeployqt cannot resolve
|
||||
# @rpath/QtQml.framework in that layout (600 rpath errors, the .app dies
|
||||
# with "no Qt platform plugin"). Homebrew bottles also target the runner's
|
||||
# own macOS and would silently raise LSMinimumSystemVersion.
|
||||
# * `macdeployqt -qmldir` is MANDATORY: a Qt binary is not self-contained.
|
||||
# The Slint workflow copied the bare Mach-O into Contents/MacOS; a Qt
|
||||
# .app assembled that way dies on launch on every machine that is not
|
||||
# the build box.
|
||||
# * CXXFLAGS=-include arm_acle.h (arm64 only): Qt 6.9.x's qyieldcpu.h calls
|
||||
# __yield() without <arm_acle.h>; Apple clang 21 makes it a hard error.
|
||||
# Harmless where the compiler does not care. Drop when the pin has the fix.
|
||||
# * MACOSX_DEPLOYMENT_TARGET=12.0 -> `minos 12.0` in the Mach-O (rustc's
|
||||
# default is 11.0, cc-rs otherwise picks the host SDK).
|
||||
# * The smoke gate runs the BUNDLE offscreen with the Qt install removed.
|
||||
#
|
||||
# MEMORY NOTE: hosted macOS runners have ~7 GB RAM; the qbz_ui rustc peaks
|
||||
# ~30 GB on Linux. macOS swaps dynamically to SSD so the build may survive
|
||||
# (slowly) — if it proves unreliable, point runs-on at a self-hosted runner
|
||||
# on the M2 Mini instead. continue-on-error keeps a mac failure from
|
||||
# blocking the Linux release.
|
||||
# macOS pins 6.9 (not the Linux 6.8 LTS): the traffic-lights fix uses
|
||||
# Window paddings + SafeArea (Qt 6.9), and Qt is always bundled here so a
|
||||
# higher floor costs no reach. `vars.QT_VERSION_MACOS` overrides.
|
||||
|
||||
name: release-macos
|
||||
|
||||
@@ -18,7 +28,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
|
||||
description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
@@ -29,27 +39,39 @@ concurrency:
|
||||
group: release-macos-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
QT_VERSION: ${{ vars.QT_VERSION_MACOS || '6.9.3' }}
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
runner: macos-latest
|
||||
artifact: qbz-macos-aarch64
|
||||
arch: aarch64
|
||||
# See header: Qt 6.9 header bug on Apple clang 21 (arm64 code path).
|
||||
cxxflags: "-include arm_acle.h"
|
||||
- target: x86_64-apple-darwin
|
||||
# macos-latest is arm64; Intel needs an Intel runner (arm_acle.h
|
||||
# is ARM-only, so no CXXFLAGS here). macos-26-intel so both legs
|
||||
# carry Xcode 26's actool for the Icon Composer app icon.
|
||||
runner: macos-26-intel
|
||||
artifact: qbz-macos-x86_64
|
||||
# Historical dmg naming: intel assets ship as "x64".
|
||||
arch: x64
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 360
|
||||
continue-on-error: true
|
||||
cxxflags: ""
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
@@ -81,7 +103,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Assert Cargo.toml version matches tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
@@ -91,35 +113,48 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Same pinned nightly as the Linux builders (-Z threads support,
|
||||
# consistent measured behavior). Bump all release workflows together.
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
# Without qtshadertools: the committed .qsb ship (see build-qt-linux.yml).
|
||||
- name: Install Qt ${{ env.QT_VERSION }} (universal)
|
||||
uses: jurplel/install-qt-action@v4
|
||||
with:
|
||||
version: ${{ env.QT_VERSION }}
|
||||
host: mac
|
||||
target: desktop
|
||||
arch: clang_64
|
||||
cache: true
|
||||
|
||||
- name: Setup Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly-2026-06-23
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: slint-macos-${{ matrix.target }}
|
||||
shared-key: qt-macos-${{ matrix.target }}-${{ env.QT_VERSION }}
|
||||
cache-on-failure: true
|
||||
save-if: true
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
|
||||
|
||||
# MIN memory tier — the 7 GB hosted runner relies on macOS dynamic
|
||||
# swap for the qbz_ui rustc peak. Binary is stripped by the workspace
|
||||
# profile (strip = "symbols"), which rustc applies correctly to Mach-O.
|
||||
- name: Build (release, MIN memory tier)
|
||||
# NEVER `-p qbz` (the frozen Slint binary shares the bin name).
|
||||
- name: Build (release)
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_BUILD_JOBS: "1"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256"
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: "2"
|
||||
RUSTFLAGS: "-Z threads=1"
|
||||
CMAKE_PREFIX_PATH: ${{ env.QT_ROOT_DIR }}
|
||||
QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake
|
||||
CXXFLAGS: ${{ matrix.cxxflags }}
|
||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
||||
run: |
|
||||
cargo build --release --manifest-path crates/Cargo.toml \
|
||||
-p qbz --target ${{ matrix.target }}
|
||||
python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml \
|
||||
-p qbz-qt --target ${{ matrix.target }}
|
||||
otool -l "crates/target/${{ matrix.target }}/release/qbz" | grep -A4 LC_BUILD_VERSION | grep minos
|
||||
|
||||
- name: Slint-free dep-graph gate (qbz-qt)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \
|
||||
| grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true)
|
||||
[ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; }
|
||||
|
||||
- name: Assemble QBZ.app
|
||||
env:
|
||||
@@ -131,6 +166,32 @@ jobs:
|
||||
cp "crates/target/${{ matrix.target }}/release/qbz" "$APP/Contents/MacOS/qbz"
|
||||
chmod +x "$APP/Contents/MacOS/qbz"
|
||||
cp packaging/icons/icon.icns "$APP/Contents/Resources/icon.icns"
|
||||
cp LICENSE "$APP/Contents/Resources/LICENSE.txt"
|
||||
cp -r licenses "$APP/Contents/Resources/licenses"
|
||||
# The Liquid Glass app icon (packaging/icons/QBZ.icon, Icon Composer
|
||||
# source, issue #712): compiled by Xcode 26's actool into
|
||||
# Assets.car + CFBundleIconName=QBZ. macOS 26 renders the layered
|
||||
# icon; older macOS and a runner without Xcode 26 fall back to
|
||||
# icon.icns via CFBundleIconFile, which is always written.
|
||||
ICON_NAME_KEY=""
|
||||
if xcrun --find actool >/dev/null 2>&1 \
|
||||
&& [[ "$(xcodebuild -version | head -1 | grep -oE '[0-9]+' | head -1)" -ge 26 ]]; then
|
||||
xcrun actool packaging/icons/QBZ.icon \
|
||||
--compile "$APP/Contents/Resources" \
|
||||
--app-icon QBZ \
|
||||
--platform macosx --minimum-deployment-target 12.0 \
|
||||
--include-all-app-icons \
|
||||
--output-partial-info-plist icon-partial.plist \
|
||||
--errors --warnings --notices 2>&1 | tee actool.log
|
||||
test -f "$APP/Contents/Resources/Assets.car" \
|
||||
|| { echo "::error::actool produced no Assets.car"; exit 1; }
|
||||
/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' icon-partial.plist \
|
||||
|| { echo "::error::actool partial plist has no CFBundleIconName"; exit 1; }
|
||||
ICON_NAME_KEY='<key>CFBundleIconName</key><string>QBZ</string>'
|
||||
echo "app icon: Icon Composer source compiled (Assets.car)"
|
||||
else
|
||||
echo "::warning::Xcode 26 actool not available — shipping icon.icns only"
|
||||
fi
|
||||
cat > "$APP/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -142,9 +203,10 @@ jobs:
|
||||
<key>CFBundleDisplayName</key><string>QBZ</string>
|
||||
<key>CFBundleExecutable</key><string>qbz</string>
|
||||
<key>CFBundleIconFile</key><string>icon</string>
|
||||
${ICON_NAME_KEY}
|
||||
<key>CFBundleShortVersionString</key><string>${VERSION}</string>
|
||||
<key>CFBundleVersion</key><string>${VERSION}</string>
|
||||
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
||||
<key>LSMinimumSystemVersion</key><string>12.0</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
<key>LSApplicationCategoryType</key><string>public.app-category.music</string>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
|
||||
@@ -159,10 +221,75 @@ jobs:
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
# Ad-hoc signature: required for arm64 Macs to launch at all;
|
||||
# matches the trust level of the previous unsigned Tauri builds.
|
||||
codesign --force --deep -s - "$APP"
|
||||
codesign --verify --verbose "$APP"
|
||||
|
||||
# Bundle the Qt frameworks, the cocoa platform plugin and the QML
|
||||
# modules the app imports (Effects, labs.qmlmodels, Controls, ...).
|
||||
# Measured: 21 s, ~225 MB. The optional sqldrivers plugins (iodbc,
|
||||
# mimer, libpq) are the only deploy "errors" and nothing uses Qt SQL.
|
||||
- name: macdeployqt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP="dist/QBZ.app"
|
||||
# Preserve the deployer's exit status. The old `... | grep ERROR ||
|
||||
# true` made every macdeployqt failure look successful because the
|
||||
# final `true` swallowed the whole pipeline's status.
|
||||
set +e
|
||||
"$QT_ROOT_DIR/bin/macdeployqt" "$APP" -qmldir=crates/qbz-qt/qml -verbose=1 2>&1 | tee macdeploy.log
|
||||
deploy_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
grep -E 'ERROR' macdeploy.log || true
|
||||
if (( deploy_status != 0 )); then
|
||||
echo "::error::macdeployqt failed with exit code $deploy_status"
|
||||
exit "$deploy_status"
|
||||
fi
|
||||
rm -rf "$APP/Contents/PlugIns/sqldrivers"
|
||||
test -f "$APP/Contents/PlugIns/platforms/libqcocoa.dylib"
|
||||
test -d "$APP/Contents/Resources/qml/QtQuick/Effects"
|
||||
test -f "$APP/Contents/Resources/qt.conf"
|
||||
# Every Qt reference must now be bundle-relative. Qt 6.9's
|
||||
# macdeployqt leaves the install names as @rpath/Qt*.framework and
|
||||
# resolves them through an LC_RPATH of @executable_path/../Frameworks
|
||||
# (measured 2026-08-29, macos-26 runner) — so the assert is: no
|
||||
# ABSOLUTE Qt path survives, and that rpath is present. The offscreen
|
||||
# smoke below (Qt install hidden) is the end-to-end proof.
|
||||
if otool -L "$APP/Contents/MacOS/qbz" | grep -E 'Qt.*\.framework' | grep -Eq '^\s*/'; then
|
||||
otool -L "$APP/Contents/MacOS/qbz"; echo "::error::binary still links Qt by absolute path outside the bundle"; exit 1
|
||||
fi
|
||||
if ! otool -l "$APP/Contents/MacOS/qbz" | grep -A2 LC_RPATH | grep -Eq '@(executable|loader)_path/\.\./Frameworks'; then
|
||||
otool -l "$APP/Contents/MacOS/qbz" | grep -A2 LC_RPATH; echo "::error::no bundle-relative LC_RPATH for the Qt frameworks"; exit 1
|
||||
fi
|
||||
# offscreen platform plugin: only for the smoke below (macdeployqt
|
||||
# deploys cocoa alone). Removed again before signing/packing.
|
||||
cp "$QT_ROOT_DIR/plugins/platforms/libqoffscreen.dylib" "$APP/Contents/PlugIns/platforms/"
|
||||
du -sh "$APP"
|
||||
|
||||
# The bundle must initialize AND survive without the Qt SDK. The helper
|
||||
# uses a private native macOS home (XDG alone is ineffective), restores
|
||||
# the SDK on failure/cancellation and reaps the observed process group.
|
||||
- name: Offscreen smoke (the bundle that ships)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP="dist/QBZ.app"
|
||||
python3 scripts/packaging/smoke_release.py "$APP/Contents/MacOS/qbz" \
|
||||
--hide-qt "$QT_ROOT_DIR" --seconds 45 --log smoke.log
|
||||
rm -f "$APP/Contents/PlugIns/platforms/libqoffscreen.dylib"
|
||||
|
||||
- name: Upload deployed startup evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-macos-smoke-${{ matrix.arch }}
|
||||
path: smoke.log*
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
# Ad-hoc signature: required for arm64 Macs to launch at all. AFTER
|
||||
# macdeployqt (it rewrites install names) and after the smoke plugin
|
||||
# is gone, so the seal matches what ships.
|
||||
- name: Sign
|
||||
run: |
|
||||
codesign --force --deep -s - "dist/QBZ.app"
|
||||
codesign --verify --verbose "dist/QBZ.app"
|
||||
|
||||
- name: Build dmg
|
||||
env:
|
||||
@@ -174,10 +301,9 @@ jobs:
|
||||
ls -la dist
|
||||
|
||||
# Tauri-updater bridge: v1.2.x installs poll latest.json and verify
|
||||
# with the Tauri minisign pubkey baked into the old binaries. The
|
||||
# updater expects QBZ_${ARCH}.app.tar.gz — keep producing it, signed
|
||||
# with the SAME key, so old installs update straight into the Slint
|
||||
# .app. release-updater-manifest.yml collects the .sig files.
|
||||
# with the Tauri minisign pubkey baked into the old binaries. Keep
|
||||
# producing QBZ_${ARCH}.app.tar.gz signed with the SAME key so old
|
||||
# installs update straight into this .app.
|
||||
- name: Package .app.tar.gz + sign (Tauri-updater bridge)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
@@ -196,13 +322,13 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
path: |
|
||||
dist/*.dmg
|
||||
dist/*.app.tar.gz
|
||||
dist/*.app.tar.gz.sig
|
||||
|
||||
# Attaches to the Release created by release-linux.yml; same
|
||||
# prerelease logic (the old workflow hardcoded prerelease: true).
|
||||
# Attaches to the Release created by release-linux.yml.
|
||||
- name: Publish GitHub Release Assets
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v3
|
||||
|
||||
+127
-179
@@ -1,27 +1,21 @@
|
||||
name: Release Snap (Slint, all-hosted)
|
||||
name: release-snap
|
||||
|
||||
# All-hosted Snap pipeline for the native (Slint) qbz — BOTH arches. NOTHING
|
||||
# runs on the dev box, so the lack of systemd/snapd there does not matter. Two
|
||||
# independent tracks, each = build-binary -> dump-pack -> publish:
|
||||
# amd64: build-binary (ubuntu-24.04, MIN-tier + 18 GB swap) -> snap
|
||||
# (packaging/snap-slint/snapcraft.yaml, plugin: dump).
|
||||
# arm64: build-binary-arm64 (native ubuntu-24.04-arm, free for public repos,
|
||||
# reuses release-linux-aarch64.yml's cargo cache) -> snap-arm64
|
||||
# (packaging/snap-slint/snapcraft-arm64.yaml, plugin: dump).
|
||||
# The heavy compile is on free public runners; the snap pack (dump, no compile)
|
||||
# runs in seconds on a runner that has snapd. This CI upload is the SOLE publish
|
||||
# path — no Launchpad / "Build from GitHub" is used (the root snapcraft.yaml is
|
||||
# reference/local-dev only and is removed from the CI working copy below).
|
||||
# All-hosted Snap pipeline — BOTH arches. Each track = build (the shared
|
||||
# build-qt-linux.yml, which yields the self-contained AppDir) -> dump-pack ->
|
||||
# publish. No compilation in snapcraft; the pack runs in seconds on a runner
|
||||
# with snapd. This CI upload is the SOLE publish path: the Launchpad /
|
||||
# "Build from GitHub" source recipe (root snapcraft.yaml) is DELETED — core24
|
||||
# has Qt 6.4.2 and the tree needs 6.8, so a source build there cannot even
|
||||
# resolve its dependencies (it OOM'd on their amd64 fleet in the Slint era
|
||||
# anyway).
|
||||
#
|
||||
# NOTE: build-binary* duplicates the deb/rpm builders' compile. When the release
|
||||
# pipeline is unified (build-once -> fan out to deb/rpm/appimage/snap/flatpak),
|
||||
# these jobs become `download-artifact` of the shared per-arch binaries instead.
|
||||
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to package (e.g. v1.2.16). Defaults to the pushed tag."
|
||||
description: "Release tag to package (e.g. v2.1.0). Defaults to the pushed tag."
|
||||
required: false
|
||||
type: string
|
||||
channel:
|
||||
@@ -30,6 +24,11 @@ on:
|
||||
default: edge
|
||||
type: choice
|
||||
options: [edge, beta, candidate, stable]
|
||||
publish:
|
||||
description: "Publish to the selected Snap Store channel"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -40,186 +39,133 @@ concurrency:
|
||||
group: release-snap-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# snapcore/action-{build,publish}@v1 still declares node20. Hosted runners
|
||||
# already execute them successfully on Node 24; make that migration explicit
|
||||
# before the Node 20 runtime is removed on 2026-09-16.
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||
|
||||
jobs:
|
||||
build-binary:
|
||||
version:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 180
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Free disk + swap (sized for the ~30 GB qbz_ui peak)
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
||||
/usr/local/lib/node_modules || true
|
||||
sudo docker image prune --all --force || true
|
||||
( sudo fallocate -l 18G /swap_extra \
|
||||
&& sudo chmod 600 /swap_extra \
|
||||
&& sudo mkswap /swap_extra \
|
||||
&& sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)"
|
||||
free -h; df -h /
|
||||
|
||||
- name: System dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake clang libclang-dev mold nasm \
|
||||
libasound2-dev libjack-jackd2-dev \
|
||||
libfontconfig1-dev libfreetype-dev \
|
||||
libxkbcommon-dev libwayland-dev libxcb1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev \
|
||||
libdbus-1-dev libssl-dev \
|
||||
libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
|
||||
libjpeg-dev zlib1g-dev libgumbo-dev
|
||||
|
||||
- name: Rust toolchain (pinned dated nightly)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: nightly-2026-06-23
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cargo cache (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: slint-linux
|
||||
cache-on-failure: true
|
||||
# share the SAME cache as build-slint.yml (same shared-key) so the
|
||||
# heavy qbz_ui rlib is reused across both workflows.
|
||||
save-if: false
|
||||
|
||||
- name: Build qbz (memory-tuned MIN tier, swap-backed)
|
||||
- name: Resolve and validate package version
|
||||
id: release
|
||||
env:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_BUILD_JOBS: '1'
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256'
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: '2'
|
||||
RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1'
|
||||
DISPATCH_TAG: ${{ inputs.tag }}
|
||||
DISPATCH_PUBLISH: ${{ inputs.publish }}
|
||||
run: |
|
||||
cargo build --release --manifest-path crates/Cargo.toml -p qbz
|
||||
strip crates/target/release/qbz || true
|
||||
ls -lh crates/target/release/qbz
|
||||
set -euo pipefail
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
elif [[ -n "${{ inputs.tag }}" ]]; then
|
||||
VERSION="${{ inputs.tag }}"
|
||||
VERSION="${VERSION#v}"
|
||||
else
|
||||
VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = "\(.*\)"/\1/')"
|
||||
fi
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid Snap version: $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
CARGO_VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = "\(.*\)"/\1/')"
|
||||
if [[ "$CARGO_VERSION" != "$VERSION" ]]; then
|
||||
echo "::error::crates/Cargo.toml workspace version ($CARGO_VERSION) != requested version ($VERSION)."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$DISPATCH_PUBLISH" == true && -z "$DISPATCH_TAG" ]]; then
|
||||
echo "::error::Manual publishing requires an explicit release tag"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-bin-amd64
|
||||
path: crates/target/release/qbz
|
||||
if-no-files-found: error
|
||||
|
||||
build-binary-arm64:
|
||||
# Native arm64 build (mirrors release-linux-aarch64.yml). Reuses that
|
||||
# workflow's cargo cache (shared-key slint-linux-aarch64) so it does not
|
||||
# recompile qbz_ui from cold when the aarch64 build already ran.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Free disk + swap (sized for the ~30 GB qbz_ui peak)
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
||||
/usr/local/lib/node_modules || true
|
||||
sudo docker image prune --all --force || true
|
||||
( sudo fallocate -l 18G /swap_extra \
|
||||
&& sudo chmod 600 /swap_extra \
|
||||
&& sudo mkswap /swap_extra \
|
||||
&& sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)"
|
||||
free -h; df -h /
|
||||
|
||||
- name: System dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake clang libclang-dev mold nasm \
|
||||
libasound2-dev libjack-jackd2-dev \
|
||||
libfontconfig1-dev libfreetype-dev \
|
||||
libxkbcommon-dev libwayland-dev libxcb1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev \
|
||||
libdbus-1-dev libssl-dev \
|
||||
libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
|
||||
libjpeg-dev zlib1g-dev libgumbo-dev
|
||||
|
||||
- name: Rust toolchain (pinned dated nightly)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: nightly-2026-06-23
|
||||
|
||||
- name: Cargo cache (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: slint-linux-aarch64
|
||||
cache-on-failure: true
|
||||
# reuse release-linux-aarch64.yml's cache (same shared-key); do not
|
||||
# clobber it from here.
|
||||
save-if: false
|
||||
|
||||
- name: Build qbz (memory-tuned MIN tier, swap-backed)
|
||||
- name: Verify tag is on main
|
||||
if: startsWith(github.ref, 'refs/tags/') || inputs.publish
|
||||
env:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_BUILD_JOBS: '1'
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256'
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: '2'
|
||||
# No x86 target-features here (aarch64 baseline has NEON).
|
||||
RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1'
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
cargo build --release --manifest-path crates/Cargo.toml -p qbz
|
||||
strip crates/target/release/qbz || true
|
||||
ls -lh crates/target/release/qbz
|
||||
set -euo pipefail
|
||||
tag="v${VERSION}"
|
||||
git fetch origin main --tags
|
||||
if ! git show-ref --verify --quiet "refs/tags/${tag}"; then
|
||||
echo "::error::Release tag ${tag} does not exist."
|
||||
exit 1
|
||||
fi
|
||||
tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")"
|
||||
checkout_sha="$(git rev-parse HEAD)"
|
||||
if [[ "$tag_sha" != "$checkout_sha" ]]; then
|
||||
echo "::error::Checked-out commit does not match ${tag}."
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
|
||||
echo "::error::Tag ${tag} is not on main. Releases are cut from main only."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
build-amd64:
|
||||
needs: version
|
||||
uses: ./.github/workflows/build-qt-linux.yml
|
||||
with:
|
||||
name: qbz-bin-arm64
|
||||
path: crates/target/release/qbz
|
||||
if-no-files-found: error
|
||||
arch: x86_64
|
||||
source_ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
build-arm64:
|
||||
needs: version
|
||||
uses: ./.github/workflows/build-qt-linux.yml
|
||||
with:
|
||||
arch: aarch64
|
||||
source_ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
snap:
|
||||
needs: build-binary
|
||||
needs: [version, build-amd64]
|
||||
runs-on: ubuntu-24.04
|
||||
# 30 min was too tight: the v2.0.0 tag run got axed mid-snapcraft (LXD
|
||||
# setup + platform snaps + pack ran past it). The job does no compilation,
|
||||
# but snapcraft's container bootstrap alone is highly variable on public
|
||||
# runners — give it real headroom.
|
||||
# No compilation here; the headroom is for snapcraft's LXD bootstrap,
|
||||
# which is highly variable on hosted runners (a 30 min cap got the
|
||||
# v2.0.0 run axed mid-pack).
|
||||
timeout-minutes: 90
|
||||
# Credentials at job level so the publish step can gate on them: an empty /
|
||||
# unset secret SKIPS publish instead of failing the whole run (the snap is
|
||||
# still built and uploaded as an artifact).
|
||||
# unset secret SKIPS publish instead of failing the whole run.
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Download prebuilt binary
|
||||
- name: Download AppDir
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbz-bin-amd64
|
||||
path: dl
|
||||
name: qbz-appdir-x86_64
|
||||
|
||||
- name: Stage snap payload (binary + desktop + icon) and recipe
|
||||
- name: Stage snap payload (AppDir tree + desktop + icon) and recipe
|
||||
run: |
|
||||
set -eux
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
VER="${TAG#v}"
|
||||
VER="$VERSION"
|
||||
tar -xf appdir.tar
|
||||
mkdir -p snap-payload
|
||||
install -m755 dl/qbz snap-payload/qbz
|
||||
cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/
|
||||
cp packaging/snap/snap/gui/qbz.desktop snap-payload/qbz.desktop
|
||||
cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png
|
||||
# Place the dump recipe where snapcraft looks for it, and stamp version.
|
||||
cp LICENSE snap-payload/LICENSE
|
||||
cp -r licenses snap-payload/licenses
|
||||
test -f snap-payload/licenses/Apache-2.0-dst-decoder.txt
|
||||
mkdir -p snap
|
||||
cp packaging/snap-slint/snapcraft.yaml snap/snapcraft.yaml
|
||||
cp packaging/snap-qt/snapcraft.yaml snap/snapcraft.yaml
|
||||
sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml
|
||||
# The repo ROOT also carries snapcraft.yaml (the SOURCE-build recipe
|
||||
# for Snap Store remote builders) and snapcraft resolves it over
|
||||
# snap/snapcraft.yaml — the v2.0.0 run recompiled the whole
|
||||
# workspace inside LXD and OOM'd the container. Remove the root
|
||||
# recipes from the CI working copy so only the dump recipe exists.
|
||||
# Belt and braces: no root recipe may shadow snap/snapcraft.yaml.
|
||||
rm -f snapcraft.yaml .snapcraft.yaml
|
||||
echo "Built snap version: ${VER}"
|
||||
cat snap/snapcraft.yaml | head -20
|
||||
head -20 snap/snapcraft.yaml
|
||||
|
||||
- name: Build the snap (no compilation — dump of the prebuilt binary)
|
||||
- name: Build the snap (dump of the prebuilt AppDir)
|
||||
id: build
|
||||
uses: snapcore/action-build@v1
|
||||
|
||||
@@ -229,51 +175,52 @@ jobs:
|
||||
name: qbz-snap-amd64
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
|
||||
- name: Publish to the Snap Store (skipped when no store credentials)
|
||||
if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' }}
|
||||
if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' && (startsWith(github.ref, 'refs/tags/') || inputs.publish) }}
|
||||
uses: snapcore/action-publish@v1
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ github.event.inputs.channel || 'edge' }}
|
||||
|
||||
snap-arm64:
|
||||
needs: build-binary-arm64
|
||||
needs: [version, build-arm64]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
# No compilation here (plugin: dump); the headroom is for snapcraft's LXD
|
||||
# bootstrap, which is variable on hosted runners.
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Download prebuilt binary
|
||||
- name: Download AppDir
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: qbz-bin-arm64
|
||||
path: dl
|
||||
name: qbz-appdir-aarch64
|
||||
|
||||
- name: Stage snap payload (binary + desktop + icon) and recipe
|
||||
- name: Stage snap payload (AppDir tree + desktop + icon) and recipe
|
||||
run: |
|
||||
set -eux
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
VER="${TAG#v}"
|
||||
VER="$VERSION"
|
||||
tar -xf appdir.tar
|
||||
mkdir -p snap-payload
|
||||
install -m755 dl/qbz snap-payload/qbz
|
||||
cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/
|
||||
cp packaging/snap/snap/gui/qbz.desktop snap-payload/qbz.desktop
|
||||
cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png
|
||||
# Place the arm64 dump recipe where snapcraft looks for it, stamp version.
|
||||
cp LICENSE snap-payload/LICENSE
|
||||
cp -r licenses snap-payload/licenses
|
||||
test -f snap-payload/licenses/Apache-2.0-dst-decoder.txt
|
||||
mkdir -p snap
|
||||
cp packaging/snap-slint/snapcraft-arm64.yaml snap/snapcraft.yaml
|
||||
cp packaging/snap-qt/snapcraft-arm64.yaml snap/snapcraft.yaml
|
||||
sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml
|
||||
# Remove the root source-build recipe from the CI working copy so
|
||||
# snapcraft resolves ONLY the dump recipe (see the amd64 job).
|
||||
rm -f snapcraft.yaml .snapcraft.yaml
|
||||
echo "Built snap version: ${VER} (arm64)"
|
||||
cat snap/snapcraft.yaml | head -20
|
||||
head -20 snap/snapcraft.yaml
|
||||
|
||||
- name: Build the snap (no compilation — dump of the prebuilt binary)
|
||||
- name: Build the snap (dump of the prebuilt AppDir)
|
||||
id: build
|
||||
uses: snapcore/action-build@v1
|
||||
|
||||
@@ -283,9 +230,10 @@ jobs:
|
||||
name: qbz-snap-arm64
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||
|
||||
- name: Publish to the Snap Store (skipped when no store credentials)
|
||||
if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' }}
|
||||
if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' && (startsWith(github.ref, 'refs/tags/') || inputs.publish) }}
|
||||
uses: snapcore/action-publish@v1
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
|
||||
@@ -18,11 +18,19 @@ jobs:
|
||||
# Only run on successful tag-triggered builds (not manual dispatches)
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout triggering release commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version from tag
|
||||
id: version
|
||||
# `head_branch` on a `workflow_run` event is attacker-controllable
|
||||
@@ -31,14 +39,29 @@ jobs:
|
||||
# the shape before letting it flow into shell or $GITHUB_OUTPUT.
|
||||
env:
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! printf '%s' "$HEAD_BRANCH" | grep -Eq '^v[0-9][0-9A-Za-z._-]{0,63}$'; then
|
||||
if ! printf '%s' "$HEAD_BRANCH" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$'; then
|
||||
echo "::error::Rejected head_branch for manifest job: $HEAD_BRANCH"
|
||||
exit 1
|
||||
fi
|
||||
TAG="$HEAD_BRANCH"
|
||||
VERSION="${TAG#v}"
|
||||
git fetch origin main --tags
|
||||
if ! git show-ref --verify --quiet "refs/tags/${TAG}"; then
|
||||
echo "::error::Release tag ${TAG} does not exist"
|
||||
exit 1
|
||||
fi
|
||||
TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "::error::workflow_run SHA does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$TAG_SHA" origin/main; then
|
||||
echo "::error::${TAG} is not on main"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -48,7 +71,12 @@ jobs:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
# Wait up to 20 minutes for .sig files from all platforms
|
||||
REQUIRED_SIGS=("aarch64.app.tar.gz.sig" "x64.app.tar.gz.sig" "AppImage.sig")
|
||||
REQUIRED_SIGS=(
|
||||
"aarch64.app.tar.gz.sig"
|
||||
"x64.app.tar.gz.sig"
|
||||
"amd64.AppImage.sig"
|
||||
"aarch64.AppImage.sig"
|
||||
)
|
||||
MAX_ATTEMPTS=40
|
||||
SLEEP_SECONDS=30
|
||||
|
||||
@@ -69,8 +97,8 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then
|
||||
echo "Warning: Not all .sig files found after $MAX_ATTEMPTS attempts. Generating manifest with available assets."
|
||||
break
|
||||
echo "::error::Not all updater signatures appeared after $MAX_ATTEMPTS attempts; refusing to replace latest.json with a partial manifest."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Attempt $attempt/$MAX_ATTEMPTS: waiting for all .sig files..."
|
||||
@@ -158,6 +186,13 @@ jobs:
|
||||
'. + {"linux-aarch64": {"signature": $sig, "url": $url}}')
|
||||
fi
|
||||
|
||||
PLATFORM_COUNT=$(echo "$PLATFORMS" | jq 'length')
|
||||
if [ "$PLATFORM_COUNT" -ne 4 ]; then
|
||||
echo "::error::Expected four updater platforms, generated $PLATFORM_COUNT."
|
||||
echo "$PLATFORMS" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Assemble final latest.json
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
@@ -175,7 +210,4 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
# Delete existing latest.json if present (from a previous run)
|
||||
gh release delete-asset "$TAG" latest.json --repo "${{ github.repository }}" --yes 2>/dev/null || true
|
||||
# Upload the new one
|
||||
gh release upload "$TAG" latest.json --repo "${{ github.repository }}"
|
||||
gh release upload "$TAG" latest.json --repo "${{ github.repository }}" --clobber
|
||||
@@ -106,13 +106,13 @@ jobs:
|
||||
foreach ($f in "qbz.exe", "platforms\qwindows.dll", "qml\QtQuick\Controls\Basic\qmldir", "imageformats\qsvg.dll", "LICENSE.txt") {
|
||||
if (-not (Test-Path "$root\$f")) { throw "installed layout is missing $f" }
|
||||
}
|
||||
foreach ($protocol in "qbz", "qobuzapp") {
|
||||
foreach ($protocol in "qbz") {
|
||||
if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { throw "$protocol protocol key missing" }
|
||||
}
|
||||
if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { throw "Start-menu shortcut missing" }
|
||||
$uninstall = Start-Process msiexec.exe -ArgumentList "/x `"$msi`" /qn /norestart" -Wait -PassThru
|
||||
if ($uninstall.ExitCode -ne 0) { throw "synthetic MSI uninstall failed: $($uninstall.ExitCode)" }
|
||||
foreach ($protocol in "qbz", "qobuzapp") {
|
||||
foreach ($protocol in "qbz") {
|
||||
if (Test-Path "HKCU:\Software\Classes\$protocol") { throw "$protocol protocol key survived uninstall" }
|
||||
}
|
||||
|
||||
@@ -171,9 +171,11 @@ jobs:
|
||||
fi
|
||||
# MSI ProductVersion must be numeric x.y.z; the full string goes to ARPCOMMENTS.
|
||||
MSI_VERSION="$(echo "$VERSION" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "msi_version=$MSI_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "msi_version=$MSI_VERSION"
|
||||
echo "prerelease=$PRERELEASE"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify tag is on main
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
@@ -238,7 +240,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: qt-windows-x86_64
|
||||
shared-key: qt-windows-x86_64-${{ env.QT_VERSION }}
|
||||
cache-on-failure: true
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
|
||||
|
||||
@@ -258,7 +260,7 @@ jobs:
|
||||
# the link and let the PDB carry them.
|
||||
CARGO_PROFILE_RELEASE_STRIP: "none"
|
||||
run: |
|
||||
cargo build --release --manifest-path crates/Cargo.toml \
|
||||
python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml \
|
||||
-p qbz-qt --target "$TARGET"
|
||||
ls -la "crates/target/$TARGET/release/qbz.exe" "crates/target/$TARGET/release/qbz.pdb"
|
||||
|
||||
@@ -286,7 +288,7 @@ jobs:
|
||||
# (the committed .qsb carry HLSL 50 *source*; d3d11 compiles it at runtime).
|
||||
# Exit code is checked (pipefail); the grep is a separate, informational pass.
|
||||
"$QT_ROOT_DIR/bin/windeployqt.exe" --release --qmldir crates/qbz-qt/qml \
|
||||
--no-translations --no-opengl-sw "$DIST/qbz.exe" 2>&1 | tee windeploy.log
|
||||
--no-translations --no-opengl-sw --no-compiler-runtime "$DIST/qbz.exe" 2>&1 | tee windeploy.log
|
||||
grep -iE 'error|warning' windeploy.log || true
|
||||
# Nothing uses Qt SQL, Qt TLS (rustls) or network-information.
|
||||
rm -rf "$DIST/sqldrivers" "$DIST/tls" "$DIST/networkinformation"
|
||||
@@ -319,43 +321,14 @@ jobs:
|
||||
# Hard fail, not a warning — plan A task 14 adds licenses/.
|
||||
if [ ! -d licenses ]; then echo "::error::licenses/ (Qt LGPL notice) is missing; refusing to package Qt without it"; exit 1; fi
|
||||
cp -r licenses "$DIST/licenses"
|
||||
# offscreen platform plugin: smoke only; removed before packaging.
|
||||
cp "$QT_ROOT_DIR/plugins/platforms/qoffscreen.dll" "$DIST/platforms/"
|
||||
du -sh "$DIST"
|
||||
|
||||
# THE gate: the deployed directory boots without the Qt install. Pass =
|
||||
# >= 10 log lines, zero QML/loader complaints, core initialized (login
|
||||
# screen reached; `home published` needs a session CI does not have).
|
||||
# This is the step that catches the qrc-backslash bug (W1) as `Cannot open`.
|
||||
# pwsh, not Git-Bash: MSYS `kill $!` cannot reliably stop a native
|
||||
# Windows process (Sol R15), and a survivor locks the deploy dir.
|
||||
- name: Offscreen smoke (the directory that ships)
|
||||
# Both products harvest this exact app-local runtime. Audit every EXE,
|
||||
# DLL and plugin (normal + delay imports) before making either artifact.
|
||||
- name: Deploy official MSVC CRT app-local and audit imports
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$dist = "dist\qbz"
|
||||
$qtRoot = $env:QT_ROOT_DIR
|
||||
$qtHidden = "$qtRoot.hidden"
|
||||
# Move-Item accepts a full destination path. Rename-Item's NewName
|
||||
# parameter does not, so using it here can leave the Qt install live
|
||||
# and turn this deployed-tree gate into a false positive.
|
||||
Move-Item -LiteralPath $qtRoot -Destination $qtHidden
|
||||
foreach ($v in "QT_PLUGIN_PATH","QML_IMPORT_PATH","QML2_IMPORT_PATH","QT_ROOT_DIR") { Remove-Item "Env:$v" -ErrorAction SilentlyContinue }
|
||||
$env:QT_QPA_PLATFORM = "offscreen"; $env:RUST_LOG = "info"
|
||||
$p = Start-Process -FilePath "$dist\qbz.exe" -RedirectStandardOutput smoke.out -RedirectStandardError smoke.err -PassThru -NoNewWindow
|
||||
Start-Sleep -Seconds 45
|
||||
if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force; Start-Sleep 1 }
|
||||
Move-Item -LiteralPath $qtHidden -Destination $qtRoot
|
||||
Get-Content smoke.out, smoke.err | Set-Content smoke.log
|
||||
$lines = (Get-Content smoke.log | Measure-Object -Line).Lines
|
||||
Write-Host "smoke log: $lines lines"
|
||||
if ($lines -lt 10) { Get-Content smoke.log; Write-Host "::error::the app did not start"; exit 1 }
|
||||
$pat = 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|non-existent property|failed to load component|is not installed|could not be found|0xc0000135|STATUS_DLL_NOT_FOUND|This application failed to start'
|
||||
$errs = Get-Content smoke.log | Where-Object { $_ -notmatch 'propertyCache' } | Select-String -Pattern $pat
|
||||
if ($errs) { $errs | Select-Object -First 20; Write-Host "::error::$($errs.Count) QML/loader complaint(s) in the smoke log"; exit 1 }
|
||||
if (-not (Select-String -Path smoke.log -Pattern 'QbzCore initialized' -Quiet)) { Get-Content smoke.log -Tail 20; Write-Host "::error::never reached QbzCore init"; exit 1 }
|
||||
Remove-Item "$dist\platforms\qoffscreen.dll"
|
||||
Write-Host "smoke OK (0 QML complaints, core initialized)"
|
||||
run: ./scripts/packaging/deploy_windows_crt.ps1 -Dist dist/qbz
|
||||
|
||||
# D4 = unsigned, so this is a no-op until a cert exists (HAVE_CERT is
|
||||
# resolved at workflow level — see the env block at the top). PLACEHOLDER
|
||||
# for an OV cert in a PFX: cloud-issued keys (Azure Trusted Signing,
|
||||
@@ -400,37 +373,43 @@ jobs:
|
||||
-o "dist/QBZ_${VERSION}_x64.msi"
|
||||
ls -la dist
|
||||
|
||||
# The MSI is exercised, not just built: install per-user (no UAC), assert
|
||||
# the layout + registry, launch the INSTALLED exe offscreen, uninstall,
|
||||
# assert nothing is left. `wix msi validate` is deliberately NOT run:
|
||||
# per-user harvested components fail ICE38 by WiX design (issue #8633).
|
||||
- name: Install / launch / uninstall the MSI (runner smoke)
|
||||
# Product smoke uses qwindows from the installed payload with an isolated
|
||||
# profile/PATH. A VS runner still does not certify a clean Windows image.
|
||||
- name: Install / upgrade / launch / uninstall the MSI (runner smoke)
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Resolve-Path "dist\QBZ_$($env:VERSION)_x64.msi").Path
|
||||
$p = Start-Process msiexec -ArgumentList "/i `"$msi`" /qn /l*v install.log" -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; Write-Host "::error::msiexec /i exit $($p.ExitCode)"; exit 1 }
|
||||
$root = "$env:LOCALAPPDATA\Programs\QBZ"
|
||||
foreach ($f in "qbz.exe","platforms\qwindows.dll","qml\QtQuick\Controls\Basic","imageformats\qsvg.dll","LICENSE.txt") {
|
||||
if (-not (Test-Path "$root\$f")) { Write-Host "::error::installed layout is missing $f"; exit 1 }
|
||||
$installed = $false
|
||||
try {
|
||||
foreach ($stage in @('install','upgrade')) {
|
||||
$p = Start-Process msiexec -ArgumentList "/i `"$msi`" /qn /l*v $stage.log" -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content "$stage.log" -Tail 60; throw "msiexec $stage exit $($p.ExitCode)" }
|
||||
$installed = $true
|
||||
}
|
||||
foreach ($protocol in "qbz", "qobuzapp") {
|
||||
if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { Write-Host "::error::$protocol protocol key missing"; exit 1 }
|
||||
foreach ($f in @('qbz.exe','platforms\qwindows.dll','qml\QtQuick\Controls\Basic','imageformats\qsvg.dll','LICENSE.txt')) {
|
||||
if (-not (Test-Path "$root\$f")) { throw "Installed layout is missing $f" }
|
||||
}
|
||||
if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { Write-Host "::error::Start-menu shortcut missing"; exit 1 }
|
||||
$env:QT_QPA_PLATFORM = "offscreen"; $env:RUST_LOG = "info"
|
||||
$app = Start-Process "$root\qbz.exe" -RedirectStandardOutput inst.out -RedirectStandardError inst.err -PassThru -NoNewWindow
|
||||
Start-Sleep 25; if (-not $app.HasExited) { Stop-Process -Id $app.Id -Force; Start-Sleep 1 }
|
||||
if (-not (Select-String -Path inst.out,inst.err -Pattern 'QbzCore initialized' -Quiet)) { Get-Content inst.out,inst.err -Tail 30; Write-Host "::error::installed app never reached QbzCore init"; exit 1 }
|
||||
if (-not (Test-Path 'HKCU:\Software\Classes\qbz')) { throw 'qbz protocol key missing' }
|
||||
if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { throw 'Start-menu shortcut missing' }
|
||||
$sourceHash = (Get-FileHash 'dist/qbz/licenses/msvc-runtime.json').Hash
|
||||
if ((Get-FileHash "$root/licenses/msvc-runtime.json").Hash -ne $sourceHash) { throw 'MSI CRT inventory differs from shared payload' }
|
||||
python scripts/packaging/windows_crt.py $root --report windows-msi-imports.json
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Installed MSI CRT audit failed' }
|
||||
./scripts/packaging/smoke_windows_deploy.ps1 -Directory $root -LogPrefix inst
|
||||
}
|
||||
finally {
|
||||
if ($installed) {
|
||||
$u = Start-Process msiexec -ArgumentList "/x `"$msi`" /qn /l*v uninstall.log" -Wait -PassThru
|
||||
if ($u.ExitCode -ne 0) { Get-Content uninstall.log -Tail 60; Write-Host "::error::msiexec /x exit $($u.ExitCode)"; exit 1 }
|
||||
if (Test-Path "$root\qbz.exe") { Write-Host "::error::uninstall left $root\qbz.exe"; exit 1 }
|
||||
foreach ($protocol in "qbz", "qobuzapp") {
|
||||
if (Test-Path "HKCU:\Software\Classes\$protocol") { Write-Host "::error::uninstall left the $protocol protocol key"; exit 1 }
|
||||
if ($u.ExitCode -ne 0) { Get-Content uninstall.log -Tail 60; throw "msiexec uninstall exit $($u.ExitCode)" }
|
||||
}
|
||||
Write-Host "MSI install/launch/uninstall OK"
|
||||
}
|
||||
if (Test-Path "$root\qbz.exe") { throw 'Uninstall left qbz.exe' }
|
||||
if (Test-Path 'HKCU:\Software\Classes\qbz') { throw 'Uninstall left the qbz protocol key' }
|
||||
Write-Host 'MSI install/upgrade/GUI launch/uninstall OK'
|
||||
|
||||
- name: Sign MSI
|
||||
if: ${{ env.HAVE_CERT == 'true' }}
|
||||
@@ -450,6 +429,31 @@ jobs:
|
||||
run: |
|
||||
Compress-Archive -Path dist\qbz\* -DestinationPath "dist\qbz_$($env:VERSION)_windows_x64.zip"
|
||||
Copy-Item "crates\target\$env:TARGET\release\qbz.pdb" "dist\qbz_$($env:VERSION)_windows_x64.pdb"
|
||||
$unpacked = Join-Path $env:RUNNER_TEMP ('qbz-portable-' + [guid]::NewGuid())
|
||||
try {
|
||||
Expand-Archive -LiteralPath "dist\qbz_$($env:VERSION)_windows_x64.zip" -DestinationPath $unpacked
|
||||
if ((Get-FileHash "$unpacked/licenses/msvc-runtime.json").Hash -ne (Get-FileHash 'dist/qbz/licenses/msvc-runtime.json').Hash) { throw 'ZIP CRT inventory differs from shared payload' }
|
||||
python scripts/packaging/windows_crt.py $unpacked --report windows-zip-imports.json
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Portable ZIP CRT audit failed' }
|
||||
./scripts/packaging/smoke_windows_deploy.ps1 -Directory $unpacked -LogPrefix portable
|
||||
}
|
||||
finally { if (Test-Path $unpacked) { Remove-Item -LiteralPath $unpacked -Recurse -Force } }
|
||||
|
||||
- name: Upload deployment evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qbz-windows-deployment-checks
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
windows-*-imports.json
|
||||
inst.*
|
||||
portable.*
|
||||
install.log
|
||||
upgrade.log
|
||||
uninstall.log
|
||||
dist/qbz/licenses/msvc-runtime.json
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
# Workspace unit/doc tests for the shipping crates/ tree (Slint-era).
|
||||
# CI for the crates/ workspace on the integration branch.
|
||||
#
|
||||
# Companion to the release/build workflows, not a substitute:
|
||||
# - release-linux / build-slint / release-snap compile -p qbz (full UI, ~30 GB peak)
|
||||
# - this job runs `cargo test` on every non-UI crate only (~5 min cold, ~1–2 min warm)
|
||||
# Three jobs, three economies:
|
||||
# test — validate all eight gettext catalogs, then `cargo test` on
|
||||
# every crate that needs no Qt (scripts/cargo-test.sh:
|
||||
# excludes the frozen Slint crates and qbz-qt), then the named
|
||||
# listen-log suite check (qbz-app::listen_log must exist and be
|
||||
# green — the per-user listen store's schema, accumulator, end
|
||||
# reasons and pause/clear rules), and the account-migration +
|
||||
# portable-blacklist suite check (Settings > Import / Export:
|
||||
# snapshot/plan/apply/ledger/local copy, additive blacklist
|
||||
# merge), plus QConnect controller handoff/read-vs-write,
|
||||
# proto3-zero cursor, manual skip/loop/shuffle/autoplay and
|
||||
# consecutive-log repetition regression checks, plus SACD
|
||||
# 2048/2064-sector TOC/frame/seek equivalence and scanner import/
|
||||
# failed-read preservation. ~5 min cold.
|
||||
# qt-gate — the Qt frontend: the five static QML audits, `cargo test -p
|
||||
# qbz-qt`, the Slint-free dep-graph gate, and an OFFSCREEN BOOT of
|
||||
# debug AND release binaries (including process liveness).
|
||||
# Release also boots on Xvfb with a deliberately silent D-Bus.
|
||||
# Node executes the actual Local Library filter/tab expressions.
|
||||
# This is the thing `cargo check` cannot give:
|
||||
# a missing QML component or a call to a bridge member that does
|
||||
# not exist compiles clean and throws when the user opens the
|
||||
# view. Qt comes from aqt — ubuntu-24.04's own Qt is 6.4.2 and has
|
||||
# no QtQuick.Effects module.
|
||||
#
|
||||
# Excludes qbz, qbz-ui, qbz-dac-wizard, qbz-slint-common so the UI compile unit
|
||||
# never enters the graph. Cache key is intentionally separate from
|
||||
# `slint-linux` (stable + test profile vs nightly + release).
|
||||
#
|
||||
# Repo policy note: release builds stay tag/dispatch-only. This is the one
|
||||
# workflow that runs on pre-release integration so regressions land before a
|
||||
# release cut. Path-filtered so packaging/docs PRs skip it.
|
||||
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Release packaging
|
||||
# stays tag/dispatch-only; this workflow also tests optimized startup on
|
||||
# pre-release
|
||||
# integration so regressions land before a release cut.
|
||||
|
||||
name: test-crates
|
||||
|
||||
@@ -22,6 +40,14 @@ on:
|
||||
paths:
|
||||
- "crates/**"
|
||||
- "scripts/cargo-test.sh"
|
||||
- "scripts/qt-*.py"
|
||||
- "scripts/qt-*.sh"
|
||||
- "scripts/test_qt_build_gates.py"
|
||||
- "scripts/packaging/windows_crt.py"
|
||||
- "scripts/packaging/test_windows_crt.py"
|
||||
- "scripts/packaging/smoke_release.py"
|
||||
- "scripts/packaging/test_smoke_release.py"
|
||||
- "scripts/qml-audits/**"
|
||||
- ".github/workflows/test-crates.yml"
|
||||
push:
|
||||
# Integration branch only — never main (a push to main is a release merge).
|
||||
@@ -29,6 +55,14 @@ on:
|
||||
paths:
|
||||
- "crates/**"
|
||||
- "scripts/cargo-test.sh"
|
||||
- "scripts/qt-*.py"
|
||||
- "scripts/qt-*.sh"
|
||||
- "scripts/test_qt_build_gates.py"
|
||||
- "scripts/packaging/windows_crt.py"
|
||||
- "scripts/packaging/test_windows_crt.py"
|
||||
- "scripts/packaging/smoke_release.py"
|
||||
- "scripts/packaging/test_smoke_release.py"
|
||||
- "scripts/qml-audits/**"
|
||||
- ".github/workflows/test-crates.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -39,12 +73,17 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Same pin as build-qt-linux.yml (the Linux floor). Bump both together.
|
||||
QT_VERSION: ${{ vars.QT_VERSION_LINUX || '6.8.3' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: cargo test (workspace, no UI)
|
||||
runs-on: ubuntu-24.04
|
||||
# Cold first run ~5 min (compile + test); warm ~1–2 min. Headroom for
|
||||
# registry lag / cache restore, not for a Slint UI compile.
|
||||
# The rule: --no-fail-fast, 20 minutes. All cores: measured cold on an
|
||||
# equivalent 4-vCPU VM, 392 s (jobs=4) vs 589 s (jobs=2), 2.7 GB peak —
|
||||
# the old jobs=2 was a Slint-era memory tier with nothing left to protect.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -52,54 +91,135 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
# Minimal subset of release-linux.yml deps: non-UI crates still link
|
||||
# ALSA / JACK / D-Bus (qbz-audio) and OpenSSL (reqwest). mold matches
|
||||
# the release linker for faster test-binary link.
|
||||
# Non-UI crates still link ALSA / JACK / D-Bus (qbz-audio) and
|
||||
# OpenSSL (reqwest).
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config mold \
|
||||
build-essential pkg-config gettext \
|
||||
libasound2-dev libjack-jackd2-dev \
|
||||
libdbus-1-dev libssl-dev
|
||||
|
||||
# Floating stable is fine here: rust-cache keys on rustc -V, so a stable
|
||||
# bump just rebuilds the test cache once. Nightly is reserved for the
|
||||
# release/UI builds that need -Z threads.
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
# Manifest is crates/Cargo.toml → artifacts in crates/target (same
|
||||
# workspaces mapping as release-linux / build-slint).
|
||||
workspaces: crates -> target
|
||||
shared-key: test-crates-linux
|
||||
cache-on-failure: true
|
||||
# PRs restore only. pre-release pushes write so the integration
|
||||
# branch keeps the cache warm (mirrors release-linux save-if policy).
|
||||
save-if: ${{ github.ref == 'refs/heads/pre-release' }}
|
||||
|
||||
- name: cargo test
|
||||
- name: cargo test (scripts/cargo-test.sh — the local mirror)
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
# Faster link; no -Z flags (stable toolchain).
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: |
|
||||
echo "::group::toolchain"
|
||||
rustc -V
|
||||
cargo -V
|
||||
echo "::endgroup::"
|
||||
rustc -V; cargo -V
|
||||
START=$(date +%s)
|
||||
./scripts/cargo-test.sh
|
||||
END=$(date +%s)
|
||||
echo "cargo test wall time: $((END - START))s"
|
||||
echo "cargo test wall time: $(( $(date +%s) - START ))s"
|
||||
|
||||
- name: qbzd slint-free dep-graph gate (01-architecture.md §1.2)
|
||||
# Every shader must bake. Separate job so qsb is never on PATH while cargo
|
||||
# runs (see qt-gate). The BAKE toolchain is pinned apart from the runtime
|
||||
# floor: the committed .qsb need qsb >= 6.9 (plasma/ambient's uint hash
|
||||
# constants vs the GLSL 1.20 variant, which desktop GL <= 3.1 selects at
|
||||
# runtime, so it must stay in the pack) — see shader_bake_gate.sh.
|
||||
shader-gate:
|
||||
name: shader bake gate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
QT_SHADERTOOLS_VERSION: ${{ vars.QT_VERSION_SHADERTOOLS || '6.9.3' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install Qt ${{ env.QT_SHADERTOOLS_VERSION }} + shader tools
|
||||
uses: jurplel/install-qt-action@v4
|
||||
with:
|
||||
version: ${{ env.QT_SHADERTOOLS_VERSION }}
|
||||
host: linux
|
||||
target: desktop
|
||||
arch: linux_gcc_64
|
||||
modules: qtshadertools
|
||||
cache: true
|
||||
- name: Bake every shader to a temp dir
|
||||
run: scripts/qml-audits/shader_bake_gate.sh "$QT_ROOT_DIR/bin/qsb"
|
||||
|
||||
qt-gate:
|
||||
name: qt gate (audits, tests, offscreen boot)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake clang libclang-dev lld nasm dbus \
|
||||
libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libxkbcommon-dev libwayland-dev \
|
||||
xvfb nodejs libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
|
||||
libxcb-render-util0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0
|
||||
# cxx-qt prefers LLD when present; without it, Ubuntu falls back to
|
||||
# the deprecated gold linker. Do not add workspace-wide RUSTFLAGS.
|
||||
ld.lld --version
|
||||
|
||||
# No qtshadertools here ON PURPOSE: with qsb on PATH build.rs re-bakes
|
||||
# shaders by mtime, which makes a fresh checkout nondeterministic. The
|
||||
# build explicitly validates/uses committed .qsb; shader health is the
|
||||
# shader-gate job, independent of tools accidentally present on PATH.
|
||||
- name: Install Qt ${{ env.QT_VERSION }}
|
||||
uses: jurplel/install-qt-action@v4
|
||||
with:
|
||||
version: ${{ env.QT_VERSION }}
|
||||
host: linux
|
||||
target: desktop
|
||||
arch: linux_gcc_64
|
||||
cache: true
|
||||
|
||||
- name: qmllint (QML type check)
|
||||
run: scripts/qml-audits/qmllint_gate.sh "$QT_ROOT_DIR"
|
||||
|
||||
# The audits need Qt's own plugins.qmltypes to resolve QtQuick types —
|
||||
# hence after the Qt install, pointed at it. ~1 s, and the class of
|
||||
# mistake nothing else in CI can see (a missing component, a bridge
|
||||
# member that does not exist).
|
||||
- name: QML audits
|
||||
env:
|
||||
QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbzd -e normal \
|
||||
for a in qml_resolution_audit qml_singleton_xref qml_eager_tab_audit qml_module_registration_audit qml_icon_bake_audit qml_on_prefixed_property_audit; do
|
||||
echo "::group::$a"
|
||||
python3 "scripts/qml-audits/$a.py" "$PWD/crates/qbz-qt"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates -> target
|
||||
shared-key: qt-gate-${{ env.QT_VERSION }}
|
||||
cache-on-failure: true
|
||||
save-if: ${{ github.ref == 'refs/heads/pre-release' }}
|
||||
|
||||
# The exact QT=1 half of scripts/cargo-test.sh, minus its `test` job
|
||||
# (already run above) and its shader gate (the shader-gate job, so qsb
|
||||
# is never on PATH while cargo runs here): dep-graph gate, cargo test
|
||||
# -p qbz-qt, Local Library/Kiosk artwork/navigation regressions, debug/release
|
||||
# offscreen boot. One shared runtime script.
|
||||
- name: qt gates (scripts/cargo-test.sh QT=1, without the workspace test)
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml
|
||||
QBZ_PREBUILT_SHADERS: '1'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \
|
||||
| grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "::error::qbzd graph resolves Slint crates:"; echo "$hits"; exit 1
|
||||
fi
|
||||
[ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; }
|
||||
bash scripts/qt-runtime-gate.sh
|
||||
@@ -50,6 +50,7 @@ static/*upscayl*
|
||||
/.tmp/
|
||||
/tmp/
|
||||
/tools/
|
||||
/scratchpad/
|
||||
|
||||
# Documentation (kept external)
|
||||
qbz-nix-docs/
|
||||
|
||||
+57
-14
@@ -5,16 +5,20 @@ This project is actively evolving. Contributions are welcome, but we have a few
|
||||
## Where the code lives
|
||||
|
||||
The live app is the Rust workspace under `crates/` — a single native process
|
||||
with a Slint UI. UI code is in `crates/qbz-ui` (`.slint` + generated bindings)
|
||||
and `crates/qbz` (the binary). The old Svelte `src/` and Tauri `src-tauri/`
|
||||
trees were **deleted in 2.0.2**; they survive only at the git tag
|
||||
`legacy-tauri-svelte` for reference. PRs against those paths cannot be merged —
|
||||
port the change to `crates/` instead.
|
||||
with a Qt/QML interface. UI code and the desktop binary live in
|
||||
`crates/qbz-qt`; shared behavior belongs in the relevant core crate.
|
||||
|
||||
The Slint crates (`crates/qbz-ui`, `crates/qbz`, `crates/qbz-slint-common` and
|
||||
`crates/qbz-dac-wizard`) are frozen historical references. The old Svelte
|
||||
`src/` and Tauri `src-tauri/` trees were deleted in 2.0.2 and survive only at
|
||||
the git tag `legacy-tauri-svelte`. PRs against these retired paths cannot be
|
||||
merged — port the change to the live Qt/Rust tree instead.
|
||||
|
||||
## Quick rules
|
||||
|
||||
- Write clear, concise English (no emojis in code, comments, or commit messages).
|
||||
- Keep PRs focused and small when possible.
|
||||
- Disclose every LLM model used and its role in the PR body; see below.
|
||||
- Do not change app branding or legal disclaimers without discussing it first.
|
||||
- Do not modify protected audio-backend behavior unless explicitly requested by the maintainer.
|
||||
|
||||
@@ -72,9 +76,11 @@ PRs targeting `main` will be closed and asked to retarget to `pre-release`.
|
||||
- `git checkout pre-release`
|
||||
- `git merge --no-ff <type>/external/<topic>`
|
||||
5. **Run checks**
|
||||
- Build/validate a touched core crate: `cargo check -p <crate>` (run from
|
||||
`crates/`). The full UI (`qbz`/`qbz-ui`) is a ~20–30 GB compile — see the
|
||||
README "Building from Source" section before attempting it.
|
||||
- Test or check each touched core crate from `crates/`.
|
||||
- For Qt/QML changes, run the relevant QML audits and use
|
||||
`./scripts/qt-run.sh` from the repository root when your platform can
|
||||
build the desktop application.
|
||||
- Never build the frozen Slint crates as validation for a live change.
|
||||
6. **Push pre-release**
|
||||
- `git push origin pre-release`
|
||||
7. **Close the PR with a comment** explaining it was merged to `pre-release`.
|
||||
@@ -104,9 +110,46 @@ Prefer:
|
||||
## What to include in PRs
|
||||
|
||||
- A short description of the problem and solution.
|
||||
- For a bug fix: the observed behavior, the root cause or best-supported
|
||||
diagnosis, and why the proposed change addresses it.
|
||||
- For a feature: the user need, the existing flow or components you inspected,
|
||||
and the constraints the design preserves.
|
||||
- Screenshots for UI changes when possible.
|
||||
- The checks or manual tests you ran.
|
||||
- The LLM disclosure described below, or `None` if no LLM was used.
|
||||
- Notes about any breaking changes or migrations.
|
||||
|
||||
## AI / LLM-assisted contributions
|
||||
|
||||
LLM-assisted contributions are welcome. If you used one or more models, the
|
||||
PR body must identify each model as precisely as the tool exposes it and state
|
||||
what role it played. A product, client or provider name alone is not enough
|
||||
when the tool shows a more specific model name or version. There is
|
||||
deliberately no predefined model list to keep current: report the identifier
|
||||
shown by the tool at the time of the contribution.
|
||||
|
||||
List different models separately when they handled different stages, such as
|
||||
repository/context mapping, problem definition, root-cause analysis,
|
||||
plan/contract/dependency graph, implementation, tests or code review. Include
|
||||
only the stages that apply and use one line per model:
|
||||
|
||||
```text
|
||||
- <provider and model/version shown by the tool>: repository mapping, diagnosis
|
||||
- <provider and model/version shown by the tool>: plan/contract, implementation
|
||||
- <provider and model/version shown by the tool>: tests, code review
|
||||
```
|
||||
|
||||
Saying only that a change was "prompted" or "AI-generated" does not describe
|
||||
the engineering process. The PR should make it possible to see how the problem,
|
||||
existing code, constraints and proposed solution were understood before the
|
||||
change was submitted.
|
||||
|
||||
If the exact model is not shown by the tool, say that instead of guessing.
|
||||
Prompt transcripts and private conversations are not required. This disclosure
|
||||
helps the maintainer choose an appropriate review strategy; it is not grounds
|
||||
for rejecting a contribution. The contributor remains responsible for reading
|
||||
the resulting diff and reporting how it was verified.
|
||||
|
||||
## What not to include
|
||||
|
||||
- Large refactors mixed with feature work.
|
||||
@@ -119,16 +162,16 @@ Prefer:
|
||||
QBZ ships 8 locales (`en es de fr pt ru ja nl`) as gettext `.po` files, bundled
|
||||
via the `qbz-i18n` crate. Rules:
|
||||
|
||||
- **No hardcoded UI strings in `.slint`** — every string goes through
|
||||
`@tr("...")`.
|
||||
- **No hardcoded UI strings in QML** — use
|
||||
`QbzSession.tr("…", QbzSession.trRev)` so live language changes update the
|
||||
binding. Rust uses `qbz_i18n::t()` or `qbz_i18n::tf()`.
|
||||
- Adding or changing a string means updating **all** locale `.po` files, not
|
||||
just English.
|
||||
- `@tr` property defaults are not reactive — re-seed from Rust on language
|
||||
change (see `crates/qbz-i18n` and `select_bundled_translation()`, called
|
||||
after `AppWindow::new()`).
|
||||
- The English text is the gettext `msgid`; do not introduce dotted keys.
|
||||
|
||||
### Checklist for PRs with UI Text
|
||||
|
||||
- [ ] No hardcoded strings in `.slint` — all text via `@tr`
|
||||
- [ ] No hardcoded strings in QML — all text uses `QbzSession.tr()` with
|
||||
`trRev`
|
||||
- [ ] Every new/changed string updated across all 8 `.po` locales
|
||||
- [ ] Reused an existing string where one already fit
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,481 @@
|
||||
// QBZ — Steam Deck (neptune) controller layout, seeded by qbz-add-to-steam.
|
||||
//
|
||||
// Based on the stock Steam "Gamepad" template (controller_neptune_gamepad_joystick.vdf,
|
||||
// Steam client 2026-06) — PURE GAMEPAD: only virtual X360 pad output, NO keyboard
|
||||
// or mouse emulation, so QBZ's own gilrs gamepad thread is the single input source
|
||||
// (see qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md, PR 3/PR 5).
|
||||
//
|
||||
// The one deviation from the template: Y is bound to Steam's "Show Keyboard"
|
||||
// system action (controller_action SHOW_KEYBOARD — token verified in
|
||||
// steamclient.so) as a manual OSK fallback next to the steam://open/keyboard
|
||||
// deeplink. Y therefore does NOT emit an XInput button.
|
||||
//
|
||||
// TODO(verify-on-hardware): this file is authored from the known exported
|
||||
// config format, not yet exported/validated by the Deck UI. Do the one-time
|
||||
// validation from the plan: load it on a real Deck in Game Mode, confirm the
|
||||
// layout applies on first launch (configset_<serial>.vdf path) and that Y
|
||||
// opens the OSK, then re-export and replace this file if the format drifted.
|
||||
"controller_mappings"
|
||||
{
|
||||
"version" "3"
|
||||
"title" "QBZ Kiosk (gamepad + Y=keyboard)"
|
||||
"description" "Pure gamepad layout for the QBZ kiosk: D-pad/stick navigate, A activates, B goes back, Y shows the Steam keyboard. No keyboard/mouse emulation."
|
||||
"controller_type" "controller_neptune"
|
||||
"major_revision" "0"
|
||||
"minor_revision" "0"
|
||||
"group"
|
||||
{
|
||||
"id" "0"
|
||||
"mode" "four_buttons"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"button_a"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button A, Activate, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_b"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button B, Back, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_x"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button X, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_y"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "controller_action SHOW_KEYBOARD, Show Keyboard, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "1"
|
||||
"mode" "dpad"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_up, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_south"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_down, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_east"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_right, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_west"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_left, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "2"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "3"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_LEFT, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"deadzone_inner_radius" "7199"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "4"
|
||||
"mode" "trigger"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"output_trigger" "1"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "5"
|
||||
"mode" "trigger"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"output_trigger" "2"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "7"
|
||||
"mode" "switches"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"button_escape"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button start, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_menu"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button select, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"left_bumper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button shoulder_left, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"right_bumper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button shoulder_right, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_back_left"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_back_right"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_back_left_upper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_back_right_upper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "8"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "9"
|
||||
"mode" "dpad"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_UP, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_south"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_DOWN, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_east"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_RIGHT, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_west"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_LEFT, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"requires_click" "0"
|
||||
}
|
||||
}
|
||||
"preset"
|
||||
{
|
||||
"id" "0"
|
||||
"name" "Default"
|
||||
"group_source_bindings"
|
||||
{
|
||||
"7" "switch active"
|
||||
"0" "button_diamond active"
|
||||
"1" "left_trackpad active"
|
||||
"2" "right_trackpad active"
|
||||
"3" "joystick active"
|
||||
"4" "left_trigger active"
|
||||
"5" "right_trigger active"
|
||||
"8" "right_joystick active"
|
||||
"9" "dpad active"
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"left_trackpad_mode" "0"
|
||||
"right_trackpad_mode" "0"
|
||||
}
|
||||
}
|
||||
Generated
+1498
-4457
File diff suppressed because it is too large.
Load diff
+44
-57
@@ -13,9 +13,17 @@ members = [
|
||||
"qbz-integrations",
|
||||
"qbz-cache",
|
||||
"qbz-library",
|
||||
# Frontend-agnostic, derived Local Library read projection. It owns no
|
||||
# source APIs and no Qt types; source caches project into this boundary.
|
||||
"qbz-local-catalog",
|
||||
"qbz-cast",
|
||||
"qbz-cmaf",
|
||||
"qbz-dsd",
|
||||
# Optical media (CD-DA now; SACD/ISO next). Frontend-agnostic, and NOT a
|
||||
# dependency of qbz-dsd's reverse: qbz-dsd will depend on THIS, never the
|
||||
# other way, or the two close a cycle.
|
||||
"qbz-disc",
|
||||
"qbz-rip",
|
||||
"qbz-secrets",
|
||||
"qbz-offline-cache",
|
||||
"qbz-plex",
|
||||
@@ -23,15 +31,15 @@ members = [
|
||||
"qconnect-protocol",
|
||||
"qconnect-transport-ws",
|
||||
"qconnect-app",
|
||||
"qbz-ui",
|
||||
"qbz-dac-wizard",
|
||||
"qconnect-lan",
|
||||
# The HiFi Wizard's frontend-agnostic core (ADR-006), consumed by Qt.
|
||||
"qbz-dac-wizard-core",
|
||||
"qbz-text-utils",
|
||||
"qbz-slint-common",
|
||||
"qbz",
|
||||
"qbz-credentials",
|
||||
"qbz-media-controls",
|
||||
"qbz-mixtape",
|
||||
"qbz-playlist-import",
|
||||
"qbz-account-migration",
|
||||
"qbz-lyrics",
|
||||
"qbz-i18n",
|
||||
"qbz-music-link",
|
||||
@@ -39,10 +47,25 @@ members = [
|
||||
"qbz-external-reco",
|
||||
"qbz-log",
|
||||
"qbzd",
|
||||
"qbz-qt",
|
||||
# The source-agnostic seam (one trait + one registry over Qobuz / local /
|
||||
# Plex). It deliberately does NOT depend on qbz-audio / qbz-player.
|
||||
"qbz-source",
|
||||
# Media-server protocol crates: siblings of `qbz-plex`, same shape. HTTP +
|
||||
# DTOs only — no cache schema, no settings store, no Qt (ADR-006).
|
||||
"qbz-jellyfin",
|
||||
"qbz-subsonic",
|
||||
# ONE cache for every remote server (see its header for why the Plex one
|
||||
# is not migrated in the same change).
|
||||
"qbz-media-cache",
|
||||
]
|
||||
|
||||
# The desktop application is Qt/QML. Plain `cargo build`/`cargo check` produces
|
||||
# that binary plus its frontend-agnostic dependency closure.
|
||||
default-members = ["qbz-qt"]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.2"
|
||||
version = "2.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/vicrodh/qbz"
|
||||
@@ -59,7 +82,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "stream"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "form", "rustls-no-provider", "cookies", "stream"] }
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
@@ -71,7 +94,7 @@ anyhow = "1"
|
||||
|
||||
# Crypto (Qobuz auth)
|
||||
md-5 = "0.10"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
|
||||
# Time
|
||||
chrono = "0.4"
|
||||
@@ -81,6 +104,7 @@ regex = "1"
|
||||
|
||||
# Audio
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
lofty = "0.25.1"
|
||||
|
||||
# Random number generation (shuffle / DJ-mix) — mirrors src-tauri pin
|
||||
rand = "0.10"
|
||||
@@ -91,59 +115,22 @@ strsim = "0.11"
|
||||
# Note: Linux-specific dependencies (alsa) go in individual crate Cargo.toml files
|
||||
# because workspace manifests cannot have [target] sections
|
||||
|
||||
# Local patch of rust_cast 0.21.0 (see crates/vendor/rust_cast/QBZ_PATCH.md):
|
||||
# its NoCertificateVerification verifier delegates TLS signature checks to
|
||||
# rustls helpers that parse the server certificate with webpki, which rejects
|
||||
# the X.509 v1 self-signed certificates served by many Cast devices (AV
|
||||
# receivers with Chromecast built-in) during the handshake. The vendored copy
|
||||
# asserts signature validity instead, matching the verifier's no-validation
|
||||
# contract. Drop this patch once a rust_cast release carries the fix upstream.
|
||||
[patch.crates-io]
|
||||
rust_cast = { path = "vendor/rust_cast" }
|
||||
|
||||
[profile.release]
|
||||
# Strip symbol tables from release binaries. Measured on 2026-07-02:
|
||||
# 400 MB -> 218 MB (-45%). There are no .debug_* sections in a default
|
||||
# release build — the weight is .strtab (151 MB) + .symtab (22 MB), inflated
|
||||
# by Slint's generated generic symbol names. Trade-off: panic backtraces show
|
||||
# addresses instead of names; the release workflow archives the UNSTRIPPED
|
||||
# binary as an Actions artifact per tag for offline symbolication.
|
||||
# 400 MB -> 218 MB (-45%). Trade-off: panic backtraces show addresses instead
|
||||
# of names; release automation retains an unstripped artifact for offline
|
||||
# symbolication.
|
||||
# Codegen knobs (opt-level / codegen-units / jobs) intentionally stay in the
|
||||
# workflows' env (CARGO_PROFILE_RELEASE_*) — they are CI-memory tuning, not
|
||||
# a property of the package.
|
||||
strip = "symbols"
|
||||
|
||||
[patch.crates-io]
|
||||
# Slint 1.16's femtovg-wgpu renderer leaves the wgpu surface alpha mode at
|
||||
# `Auto`, which resolves as an opaque compositor surface on KWin/Wayland and
|
||||
# makes borderless transparent windows show a black rectangular backing. The
|
||||
# patch keeps Slint pinned but chooses a supported non-opaque composite alpha
|
||||
# mode when the surface advertises one, so the miniplayer card can have real
|
||||
# transparent rounded corners. The non-opaque mode is PER-WINDOW opt-in
|
||||
# (`wgpu::set_surface_prefers_transparent`, flipped from qbz's winit
|
||||
# window-attributes hook): only the miniplayer pays the compositor-blend cost,
|
||||
# the main window keeps an Opaque swapchain.
|
||||
i-slint-renderer-femtovg = { path = "vendor/i-slint-renderer-femtovg-1.16.1" }
|
||||
# On modern macOS, fontique's CoreText per-script fallback can fail to resolve
|
||||
# a Han-script family (the suggested PingFang is not always in the scanned
|
||||
# family map), so Japanese kanji rendered blank while kana worked. The patch
|
||||
# pre-registers the known system CJK families (Hiragino/PingFang/Apple SD
|
||||
# Gothic Neo/Arial Unicode MS, Japanese-biased default) in the fallback map at
|
||||
# collection creation, which takes priority over the CoreText query. macOS-only
|
||||
# (#[cfg]); Linux keeps its working fontconfig fallback.
|
||||
i-slint-common = { path = "vendor/i-slint-common-1.16.1" }
|
||||
# With `overflow: elide`, sharedparley culled EVERY line whose bottom edge
|
||||
# exceeded max_height — including the FIRST one, eliding the text to nothing.
|
||||
# CJK fallback fonts (Hiragino on macOS) report line metrics taller than
|
||||
# snug fixed-height boxes (TrackRow's 20px title line at 14px), so every
|
||||
# CJK track title rendered blank on macOS while Latin fit (#543; verified
|
||||
# with a headless repro matrix on the Mac Mini). The patch always draws the
|
||||
# first line — it still gets the ellipsis treatment as the last visible one.
|
||||
i-slint-core = { path = "vendor/i-slint-core-1.16.1" }
|
||||
# Slint compiles femtovg with the swash raster path. Swash fails to scale
|
||||
# some variable fonts at a non-default variation instance (the CFF2-based
|
||||
# CJK system fonts on modern macOS), and femtovg cached those glyphs as
|
||||
# permanently missing — so CJK text at any non-regular weight rendered
|
||||
# blank (#543; regular weight = empty coords = renders fine). The patch
|
||||
# retries the glyph at the font's DEFAULT instance before giving up: worst
|
||||
# case the glyph draws at regular weight instead of not at all. No effect
|
||||
# on fonts/instances that already render.
|
||||
femtovg = { path = "vendor/femtovg-0.23.2" }
|
||||
# macOS Skia renderer: skia bug 310510989 makes new_from_data fail for TTC
|
||||
# faces at index > 0, and for some system collections (AppleSDGothicNeo.ttc,
|
||||
# where EVERY weight sits at index > 0) the upstream read/write-fonts rebuild
|
||||
# workaround ALSO fails — every Korean glyph run at non-regular weights drew
|
||||
# nothing. The patch adds a second-chance workaround: resolve the same face
|
||||
# through CoreText by family name + OS/2 weight, guarded by cmap glyph-id
|
||||
# spot checks so a wrong match is rejected instead of drawing wrong glyphs.
|
||||
i-slint-renderer-skia = { path = "vendor/i-slint-renderer-skia-1.16.1" }
|
||||
+5
-3
@@ -1,6 +1,8 @@
|
||||
# cross (https://github.com/cross-rs/cross) config for cross-compiling the
|
||||
# aarch64 `qbz` binary from an x86-64 Linux host. Used by
|
||||
# scripts/build-aarch64-linux.sh (CROSS mode).
|
||||
# aarch64 `qbzd` daemon from an x86-64 Linux host. Used by
|
||||
# scripts/build-aarch64-qbzd.sh (CROSS mode). The Qt desktop binary is NOT
|
||||
# cross-built (cxx-qt needs the target Qt's qmake at build time; see
|
||||
# scripts/build-aarch64-linux.sh) — build it natively on an ARM box.
|
||||
#
|
||||
# `image` pins a newer Ubuntu (cross 0.2.5's default aarch64 image is Ubuntu
|
||||
# 16.04 "xenial" — EOL, archived repos, OpenSSL 1.0.2 — which breaks modern
|
||||
@@ -20,7 +22,7 @@ image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main"
|
||||
pre-build = [
|
||||
"dpkg --add-architecture arm64",
|
||||
"apt-get update",
|
||||
"apt-get install -y --no-install-recommends pkg-config cmake clang libclang-dev nasm libasound2-dev:arm64 libjack-jackd2-dev:arm64 libfontconfig1-dev:arm64 libfreetype6-dev:arm64 libxkbcommon-dev:arm64 libwayland-dev:arm64 libxcb1-dev:arm64 libgl1-mesa-dev:arm64 libegl1-mesa-dev:arm64 libdbus-1-dev:arm64 libssl-dev:arm64",
|
||||
"apt-get install -y --no-install-recommends pkg-config cmake clang libclang-dev nasm libasound2-dev:arm64 libjack-jackd2-dev:arm64 libdbus-1-dev:arm64 libssl-dev:arm64",
|
||||
]
|
||||
|
||||
[target.aarch64-unknown-linux-gnu.env]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "qbz-account-migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Qobuz account migration for QBZ — snapshot one account's favorites and playlists, plan and apply them additively into another (headless, frontend-agnostic)"
|
||||
authors = ["blitzkriegfc"]
|
||||
|
||||
[dependencies]
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,78 @@
|
||||
//! The slice of the Qobuz client the migration needs, as a trait so the
|
||||
//! planner and the applier are tested against a fake and so another host
|
||||
//! (qbzd) can supply its own client.
|
||||
|
||||
use qbz_models::{Playlist, PlaylistWithTrackIds};
|
||||
use qbz_qobuz::QobuzClient;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Reads and writes on the ACTIVE session's account. Writes are additive
|
||||
/// by construction: there is no delete, no unsubscribe, no update here.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait MigrationApi: Send + Sync {
|
||||
/// `favorite/getUserFavorites?type=<plural>&limit&offset` — the raw
|
||||
/// envelope `{ <plural>: { items, total } }`.
|
||||
async fn favorites_page(&self, plural: &str, limit: u32, offset: u32) -> Result<Value, String>;
|
||||
/// Every playlist the account owns or follows.
|
||||
async fn user_playlists(&self) -> Result<Vec<Playlist>, String>;
|
||||
/// A playlist's track ids in order (`extra=track_ids`).
|
||||
async fn playlist_track_ids(&self, playlist_id: u64) -> Result<PlaylistWithTrackIds, String>;
|
||||
/// `favorite/create` for one id; `singular` is `album|track|artist|label|award`.
|
||||
async fn add_favorite(&self, singular: &str, id: &str) -> Result<(), String>;
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
is_public: bool,
|
||||
) -> Result<Playlist, String>;
|
||||
/// Appends `track_ids` in order (the caller chunks).
|
||||
async fn add_tracks(&self, playlist_id: u64, track_ids: &[u64]) -> Result<(), String>;
|
||||
async fn subscribe(&self, playlist_id: u64) -> Result<(), String>;
|
||||
}
|
||||
|
||||
impl MigrationApi for QobuzClient {
|
||||
async fn favorites_page(&self, plural: &str, limit: u32, offset: u32) -> Result<Value, String> {
|
||||
self.get_favorites(plural, limit, offset)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn user_playlists(&self) -> Result<Vec<Playlist>, String> {
|
||||
self.get_user_playlists().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn playlist_track_ids(&self, playlist_id: u64) -> Result<PlaylistWithTrackIds, String> {
|
||||
self.get_playlist_track_ids(playlist_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn add_favorite(&self, singular: &str, id: &str) -> Result<(), String> {
|
||||
QobuzClient::add_favorite(self, singular, id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
is_public: bool,
|
||||
) -> Result<Playlist, String> {
|
||||
QobuzClient::create_playlist(self, name, description, is_public)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn add_tracks(&self, playlist_id: u64, track_ids: &[u64]) -> Result<(), String> {
|
||||
self.add_tracks_to_playlist(playlist_id, track_ids)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn subscribe(&self, playlist_id: u64) -> Result<(), String> {
|
||||
self.subscribe_playlist(playlist_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Execute a [`CloudPlan`] against the target account: one favorite per
|
||||
//! call with pacing and retries, playlists created and filled in 50-track
|
||||
//! chunks in order, subscriptions re-followed. Every success is written to
|
||||
//! the ledger before the next write, so a crash mid-run resumes.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::api::MigrationApi;
|
||||
use crate::ledger::Ledger;
|
||||
use crate::plan::{CloudPlan, PlaylistAction};
|
||||
use crate::sink::{MigrationEvent, MigrationPhase, MigrationSink};
|
||||
|
||||
/// The importer's chunk (`qbz-playlist-import`): 50 ids per `addTracks`.
|
||||
const ADD_CHUNK: usize = 50;
|
||||
/// Breathing room between writes; the 403 breaker is the real ceiling.
|
||||
const PACE: Duration = Duration::from_millis(100);
|
||||
/// Transient retries per write: 1 s, 2 s, 4 s.
|
||||
const RETRY_BACKOFF: [Duration; 3] = [
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(2),
|
||||
Duration::from_secs(4),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct SectionReport {
|
||||
pub added: usize,
|
||||
pub already: usize,
|
||||
pub failed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ApplyReport {
|
||||
pub favorites: SectionReport,
|
||||
pub playlists: SectionReport,
|
||||
/// Tracks appended across all playlists.
|
||||
pub tracks_added: usize,
|
||||
pub subscriptions: SectionReport,
|
||||
}
|
||||
|
||||
impl ApplyReport {
|
||||
pub fn failed(&self) -> usize {
|
||||
self.favorites.failed.len() + self.playlists.failed.len() + self.subscriptions.failed.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// A write with bounded retries. Only the last error is reported.
|
||||
async fn with_retry<F, Fut>(mut op: F) -> Result<(), String>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<(), String>>,
|
||||
{
|
||||
let mut last = String::new();
|
||||
for (attempt, backoff) in RETRY_BACKOFF.iter().enumerate() {
|
||||
match op().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[account-migration] write failed (attempt {}): {e}",
|
||||
attempt + 1
|
||||
);
|
||||
last = e;
|
||||
tokio::time::sleep(*backoff).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
match op().await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(if e.is_empty() { last } else { e }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the plan. `profile_dir` is the TARGET profile (the ledger lives in
|
||||
/// it). Failures are collected per item and the run continues; the report
|
||||
/// says exactly what did not happen.
|
||||
pub async fn apply<A: MigrationApi, S: MigrationSink>(
|
||||
plan: &CloudPlan,
|
||||
api: &A,
|
||||
profile_dir: &Path,
|
||||
source_user_id: u64,
|
||||
sink: &S,
|
||||
) -> Result<ApplyReport, String> {
|
||||
let mut ledger = Ledger::load(profile_dir)?;
|
||||
ledger.sources.insert(source_user_id);
|
||||
let mut report = ApplyReport::default();
|
||||
|
||||
// --- Favorites, oldest first -----------------------------------------
|
||||
sink.emit(MigrationEvent::Phase(MigrationPhase::Favorites));
|
||||
let total: usize = plan.favorites_to_add();
|
||||
let mut done = 0usize;
|
||||
for (kind, items) in &plan.favorites.to_add {
|
||||
for item in items {
|
||||
sink.emit(MigrationEvent::Progress {
|
||||
done,
|
||||
total,
|
||||
label: format!("{}: {}", kind.plural(), item.title),
|
||||
});
|
||||
let id = item.id.clone();
|
||||
let singular = kind.singular();
|
||||
match with_retry(|| api.add_favorite(singular, &id)).await {
|
||||
Ok(()) => {
|
||||
ledger.mark_favorite(kind.plural(), &item.id);
|
||||
ledger.save(profile_dir)?;
|
||||
report.favorites.added += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
report
|
||||
.favorites
|
||||
.failed
|
||||
.push(format!("{} {}: {e}", kind.singular(), item.id))
|
||||
}
|
||||
}
|
||||
done += 1;
|
||||
tokio::time::sleep(PACE).await;
|
||||
}
|
||||
}
|
||||
report.favorites.already = plan.favorites.already.iter().map(|(_, n)| n).sum();
|
||||
|
||||
// --- Playlists ---------------------------------------------------------
|
||||
sink.emit(MigrationEvent::Phase(MigrationPhase::Playlists));
|
||||
let total = plan.playlists.len();
|
||||
for (i, action) in plan.playlists.iter().enumerate() {
|
||||
sink.emit(MigrationEvent::Progress {
|
||||
done: i,
|
||||
total,
|
||||
label: action.name().to_string(),
|
||||
});
|
||||
let (target_id, track_ids): (u64, &[u64]) = match action {
|
||||
PlaylistAction::Merge {
|
||||
target_id,
|
||||
missing_track_ids,
|
||||
..
|
||||
} => (*target_id, missing_track_ids),
|
||||
PlaylistAction::Create {
|
||||
name,
|
||||
description,
|
||||
is_public,
|
||||
track_ids,
|
||||
..
|
||||
}
|
||||
| PlaylistAction::CreateCopy {
|
||||
name,
|
||||
description,
|
||||
is_public,
|
||||
track_ids,
|
||||
..
|
||||
} => {
|
||||
let created = match api
|
||||
.create_playlist(name, description.as_deref(), *is_public)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report
|
||||
.playlists
|
||||
.failed
|
||||
.push(format!("{name}: create failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Map it before filling it: a crash while adding tracks
|
||||
// must not create a second copy on the next run.
|
||||
ledger.playlist_map.insert(action.source_id(), created.id);
|
||||
ledger.save(profile_dir)?;
|
||||
(created.id, track_ids)
|
||||
}
|
||||
};
|
||||
let mut failed_chunk = None;
|
||||
for chunk in track_ids.chunks(ADD_CHUNK) {
|
||||
if let Err(e) = with_retry(|| api.add_tracks(target_id, chunk)).await {
|
||||
failed_chunk = Some(e);
|
||||
break;
|
||||
}
|
||||
report.tracks_added += chunk.len();
|
||||
tokio::time::sleep(PACE).await;
|
||||
}
|
||||
match failed_chunk {
|
||||
Some(e) => report
|
||||
.playlists
|
||||
.failed
|
||||
.push(format!("{}: adding tracks failed: {e}", action.name())),
|
||||
None => {
|
||||
ledger.playlist_map.insert(action.source_id(), target_id);
|
||||
ledger.save(profile_dir)?;
|
||||
report.playlists.added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
report.playlists.already = plan.playlists_already;
|
||||
|
||||
// --- Subscriptions -----------------------------------------------------
|
||||
sink.emit(MigrationEvent::Phase(MigrationPhase::Subscriptions));
|
||||
let total = plan.subscriptions.len();
|
||||
for (i, id) in plan.subscriptions.iter().enumerate() {
|
||||
sink.emit(MigrationEvent::Progress {
|
||||
done: i,
|
||||
total,
|
||||
label: id.to_string(),
|
||||
});
|
||||
match with_retry(|| api.subscribe(*id)).await {
|
||||
Ok(()) => {
|
||||
ledger.subscriptions_done.insert(*id);
|
||||
ledger.playlist_map.insert(*id, *id);
|
||||
ledger.save(profile_dir)?;
|
||||
report.subscriptions.added += 1;
|
||||
}
|
||||
Err(e) => report
|
||||
.subscriptions
|
||||
.failed
|
||||
.push(format!("playlist {id}: subscribe failed: {e}")),
|
||||
}
|
||||
tokio::time::sleep(PACE).await;
|
||||
}
|
||||
report.subscriptions.already = plan.subscriptions_already;
|
||||
|
||||
sink.emit(MigrationEvent::Phase(MigrationPhase::Done));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plan::plan;
|
||||
use crate::sink::NullSink;
|
||||
use crate::snapshot::{AccountSnapshot, FavoriteItem, OwnedPlaylist, SubscribedPlaylist};
|
||||
use qbz_models::{Playlist, PlaylistOwner, PlaylistWithTrackIds};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Records every write; `fail_track` makes one track id fail forever.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
writes: Mutex<Vec<String>>,
|
||||
next_playlist_id: Mutex<u64>,
|
||||
fail_add_favorite: Option<String>,
|
||||
}
|
||||
|
||||
impl MigrationApi for Fake {
|
||||
async fn favorites_page(&self, plural: &str, _: u32, _: u32) -> Result<Value, String> {
|
||||
Ok(json!({ plural: { "items": [], "total": 0 } }))
|
||||
}
|
||||
async fn user_playlists(&self) -> Result<Vec<Playlist>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn playlist_track_ids(&self, _: u64) -> Result<PlaylistWithTrackIds, String> {
|
||||
Err("unused".into())
|
||||
}
|
||||
async fn add_favorite(&self, singular: &str, id: &str) -> Result<(), String> {
|
||||
if self.fail_add_favorite.as_deref() == Some(id) {
|
||||
return Err("boom".into());
|
||||
}
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("fav {singular} {id}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
_: Option<&str>,
|
||||
_: bool,
|
||||
) -> Result<Playlist, String> {
|
||||
let mut next = self.next_playlist_id.lock().unwrap();
|
||||
*next += 1;
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("create {name} -> {}", *next));
|
||||
Ok(Playlist {
|
||||
id: *next,
|
||||
name: name.to_string(),
|
||||
owner: PlaylistOwner::default(),
|
||||
..serde_json::from_value(json!({"id": *next, "name": name})).unwrap()
|
||||
})
|
||||
}
|
||||
async fn add_tracks(&self, playlist_id: u64, track_ids: &[u64]) -> Result<(), String> {
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("add {playlist_id} {track_ids:?}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn subscribe(&self, playlist_id: u64) -> Result<(), String> {
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("subscribe {playlist_id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn source() -> AccountSnapshot {
|
||||
let mut s = AccountSnapshot::empty(1, "src");
|
||||
s.favorites.albums = vec![
|
||||
FavoriteItem {
|
||||
id: "b".into(),
|
||||
favorited_at: Some(2),
|
||||
title: "B".into(),
|
||||
server_index: 0,
|
||||
},
|
||||
FavoriteItem {
|
||||
id: "a".into(),
|
||||
favorited_at: Some(1),
|
||||
title: "A".into(),
|
||||
server_index: 1,
|
||||
},
|
||||
];
|
||||
s.playlists = vec![OwnedPlaylist {
|
||||
id: 10,
|
||||
name: "Mix".into(),
|
||||
description: Some("d".into()),
|
||||
is_public: false,
|
||||
track_ids: (1..=60).collect(),
|
||||
}];
|
||||
s.subscriptions = vec![SubscribedPlaylist {
|
||||
id: 77,
|
||||
name: "Theirs".into(),
|
||||
owner_id: 9,
|
||||
}];
|
||||
s
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_in_order_and_a_second_run_is_a_no_op() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = Fake::default();
|
||||
let target = AccountSnapshot::empty(2, "dst");
|
||||
let p = plan(&source(), &target, &Ledger::load(dir.path()).unwrap());
|
||||
let report = apply(&p, &api, dir.path(), 1, &NullSink).await.unwrap();
|
||||
assert_eq!(report.favorites.added, 2);
|
||||
assert_eq!(report.playlists.added, 1);
|
||||
assert_eq!(report.tracks_added, 60);
|
||||
assert_eq!(report.subscriptions.added, 1);
|
||||
assert_eq!(report.failed(), 0);
|
||||
let writes = api.writes.lock().unwrap().clone();
|
||||
assert_eq!(writes[0], "fav album a"); // oldest first
|
||||
assert_eq!(writes[1], "fav album b");
|
||||
assert_eq!(writes[2], "create Mix -> 1");
|
||||
assert!(writes[3].starts_with("add 1 [1, 2,")); // chunk of 50
|
||||
assert!(writes[4].starts_with("add 1 [51,")); // then the 10 left
|
||||
assert_eq!(writes[5], "subscribe 77");
|
||||
|
||||
// The ledger now knows everything: the same plan against the same
|
||||
// (still empty) target reads as done.
|
||||
let ledger = Ledger::load(dir.path()).unwrap();
|
||||
assert_eq!(ledger.playlist_map.get(&10), Some(&1));
|
||||
assert!(ledger.favorite_done("albums", "a"));
|
||||
let again = plan(&source(), &target, &ledger);
|
||||
assert_eq!(again.favorites_to_add(), 0);
|
||||
assert!(again.subscriptions.is_empty());
|
||||
// The mapped playlist is absent from the (empty) target: planned as
|
||||
// new, which is honest — the target really has no such playlist.
|
||||
assert_eq!(again.playlists.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failing_favorite_is_reported_and_the_rest_continues() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = Fake {
|
||||
fail_add_favorite: Some("a".into()),
|
||||
..Fake::default()
|
||||
};
|
||||
let target = AccountSnapshot::empty(2, "dst");
|
||||
let p = plan(&source(), &target, &Ledger::default());
|
||||
let report = apply(&p, &api, dir.path(), 1, &NullSink).await.unwrap();
|
||||
assert_eq!(report.favorites.added, 1);
|
||||
assert_eq!(report.favorites.failed.len(), 1);
|
||||
assert!(report.favorites.failed[0].contains("album a"));
|
||||
assert_eq!(report.playlists.added, 1);
|
||||
let ledger = Ledger::load(dir.path()).unwrap();
|
||||
assert!(!ledger.favorite_done("albums", "a"));
|
||||
assert!(ledger.favorite_done("albums", "b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! The ledger: what earlier runs already wrote into the TARGET account, kept
|
||||
//! in the target's profile directory. Two jobs: resume/re-run to "0
|
||||
//! changes", and the old→new playlist id map the local-profile copy needs
|
||||
//! to remap everything keyed by `qobuz_playlist_id`.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::snapshot::SNAPSHOT_DIR;
|
||||
|
||||
pub const LEDGER_FILE: &str = "ledger.json";
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Ledger {
|
||||
/// Source playlist id → target playlist id (created, merged into, or
|
||||
/// re-subscribed; the last two map an id onto itself or the merge
|
||||
/// target).
|
||||
#[serde(default)]
|
||||
pub playlist_map: BTreeMap<u64, u64>,
|
||||
/// Favorite ids already written, per kind (`albums`, `tracks`, ...).
|
||||
#[serde(default)]
|
||||
pub favorites_done: BTreeMap<String, BTreeSet<String>>,
|
||||
#[serde(default)]
|
||||
pub subscriptions_done: BTreeSet<u64>,
|
||||
/// Source account ids this ledger has seen, for the report.
|
||||
#[serde(default)]
|
||||
pub sources: BTreeSet<u64>,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
pub fn path(profile_dir: &Path) -> PathBuf {
|
||||
profile_dir.join(SNAPSHOT_DIR).join(LEDGER_FILE)
|
||||
}
|
||||
|
||||
/// Missing file = empty ledger; a corrupt file is an error, never
|
||||
/// silently a fresh start (that would re-write everything).
|
||||
pub fn load(profile_dir: &Path) -> Result<Self, String> {
|
||||
let path = Self::path(profile_dir);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str(&text)
|
||||
.map_err(|e| format!("ledger {} is corrupt: {e}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||
Err(e) => Err(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, profile_dir: &Path) -> Result<(), String> {
|
||||
let path = Self::path(profile_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn favorite_done(&self, plural: &str, id: &str) -> bool {
|
||||
self.favorites_done
|
||||
.get(plural)
|
||||
.map(|set| set.contains(id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn mark_favorite(&mut self, plural: &str, id: &str) {
|
||||
self.favorites_done
|
||||
.entry(plural.to_string())
|
||||
.or_default()
|
||||
.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Qobuz account migration (contract:
|
||||
//! `qbz-nix-docs/specs/2026-09-02-account-migration-contract.md`, §9).
|
||||
//!
|
||||
//! Two halves, both frontend-agnostic (ADR 006):
|
||||
//!
|
||||
//! 1. [`snapshot`]: while signed in with the SOURCE account, capture its
|
||||
//! favorites (five kinds, with `favorited_at`), its own playlists with
|
||||
//! their track ids in order, and the playlists it follows, into one JSON
|
||||
//! file in that account's profile directory.
|
||||
//! 2. [`plan`] + [`apply`]: while signed in with the TARGET account, read a
|
||||
//! snapshot, compute the delta against the target's live state (captured
|
||||
//! with the same code) and the [`ledger`] of earlier runs, and write it
|
||||
//! — additively, never deleting on either side. Every write lands in the
|
||||
//! ledger before the next one starts, so a run can be resumed and re-run
|
||||
//! to "0 changes".
|
||||
//!
|
||||
//! Nothing here touches subscriptions, purchases or account data: the
|
||||
//! writes are the same `favorite/create`, `playlist/create`,
|
||||
//! `playlist/addTracks` and `playlist/subscribe` the app already issues
|
||||
//! when the user hearts or creates something.
|
||||
|
||||
pub mod api;
|
||||
pub mod apply;
|
||||
pub mod ledger;
|
||||
pub mod local;
|
||||
pub mod plan;
|
||||
pub mod sink;
|
||||
pub mod snapshot;
|
||||
|
||||
pub use api::MigrationApi;
|
||||
pub use apply::{apply, ApplyReport, SectionReport};
|
||||
pub use ledger::Ledger;
|
||||
pub use local::{copy_profile, LocalOptions, LocalReport};
|
||||
pub use plan::{plan, CloudPlan, PlaylistAction};
|
||||
pub use sink::{MigrationEvent, MigrationPhase, MigrationSink};
|
||||
pub use snapshot::{
|
||||
capture, AccountSnapshot, FavoriteItem, FavoriteKind, OwnedPlaylist, SubscribedPlaylist,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,420 @@
|
||||
//! The delta: what the target account is missing, given the source
|
||||
//! snapshot, the target's live state and the ledger. Pure — no I/O — so
|
||||
//! the rules are unit-tested here and the applier only executes.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ledger::Ledger;
|
||||
use crate::snapshot::{AccountSnapshot, FavoriteItem, FavoriteKind, OwnedPlaylist};
|
||||
|
||||
/// One playlist the applier will act on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum PlaylistAction {
|
||||
/// No playlist of that name on the target: create it and fill it.
|
||||
Create {
|
||||
source_id: u64,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
is_public: bool,
|
||||
track_ids: Vec<u64>,
|
||||
},
|
||||
/// A target playlist of the same name holds nothing the source lacks:
|
||||
/// it is the same playlist. Append what it is missing, in source order.
|
||||
Merge {
|
||||
source_id: u64,
|
||||
target_id: u64,
|
||||
name: String,
|
||||
missing_track_ids: Vec<u64>,
|
||||
},
|
||||
/// A target playlist of the same name holds tracks the source does not:
|
||||
/// a different playlist. Create a copy with a suffix rather than mix.
|
||||
CreateCopy {
|
||||
source_id: u64,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
is_public: bool,
|
||||
track_ids: Vec<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlaylistAction {
|
||||
pub fn source_id(&self) -> u64 {
|
||||
match self {
|
||||
PlaylistAction::Create { source_id, .. }
|
||||
| PlaylistAction::Merge { source_id, .. }
|
||||
| PlaylistAction::CreateCopy { source_id, .. } => *source_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
PlaylistAction::Create { name, .. }
|
||||
| PlaylistAction::Merge { name, .. }
|
||||
| PlaylistAction::CreateCopy { name, .. } => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks this action will write.
|
||||
pub fn track_count(&self) -> usize {
|
||||
match self {
|
||||
PlaylistAction::Create { track_ids, .. }
|
||||
| PlaylistAction::CreateCopy { track_ids, .. } => track_ids.len(),
|
||||
PlaylistAction::Merge {
|
||||
missing_track_ids, ..
|
||||
} => missing_track_ids.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct FavoritesPlan {
|
||||
pub kind_counts: Vec<(FavoriteKind, usize)>,
|
||||
/// Per kind, in write order (oldest favorite first).
|
||||
pub to_add: Vec<(FavoriteKind, Vec<FavoriteItem>)>,
|
||||
/// Already on the target (or in the ledger), per kind.
|
||||
pub already: Vec<(FavoriteKind, usize)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct CloudPlan {
|
||||
pub favorites: FavoritesPlan,
|
||||
pub playlists: Vec<PlaylistAction>,
|
||||
/// Owned source playlists that need nothing (fully present already).
|
||||
pub playlists_already: usize,
|
||||
pub subscriptions: Vec<u64>,
|
||||
pub subscriptions_already: usize,
|
||||
}
|
||||
|
||||
impl CloudPlan {
|
||||
pub fn favorites_to_add(&self) -> usize {
|
||||
self.favorites.to_add.iter().map(|(_, v)| v.len()).sum()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.favorites_to_add() == 0 && self.playlists.is_empty() && self.subscriptions.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The suffix a copy gets when the target already has a DIFFERENT
|
||||
/// playlist of the same name.
|
||||
pub const COPY_SUFFIX: &str = " (migrated)";
|
||||
|
||||
/// Write order for favorites: oldest first, so the target's own
|
||||
/// `favorited_at` stamps keep the source's relative chronology. With no
|
||||
/// stamp, the server listing is newest-first, so the fallback is the
|
||||
/// reverse of the listing order.
|
||||
fn chronological(mut items: Vec<FavoriteItem>) -> Vec<FavoriteItem> {
|
||||
let all_stamped = items.iter().all(|f| f.favorited_at.is_some());
|
||||
if all_stamped {
|
||||
items.sort_by_key(|f| {
|
||||
(
|
||||
f.favorited_at.unwrap_or(0),
|
||||
std::cmp::Reverse(f.server_index),
|
||||
)
|
||||
});
|
||||
} else {
|
||||
items.sort_by_key(|f| std::cmp::Reverse(f.server_index));
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn normalized(name: &str) -> String {
|
||||
name.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn plan(source: &AccountSnapshot, target: &AccountSnapshot, ledger: &Ledger) -> CloudPlan {
|
||||
let mut plan = CloudPlan::default();
|
||||
|
||||
// --- Favorites -------------------------------------------------------
|
||||
for kind in FavoriteKind::ALL {
|
||||
let have = target.favorites.ids(kind);
|
||||
let mut add = Vec::new();
|
||||
let mut already = 0usize;
|
||||
for item in source.favorites.of(kind) {
|
||||
if have.contains(item.id.as_str()) || ledger.favorite_done(kind.plural(), &item.id) {
|
||||
already += 1;
|
||||
} else {
|
||||
add.push(item.clone());
|
||||
}
|
||||
}
|
||||
let add = chronological(add);
|
||||
plan.favorites
|
||||
.kind_counts
|
||||
.push((kind, source.favorites.of(kind).len()));
|
||||
plan.favorites.already.push((kind, already));
|
||||
plan.favorites.to_add.push((kind, add));
|
||||
}
|
||||
|
||||
// --- Owned playlists ---------------------------------------------------
|
||||
let target_by_id: HashMap<u64, &OwnedPlaylist> =
|
||||
target.playlists.iter().map(|p| (p.id, p)).collect();
|
||||
let mut target_by_name: HashMap<String, Vec<&OwnedPlaylist>> = HashMap::new();
|
||||
for p in &target.playlists {
|
||||
target_by_name
|
||||
.entry(normalized(&p.name))
|
||||
.or_default()
|
||||
.push(p);
|
||||
}
|
||||
for src in &source.playlists {
|
||||
let src_set: HashSet<u64> = src.track_ids.iter().copied().collect();
|
||||
// An earlier run already mapped it: keep syncing the SAME target.
|
||||
if let Some(mapped) = ledger.playlist_map.get(&src.id) {
|
||||
if let Some(tgt) = target_by_id.get(mapped) {
|
||||
let tgt_set: HashSet<u64> = tgt.track_ids.iter().copied().collect();
|
||||
let missing: Vec<u64> = src
|
||||
.track_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !tgt_set.contains(id))
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
plan.playlists_already += 1;
|
||||
} else {
|
||||
plan.playlists.push(PlaylistAction::Merge {
|
||||
source_id: src.id,
|
||||
target_id: tgt.id,
|
||||
name: src.name.clone(),
|
||||
missing_track_ids: missing,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The mapped playlist is gone from the target: fall through and
|
||||
// treat the source playlist as new.
|
||||
}
|
||||
let same_name = target_by_name
|
||||
.get(&normalized(&src.name))
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
// §9.3: same name and nothing foreign in the target copy = the same
|
||||
// playlist (possibly behind); anything foreign = a different one.
|
||||
let twin = same_name
|
||||
.iter()
|
||||
.find(|tgt| tgt.track_ids.iter().all(|id| src_set.contains(id)));
|
||||
match twin {
|
||||
Some(tgt) => {
|
||||
let tgt_set: HashSet<u64> = tgt.track_ids.iter().copied().collect();
|
||||
let missing: Vec<u64> = src
|
||||
.track_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !tgt_set.contains(id))
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
plan.playlists_already += 1;
|
||||
} else {
|
||||
plan.playlists.push(PlaylistAction::Merge {
|
||||
source_id: src.id,
|
||||
target_id: tgt.id,
|
||||
name: src.name.clone(),
|
||||
missing_track_ids: missing,
|
||||
});
|
||||
}
|
||||
}
|
||||
None if same_name.is_empty() => plan.playlists.push(PlaylistAction::Create {
|
||||
source_id: src.id,
|
||||
name: src.name.clone(),
|
||||
description: src.description.clone(),
|
||||
is_public: src.is_public,
|
||||
track_ids: src.track_ids.clone(),
|
||||
}),
|
||||
None => plan.playlists.push(PlaylistAction::CreateCopy {
|
||||
source_id: src.id,
|
||||
name: format!("{}{}", src.name.trim(), COPY_SUFFIX),
|
||||
description: src.description.clone(),
|
||||
is_public: src.is_public,
|
||||
track_ids: src.track_ids.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Subscriptions -----------------------------------------------------
|
||||
let target_has: HashSet<u64> = target
|
||||
.subscribed_ids()
|
||||
.union(&target.owned_ids())
|
||||
.copied()
|
||||
.collect();
|
||||
for sub in &source.subscriptions {
|
||||
if target_has.contains(&sub.id) || ledger.subscriptions_done.contains(&sub.id) {
|
||||
plan.subscriptions_already += 1;
|
||||
} else {
|
||||
plan.subscriptions.push(sub.id);
|
||||
}
|
||||
}
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::snapshot::SubscribedPlaylist;
|
||||
|
||||
fn fav(id: &str, at: Option<i64>, idx: usize) -> FavoriteItem {
|
||||
FavoriteItem {
|
||||
id: id.into(),
|
||||
favorited_at: at,
|
||||
title: id.into(),
|
||||
server_index: idx,
|
||||
}
|
||||
}
|
||||
|
||||
fn owned(id: u64, name: &str, tracks: &[u64]) -> OwnedPlaylist {
|
||||
OwnedPlaylist {
|
||||
id,
|
||||
name: name.into(),
|
||||
description: None,
|
||||
is_public: false,
|
||||
track_ids: tracks.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn snap() -> AccountSnapshot {
|
||||
AccountSnapshot::empty(1, "src")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_are_a_delta_written_oldest_first() {
|
||||
let mut source = snap();
|
||||
source.favorites.albums = vec![
|
||||
fav("new", Some(300), 0),
|
||||
fav("mid", Some(200), 1),
|
||||
fav("old", Some(100), 2),
|
||||
fav("have", Some(50), 3),
|
||||
];
|
||||
let mut target = snap();
|
||||
target.favorites.albums = vec![fav("have", None, 0)];
|
||||
let p = plan(&source, &target, &Ledger::default());
|
||||
let albums = &p.favorites.to_add[0].1;
|
||||
let ids: Vec<&str> = albums.iter().map(|f| f.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["old", "mid", "new"]);
|
||||
assert_eq!(p.favorites.already[0], (FavoriteKind::Albums, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_without_stamps_reverse_the_server_listing() {
|
||||
let mut source = snap();
|
||||
source.favorites.tracks = vec![fav("newest", None, 0), fav("older", None, 1)];
|
||||
let p = plan(&source, &snap(), &Ledger::default());
|
||||
let ids: Vec<&str> = p.favorites.to_add[1]
|
||||
.1
|
||||
.iter()
|
||||
.map(|f| f.id.as_str())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["older", "newest"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ledger_entries_count_as_already_there() {
|
||||
let mut source = snap();
|
||||
source.favorites.artists = vec![fav("7", None, 0)];
|
||||
let mut ledger = Ledger::default();
|
||||
ledger.mark_favorite("artists", "7");
|
||||
let p = plan(&source, &snap(), &ledger);
|
||||
assert_eq!(p.favorites_to_add(), 0);
|
||||
assert!(p.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlist_rules_create_merge_and_copy() {
|
||||
let mut source = snap();
|
||||
source.playlists = vec![
|
||||
owned(10, "Road Trip", &[1, 2, 3]),
|
||||
owned(11, "Sleep", &[4, 5]),
|
||||
owned(12, "Metal", &[6, 7]),
|
||||
owned(13, "Done", &[8]),
|
||||
];
|
||||
let mut target = snap();
|
||||
target.playlists = vec![
|
||||
// same name, subset of the source: the same playlist, behind
|
||||
owned(100, "road trip ", &[1, 2]),
|
||||
// same name, holds a foreign track: a different playlist
|
||||
owned(101, "Metal", &[6, 99]),
|
||||
// same name, identical: nothing to do
|
||||
owned(102, "Done", &[8]),
|
||||
];
|
||||
let p = plan(&source, &target, &Ledger::default());
|
||||
assert_eq!(p.playlists_already, 1);
|
||||
assert_eq!(p.playlists.len(), 3);
|
||||
assert_eq!(
|
||||
p.playlists[0],
|
||||
PlaylistAction::Merge {
|
||||
source_id: 10,
|
||||
target_id: 100,
|
||||
name: "Road Trip".into(),
|
||||
missing_track_ids: vec![3],
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
&p.playlists[1],
|
||||
PlaylistAction::Create { source_id: 11, .. }
|
||||
));
|
||||
match &p.playlists[2] {
|
||||
PlaylistAction::CreateCopy {
|
||||
name, track_ids, ..
|
||||
} => {
|
||||
assert_eq!(name, "Metal (migrated)");
|
||||
assert_eq!(track_ids, &vec![6, 7]);
|
||||
}
|
||||
other => panic!("expected a copy, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mapped_playlist_keeps_syncing_its_target_even_under_another_name() {
|
||||
let mut source = snap();
|
||||
source.playlists = vec![owned(10, "Renamed since", &[1, 2, 3])];
|
||||
let mut target = snap();
|
||||
target.playlists = vec![owned(500, "Old name", &[1])];
|
||||
let mut ledger = Ledger::default();
|
||||
ledger.playlist_map.insert(10, 500);
|
||||
let p = plan(&source, &target, &ledger);
|
||||
assert_eq!(
|
||||
p.playlists,
|
||||
vec![PlaylistAction::Merge {
|
||||
source_id: 10,
|
||||
target_id: 500,
|
||||
name: "Renamed since".into(),
|
||||
missing_track_ids: vec![2, 3],
|
||||
}]
|
||||
);
|
||||
// Mapped target deleted on Qobuz: treated as new again.
|
||||
target.playlists.clear();
|
||||
let p = plan(&source, &target, &ledger);
|
||||
assert!(matches!(&p.playlists[0], PlaylistAction::Create { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriptions_skip_what_the_target_already_follows_or_owns() {
|
||||
let mut source = snap();
|
||||
source.subscriptions = vec![
|
||||
SubscribedPlaylist {
|
||||
id: 1,
|
||||
name: "a".into(),
|
||||
owner_id: 9,
|
||||
},
|
||||
SubscribedPlaylist {
|
||||
id: 2,
|
||||
name: "b".into(),
|
||||
owner_id: 9,
|
||||
},
|
||||
SubscribedPlaylist {
|
||||
id: 3,
|
||||
name: "c".into(),
|
||||
owner_id: 9,
|
||||
},
|
||||
];
|
||||
let mut target = snap();
|
||||
target.subscriptions = vec![SubscribedPlaylist {
|
||||
id: 1,
|
||||
name: "a".into(),
|
||||
owner_id: 9,
|
||||
}];
|
||||
target.playlists = vec![owned(2, "b", &[])];
|
||||
let p = plan(&source, &target, &Ledger::default());
|
||||
assert_eq!(p.subscriptions, vec![3]);
|
||||
assert_eq!(p.subscriptions_already, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Frontend-agnostic progress reporting (the playlist importer's shape).
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MigrationPhase {
|
||||
ReadingSource,
|
||||
ReadingTarget,
|
||||
Favorites,
|
||||
Playlists,
|
||||
Subscriptions,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum MigrationEvent {
|
||||
Phase(MigrationPhase),
|
||||
/// `done` of `total` units in the current phase, with a short label for
|
||||
/// the status line (a playlist name, a favorites kind).
|
||||
Progress {
|
||||
done: usize,
|
||||
total: usize,
|
||||
label: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait MigrationSink: Send + Sync {
|
||||
fn emit(&self, event: MigrationEvent);
|
||||
}
|
||||
|
||||
impl<F: Fn(MigrationEvent) + Send + Sync> MigrationSink for F {
|
||||
fn emit(&self, event: MigrationEvent) {
|
||||
self(event)
|
||||
}
|
||||
}
|
||||
|
||||
/// A sink that drops everything (tests, the CLI's quiet mode).
|
||||
pub struct NullSink;
|
||||
|
||||
impl MigrationSink for NullSink {
|
||||
fn emit(&self, _event: MigrationEvent) {}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
//! The account snapshot: everything the migration moves, captured while
|
||||
//! signed in with that account, as one JSON file.
|
||||
//!
|
||||
//! The same capture serves both sides: taken on the SOURCE it is the
|
||||
//! snapshot the user keeps; taken on the TARGET (in memory, right before
|
||||
//! applying) it is the live state the delta is computed against.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::api::MigrationApi;
|
||||
use crate::sink::{MigrationEvent, MigrationPhase, MigrationSink};
|
||||
|
||||
pub const KIND: &str = "qbz-account-snapshot";
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Favorites page size and the per-kind cap, the same policy as the
|
||||
/// Library's reader (`library_qt::fetch_favorites`).
|
||||
const PAGE_SIZE: u32 = 500;
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
/// The five favorite kinds Qobuz keeps, in the order they are migrated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FavoriteKind {
|
||||
Albums,
|
||||
Tracks,
|
||||
Artists,
|
||||
Labels,
|
||||
Awards,
|
||||
}
|
||||
|
||||
impl FavoriteKind {
|
||||
pub const ALL: [FavoriteKind; 5] = [
|
||||
FavoriteKind::Albums,
|
||||
FavoriteKind::Tracks,
|
||||
FavoriteKind::Artists,
|
||||
FavoriteKind::Labels,
|
||||
FavoriteKind::Awards,
|
||||
];
|
||||
|
||||
/// The `type` query value of `getUserFavorites` and the envelope key.
|
||||
pub fn plural(self) -> &'static str {
|
||||
match self {
|
||||
FavoriteKind::Albums => "albums",
|
||||
FavoriteKind::Tracks => "tracks",
|
||||
FavoriteKind::Artists => "artists",
|
||||
FavoriteKind::Labels => "labels",
|
||||
FavoriteKind::Awards => "awards",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<singular>_ids` key of `favorite/create`.
|
||||
pub fn singular(self) -> &'static str {
|
||||
match self {
|
||||
FavoriteKind::Albums => "album",
|
||||
FavoriteKind::Tracks => "track",
|
||||
FavoriteKind::Artists => "artist",
|
||||
FavoriteKind::Labels => "label",
|
||||
FavoriteKind::Awards => "award",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One favorite. `id` is a string for every kind because album ids are
|
||||
/// alphanumeric; numeric ids round-trip through their decimal form.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FavoriteItem {
|
||||
pub id: String,
|
||||
/// Unix seconds when the account favorited it, when Qobuz sends it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub favorited_at: Option<i64>,
|
||||
/// A display name for reports: album/track title, artist/label name.
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
/// The position in the server's listing (newest first), the fallback
|
||||
/// ordering when `favorited_at` is absent.
|
||||
#[serde(default)]
|
||||
pub server_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Favorites {
|
||||
#[serde(default)]
|
||||
pub albums: Vec<FavoriteItem>,
|
||||
#[serde(default)]
|
||||
pub tracks: Vec<FavoriteItem>,
|
||||
#[serde(default)]
|
||||
pub artists: Vec<FavoriteItem>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<FavoriteItem>,
|
||||
#[serde(default)]
|
||||
pub awards: Vec<FavoriteItem>,
|
||||
}
|
||||
|
||||
impl Favorites {
|
||||
pub fn of(&self, kind: FavoriteKind) -> &[FavoriteItem] {
|
||||
match kind {
|
||||
FavoriteKind::Albums => &self.albums,
|
||||
FavoriteKind::Tracks => &self.tracks,
|
||||
FavoriteKind::Artists => &self.artists,
|
||||
FavoriteKind::Labels => &self.labels,
|
||||
FavoriteKind::Awards => &self.awards,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn of_mut(&mut self, kind: FavoriteKind) -> &mut Vec<FavoriteItem> {
|
||||
match kind {
|
||||
FavoriteKind::Albums => &mut self.albums,
|
||||
FavoriteKind::Tracks => &mut self.tracks,
|
||||
FavoriteKind::Artists => &mut self.artists,
|
||||
FavoriteKind::Labels => &mut self.labels,
|
||||
FavoriteKind::Awards => &mut self.awards,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ids(&self, kind: FavoriteKind) -> HashSet<&str> {
|
||||
self.of(kind).iter().map(|f| f.id.as_str()).collect()
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
FavoriteKind::ALL.iter().map(|k| self.of(*k).len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// A playlist the account OWNS, with its track ids in order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OwnedPlaylist {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_public: bool,
|
||||
#[serde(default)]
|
||||
pub track_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
/// A playlist the account FOLLOWS (someone else's). Same id on every
|
||||
/// account, so it is re-subscribed, never copied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SubscribedPlaylist {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub owner_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SnapshotSource {
|
||||
pub user_id: u64,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
/// Stored only to help the owner identify bundles belonging to accounts
|
||||
/// with similar display names. Older snapshots deserialize without it.
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountSnapshot {
|
||||
pub kind: String,
|
||||
pub schema_version: u32,
|
||||
/// RFC 3339.
|
||||
pub created_at: String,
|
||||
pub source: SnapshotSource,
|
||||
#[serde(default)]
|
||||
pub favorites: Favorites,
|
||||
#[serde(default)]
|
||||
pub playlists: Vec<OwnedPlaylist>,
|
||||
#[serde(default)]
|
||||
pub subscriptions: Vec<SubscribedPlaylist>,
|
||||
}
|
||||
|
||||
impl AccountSnapshot {
|
||||
pub fn empty(user_id: u64, display_name: &str) -> Self {
|
||||
Self {
|
||||
kind: KIND.to_string(),
|
||||
schema_version: SCHEMA_VERSION,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
source: SnapshotSource {
|
||||
user_id,
|
||||
display_name: display_name.to_string(),
|
||||
email: String::new(),
|
||||
},
|
||||
favorites: Favorites::default(),
|
||||
playlists: Vec::new(),
|
||||
subscriptions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let snap: AccountSnapshot =
|
||||
serde_json::from_str(text).map_err(|e| format!("not a QBZ account snapshot: {e}"))?;
|
||||
if snap.kind != KIND {
|
||||
return Err(format!("not a QBZ account snapshot (kind `{}`)", snap.kind));
|
||||
}
|
||||
if snap.schema_version > SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"snapshot schema {} is newer than this QBZ understands ({})",
|
||||
snap.schema_version, SCHEMA_VERSION
|
||||
));
|
||||
}
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> Result<Self, String> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Self::parse(&text)
|
||||
}
|
||||
|
||||
pub fn write(&self, path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, json).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// `created_at` in the machine's local time, `YYYY-MM-DD HH:MM`, for
|
||||
/// listings; the raw RFC 3339 string when it does not parse.
|
||||
pub fn created_at_local(&self) -> String {
|
||||
chrono::DateTime::parse_from_rfc3339(&self.created_at)
|
||||
.map(|t| {
|
||||
t.with_timezone(&chrono::Local)
|
||||
.format("%Y-%m-%d %H:%M")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|_| self.created_at.clone())
|
||||
}
|
||||
|
||||
pub fn owned_ids(&self) -> HashSet<u64> {
|
||||
self.playlists.iter().map(|p| p.id).collect()
|
||||
}
|
||||
|
||||
pub fn subscribed_ids(&self) -> HashSet<u64> {
|
||||
self.subscriptions.iter().map(|p| p.id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where snapshots live inside a profile directory (`users/<uid>/`).
|
||||
pub const SNAPSHOT_DIR: &str = "account_migration";
|
||||
|
||||
/// `<profile>/account_migration/snapshot-YYYYMMDD-HHMMSS.json`.
|
||||
pub fn snapshot_path(profile_dir: &Path) -> PathBuf {
|
||||
profile_dir.join(SNAPSHOT_DIR).join(format!(
|
||||
"snapshot-{}.json",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S")
|
||||
))
|
||||
}
|
||||
|
||||
fn id_string(v: Option<&Value>) -> Option<String> {
|
||||
match v? {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_title(item: &Value) -> String {
|
||||
for key in ["title", "name"] {
|
||||
if let Some(s) = item.get(key).and_then(Value::as_str) {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Page one favorites kind until exhausted (short page, `total` reached,
|
||||
/// or the cap), preserving the server order in `server_index`.
|
||||
async fn fetch_favorites<A: MigrationApi>(
|
||||
api: &A,
|
||||
kind: FavoriteKind,
|
||||
) -> Result<Vec<FavoriteItem>, String> {
|
||||
let mut items: Vec<FavoriteItem> = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
loop {
|
||||
let value = api.favorites_page(kind.plural(), PAGE_SIZE, offset).await?;
|
||||
let branch = value.get(kind.plural());
|
||||
let total = branch
|
||||
.and_then(|b| b.get("total"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as usize;
|
||||
let page: Vec<Value> = branch
|
||||
.and_then(|b| b.get("items"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let page_len = page.len();
|
||||
for item in page {
|
||||
let Some(id) = id_string(item.get("id")) else {
|
||||
continue;
|
||||
};
|
||||
items.push(FavoriteItem {
|
||||
id,
|
||||
favorited_at: item.get("favorited_at").and_then(Value::as_i64),
|
||||
title: item_title(&item),
|
||||
server_index: items.len(),
|
||||
});
|
||||
}
|
||||
offset += page_len as u32;
|
||||
if page_len < PAGE_SIZE as usize
|
||||
|| (total > 0 && offset as usize >= total)
|
||||
|| items.len() >= MAX_ITEMS
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Capture the active session's account. `user_id` splits owned playlists
|
||||
/// from followed ones (`owner.id == user_id`). A favorites kind that fails
|
||||
/// (labels have 400'd for months at times) is logged and left empty; a
|
||||
/// playlist whose track ids cannot be read is skipped with a log line —
|
||||
/// the snapshot says what it holds, never guesses.
|
||||
pub async fn capture<A: MigrationApi, S: MigrationSink>(
|
||||
api: &A,
|
||||
user_id: u64,
|
||||
display_name: &str,
|
||||
email: &str,
|
||||
sink: &S,
|
||||
) -> Result<AccountSnapshot, String> {
|
||||
sink.emit(MigrationEvent::Phase(MigrationPhase::ReadingSource));
|
||||
let mut snap = AccountSnapshot::empty(user_id, display_name);
|
||||
snap.source.email = email.to_string();
|
||||
for (i, kind) in FavoriteKind::ALL.iter().enumerate() {
|
||||
sink.emit(MigrationEvent::Progress {
|
||||
done: i,
|
||||
total: FavoriteKind::ALL.len(),
|
||||
label: kind.plural().to_string(),
|
||||
});
|
||||
match fetch_favorites(api, *kind).await {
|
||||
Ok(items) => *snap.favorites.of_mut(*kind) = items,
|
||||
Err(e) => log::warn!(
|
||||
"[account-migration] favorites {} unreadable: {e}",
|
||||
kind.plural()
|
||||
),
|
||||
}
|
||||
}
|
||||
let playlists = api.user_playlists().await?;
|
||||
let total = playlists.len();
|
||||
for (i, playlist) in playlists.into_iter().enumerate() {
|
||||
sink.emit(MigrationEvent::Progress {
|
||||
done: i,
|
||||
total,
|
||||
label: playlist.name.clone(),
|
||||
});
|
||||
if playlist.owner.id == user_id {
|
||||
match api.playlist_track_ids(playlist.id).await {
|
||||
Ok(with_ids) => snap.playlists.push(OwnedPlaylist {
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
description: playlist.description,
|
||||
is_public: playlist.is_public,
|
||||
track_ids: with_ids.track_ids,
|
||||
}),
|
||||
Err(e) => log::warn!(
|
||||
"[account-migration] playlist {} ({}) unreadable, skipped: {e}",
|
||||
playlist.id,
|
||||
playlist.name
|
||||
),
|
||||
}
|
||||
} else {
|
||||
snap.subscriptions.push(SubscribedPlaylist {
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
owner_id: playlist.owner.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
/// Every snapshot file under `<data_root>/users/*/account_migration/`,
|
||||
/// newest first, with the profile (uid) it was found in.
|
||||
pub fn list_snapshots(data_root: &Path) -> Vec<(u64, PathBuf)> {
|
||||
let mut found: Vec<(u64, PathBuf, std::time::SystemTime)> = Vec::new();
|
||||
let Ok(users) = std::fs::read_dir(data_root.join("users")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
for user in users.flatten() {
|
||||
let Some(uid) = user
|
||||
.file_name()
|
||||
.to_str()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(files) = std::fs::read_dir(user.path().join(SNAPSHOT_DIR)) else {
|
||||
continue;
|
||||
};
|
||||
for file in files.flatten() {
|
||||
let path = file.path();
|
||||
let is_snapshot = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with("snapshot-") && n.ends_with(".json"))
|
||||
.unwrap_or(false);
|
||||
if !is_snapshot {
|
||||
continue;
|
||||
}
|
||||
let modified = file
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
found.push((uid, path, modified));
|
||||
}
|
||||
}
|
||||
found.sort_by(|a, b| b.2.cmp(&a.2));
|
||||
found
|
||||
.into_iter()
|
||||
.map(|(uid, path, _)| (uid, path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Delete one snapshot returned by [`list_snapshots`].
|
||||
///
|
||||
/// The exact-membership check is intentional: the UI passes a path as a
|
||||
/// string, so accepting an arbitrary path (or merely checking its suffix)
|
||||
/// would turn a cleanup button into a general file-delete primitive. This
|
||||
/// removes only the snapshot bundle; the source profile and all of its other
|
||||
/// user data remain untouched.
|
||||
pub fn delete_snapshot(data_root: &Path, requested: &Path) -> Result<(), String> {
|
||||
let allowed = list_snapshots(data_root)
|
||||
.into_iter()
|
||||
.any(|(_, path)| path == requested);
|
||||
if !allowed {
|
||||
return Err(format!(
|
||||
"refusing to delete a path outside the migration snapshot list: {}",
|
||||
requested.display()
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(requested).map_err(|e| format!("{}: {e}", requested.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_root(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"qbz-account-migration-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_created_before_email_identity_remain_readable() {
|
||||
let snapshot = AccountSnapshot::empty(42, "Old account");
|
||||
let mut value = serde_json::to_value(snapshot).unwrap();
|
||||
value["source"].as_object_mut().unwrap().remove("email");
|
||||
|
||||
let parsed = AccountSnapshot::parse(&value.to_string()).unwrap();
|
||||
|
||||
assert_eq!(parsed.source.display_name, "Old account");
|
||||
assert_eq!(parsed.source.user_id, 42);
|
||||
assert!(parsed.source.email.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_snapshot_removes_only_a_listed_bundle() {
|
||||
let root = temp_root("delete");
|
||||
let dir = root.join("users/42").join(SNAPSHOT_DIR);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let snapshot = dir.join("snapshot-20260902-120000.json");
|
||||
let keep = root.join("users/42/profile.db");
|
||||
std::fs::write(&snapshot, "{}").unwrap();
|
||||
std::fs::write(&keep, "profile").unwrap();
|
||||
|
||||
delete_snapshot(&root, &snapshot).unwrap();
|
||||
|
||||
assert!(!snapshot.exists());
|
||||
assert!(keep.exists());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_snapshot_refuses_an_unlisted_path() {
|
||||
let root = temp_root("refuse");
|
||||
let profile = root.join("users/42");
|
||||
std::fs::create_dir_all(&profile).unwrap();
|
||||
let keep = profile.join("profile.db");
|
||||
std::fs::write(&keep, "profile").unwrap();
|
||||
|
||||
let error = delete_snapshot(&root, &keep).unwrap_err();
|
||||
|
||||
assert!(error.contains("outside the migration snapshot list"));
|
||||
assert!(keep.exists());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,23 @@ qbz-player = { path = "../qbz-player" }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
qbz-credentials = { path = "../qbz-credentials" }
|
||||
qbz-offline-cache = { path = "../qbz-offline-cache" }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
dirs = "6"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { workspace = true }
|
||||
md-5 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
rand = "0.10"
|
||||
|
||||
# TLS provider selection
|
||||
rustls = { version = "0.23", features = ["aws_lc_rs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# A temp user dir for the settings-store tests.
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Local output-device quality cap (#638 fix 3).
|
||||
//!
|
||||
//! Caches the local DAC's detected rate ceiling, mapped to a Qobuz tier, so
|
||||
//! the request-time resolution (the frontend's `local_playback_quality`) can
|
||||
//! clamp the streaming-quality preference without probing per call. Detection
|
||||
//! is the proven read-only `qbz_audio::query_dac_capabilities` (reads
|
||||
//! `/proc/asound` and shells out to `pw-dump`; never opens a stream), so a
|
||||
//! refresh runs inside `spawn_blocking` on EXPLICIT triggers only — startup,
|
||||
//! the Settings toggle, an output-device/backend change, reset-to-defaults,
|
||||
//! the device refresh/release button — never on the playback hot path or the
|
||||
//! poll tick. A stale cap after a hot-unplug (until the next device change),
|
||||
//! and an uncapped first track when a session-restore play beats the startup
|
||||
//! refresh, are the accepted trades — same class as the HiFi wizard's
|
||||
//! behavior; both self-heal.
|
||||
//!
|
||||
//! PRECEDENCE (owner decision, #638): the cap of the device ACTUALLY PLAYING
|
||||
//! governs. This cache is for LOCAL playback only — the cast path must never
|
||||
//! read it (the local DAC is not in a cast's signal path).
|
||||
//!
|
||||
//! Frontend-agnostic by design: it lives here rather than in a binary crate
|
||||
//! because that is exactly why the row never reached the Qt frontend.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use qbz_audio::backend::AudioBackendType;
|
||||
use qbz_models::Quality;
|
||||
|
||||
/// The cached cap. `tier` is the coarse Qobuz-tier mapping of the detected
|
||||
/// ceiling (`max_rate_hz`); `detected` false = the probe fell back to the
|
||||
/// common rate set, so the Settings caveat must disclose that the cap may
|
||||
/// not match the hardware (owner Decision B: it still applies).
|
||||
#[derive(Clone)]
|
||||
pub struct CapState {
|
||||
pub tier: Quality,
|
||||
pub detected: bool,
|
||||
pub max_rate_hz: u32,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// None = the cap is disabled (toggle off) or not refreshed yet.
|
||||
static CAP: RwLock<Option<CapState>> = RwLock::new(None);
|
||||
|
||||
/// Refresh generation, and it is load-bearing rather than defensive.
|
||||
///
|
||||
/// The triggers are independently spawned tasks and the probe is a
|
||||
/// `spawn_blocking` that shells out to `pw-dump` — so two refreshes CAN be in
|
||||
/// flight, and the slow one can land last. Without this counter the sequence
|
||||
/// "toggle ON (slow probe) → user toggles OFF" ends with the OFF committing
|
||||
/// first and the stale probe RESURRECTING a cap the settings say is disabled:
|
||||
/// silently capped playback with the toggle visibly off, and nothing re-runs
|
||||
/// until the next device change. Every write below commits only if it is still
|
||||
/// the newest refresh.
|
||||
static GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// False until the first refresh of the process commits. It exists to stop a
|
||||
/// catastrophe, not to be tidy: the cap cache starts empty, so at boot
|
||||
/// `None -> Some(tier)` looks exactly like "the user just changed the cap",
|
||||
/// and the invalidation that answer triggers is `clear_audio_cache()`, which
|
||||
/// unlinks EVERY `<id>.audio` file on disk (hundreds of MB to GBs). Without
|
||||
/// this flag, every single launch with the toggle on would silently wipe the
|
||||
/// whole playback cache and re-download everything.
|
||||
static SEEDED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// What a refresh did — the audio-cache invalidation decision, made where the
|
||||
/// state lives instead of by a before/after comparison at the call site (which
|
||||
/// cannot tell a boot from a change, and misattributes a concurrent refresh's
|
||||
/// commit to itself).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CapChange {
|
||||
/// Same tier as before, or this refresh was superseded by a newer one.
|
||||
/// Nothing to invalidate.
|
||||
Unchanged,
|
||||
/// The effective request tier MOVED. The audio cache is quality-blind, so
|
||||
/// bytes fetched under the old tier must not keep serving.
|
||||
Changed,
|
||||
/// The first refresh of the process learned the cap. It is not a change:
|
||||
/// nothing was fetched under a different tier *by this process*, and the
|
||||
/// previous session already invalidated on its own transitions. Known
|
||||
/// residual: bytes cached under a session whose DAC had a HIGHER ceiling
|
||||
/// survive a reboot onto a smaller DAC until any in-session trigger fires.
|
||||
/// That narrow hole is worth far less than deleting the disk cache on
|
||||
/// every launch.
|
||||
Seeded,
|
||||
}
|
||||
|
||||
/// The classification, lifted out of the lock so it is testable.
|
||||
fn classify(first_refresh: bool, before: Option<Quality>, after: Option<Quality>) -> CapChange {
|
||||
if first_refresh {
|
||||
CapChange::Seeded
|
||||
} else if before == after {
|
||||
CapChange::Unchanged
|
||||
} else {
|
||||
CapChange::Changed
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a new cap under the write lock, refusing a superseded write.
|
||||
///
|
||||
/// The generation is re-checked WHILE HOLDING the lock. Checking it before
|
||||
/// doing the work left a TOCTOU window wide enough to drive the exact bug the
|
||||
/// counter exists for through: a slow probe passes the check, the user toggles
|
||||
/// the cap off and commits `None`, then the stale probe writes its `Some`
|
||||
/// back — a live cap with the toggle visibly off.
|
||||
fn commit(next: Option<CapState>, generation: u64) -> CapChange {
|
||||
let mut guard = CAP.write().unwrap_or_else(|e| e.into_inner());
|
||||
if GENERATION.load(Ordering::SeqCst) != generation {
|
||||
log::info!("[qbz] device cap: refresh superseded by a newer one, discarding");
|
||||
return CapChange::Unchanged;
|
||||
}
|
||||
let before = guard.as_ref().map(|c| c.tier);
|
||||
let after = next.as_ref().map(|c| c.tier);
|
||||
*guard = next;
|
||||
classify(!SEEDED.swap(true, Ordering::SeqCst), before, after)
|
||||
}
|
||||
|
||||
/// Map a detected max rate to the tier we may REQUEST. Coarse by design:
|
||||
/// Qobuz sells four discrete tiers, and the mapping must never name a tier
|
||||
/// the device cannot play, because the tier is a CEILING the service is free
|
||||
/// to deliver at:
|
||||
///
|
||||
/// - Hi-Res+ can deliver up to 192 kHz, so it needs a 192 kHz device. A
|
||||
/// 176.4 kHz ceiling resolves to Hi-Res, not Hi-Res+.
|
||||
/// - Hi-Res can deliver up to 96 kHz, so it needs a 96 kHz device. An
|
||||
/// 88.2 kHz ceiling resolves to CD, not Hi-Res.
|
||||
/// - There is no 48 kHz tier, so anything below 96 kHz steps down to CD
|
||||
/// 16/44.1 — bit depth included; the Settings summary says it plainly
|
||||
/// instead of letting the user discover it.
|
||||
///
|
||||
/// (Owner decision D4, 2026-08-17: these boundaries were the loose
|
||||
/// `> 96_000` / `>= 88_200` pair, which handed an 88.2 kHz DAC a tier
|
||||
/// requestable at 96 and a 176.4 kHz DAC one requestable at 192.)
|
||||
fn tier_for_max_rate_hz(max_hz: u32) -> Quality {
|
||||
if max_hz >= 192_000 {
|
||||
Quality::UltraHiRes
|
||||
} else if max_hz >= 96_000 {
|
||||
Quality::HiRes
|
||||
} else {
|
||||
Quality::Lossless
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap read for the request-time resolution: `(tier, detected)`.
|
||||
/// None = no cap configured. Two uncontended lock reads — safe to call on
|
||||
/// every play, which is what lets the cap live inside the play funnel.
|
||||
pub fn cap() -> Option<(Quality, bool)> {
|
||||
CAP.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.map(|c| (c.tier, c.detected))
|
||||
}
|
||||
|
||||
/// The Settings "Detected device limit" value line: `(summary, detected)`,
|
||||
/// e.g. `("192 kHz · Hi-Res+", true)`. Untranslated data composition — the
|
||||
/// same convention as the quality badge's "24-bit / 96 kHz" (tier names are
|
||||
/// product names). `("", true)` when no cap is active: the row hides on the
|
||||
/// empty summary, and `true` keeps the fallback caveat from flashing before
|
||||
/// the first refresh lands.
|
||||
pub fn summary() -> (String, bool) {
|
||||
match CAP.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
Some(c) => (
|
||||
format!(
|
||||
"{} · {}",
|
||||
rate_khz_label(c.max_rate_hz),
|
||||
tier_display(c.tier)
|
||||
),
|
||||
c.detected,
|
||||
),
|
||||
None => (String::new(), true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Product-name tier label for the summary line. The CD entry spells out the
|
||||
/// bit-depth cost (no 48 kHz tier exists, so the step below Hi-Res loses
|
||||
/// depth too — say it, don't let the user discover it).
|
||||
fn tier_display(tier: Quality) -> &'static str {
|
||||
match tier {
|
||||
Quality::UltraHiRes => "Hi-Res+",
|
||||
Quality::HiRes => "Hi-Res",
|
||||
Quality::Lossless => "CD 16-bit / 44.1 kHz",
|
||||
// Unreachable from tier_for_max_rate_hz; total match for safety.
|
||||
Quality::Mp3 => "MP3 320",
|
||||
}
|
||||
}
|
||||
|
||||
/// "192 kHz" / "44.1 kHz" from Hz — integer when whole, one decimal
|
||||
/// otherwise (matches the quality badge's rate formatting).
|
||||
fn rate_khz_label(hz: u32) -> String {
|
||||
let khz = hz as f64 / 1000.0;
|
||||
if khz.fract().abs() < f64::EPSILON {
|
||||
format!("{} kHz", khz as i64)
|
||||
} else {
|
||||
format!("{khz} kHz")
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run detection and refresh the cache. `limit_enabled` off clears it
|
||||
/// immediately (no probe). The probe runs in `spawn_blocking` (pw-dump
|
||||
/// subprocess + /proc reads — never on the UI thread). Await-able so the
|
||||
/// Settings controller can re-push the summary row right after.
|
||||
///
|
||||
/// `backend` is the audio backend ACTUALLY in use (owner decision D7), and its
|
||||
/// reach is narrower than it looks: it selects the backend that ENUMERATES the
|
||||
/// default sink when `output_device` is None ("System default"). The probe
|
||||
/// itself is PipeWire-backed regardless (`query_dac_capabilities` resolves the
|
||||
/// description through PipeWire, then falls through to `/proc/asound`). `None`
|
||||
/// means "no configured backend" and enumerates through PipeWire, the
|
||||
/// historical behavior.
|
||||
///
|
||||
/// Returns what changed, so the caller can decide about the audio cache
|
||||
/// without re-deriving it from a racy before/after read.
|
||||
pub async fn refresh(
|
||||
limit_enabled: bool,
|
||||
output_device: Option<String>,
|
||||
backend: Option<AudioBackendType>,
|
||||
) -> CapChange {
|
||||
let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if !limit_enabled {
|
||||
log::info!("[qbz] device cap: disabled");
|
||||
return commit(None, generation);
|
||||
}
|
||||
let probed = tokio::task::spawn_blocking(move || {
|
||||
// The configured device id, or the system-default sink when the
|
||||
// selection is "System default" (None). An unresolvable default
|
||||
// probes with an empty node name, which lands on the fallback set →
|
||||
// detected=false → Hi-Res+ no-op cap with the caveat disclosed.
|
||||
let node = output_device.unwrap_or_else(|| default_output_node(backend));
|
||||
qbz_audio::query_dac_capabilities(&node)
|
||||
})
|
||||
.await;
|
||||
let caps = match probed {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz] device cap: probe task failed: {e}");
|
||||
return CapChange::Unchanged;
|
||||
}
|
||||
};
|
||||
let max_rate_hz = caps.sample_rates.iter().copied().max().unwrap_or(0);
|
||||
// assemble() always yields a non-empty rate list (fallback set), but
|
||||
// never store a 0 Hz cap if that invariant ever breaks.
|
||||
if max_rate_hz == 0 {
|
||||
return commit(None, generation);
|
||||
}
|
||||
let state = CapState {
|
||||
tier: tier_for_max_rate_hz(max_rate_hz),
|
||||
detected: caps.detected,
|
||||
max_rate_hz,
|
||||
description: caps.description.unwrap_or_else(|| caps.node_name.clone()),
|
||||
};
|
||||
log::info!(
|
||||
"[qbz] device cap: {} -> max {} Hz -> {:?} ({})",
|
||||
state.description,
|
||||
state.max_rate_hz,
|
||||
state.tier,
|
||||
if state.detected {
|
||||
"detected"
|
||||
} else {
|
||||
"fallback set"
|
||||
},
|
||||
);
|
||||
commit(Some(state), generation)
|
||||
}
|
||||
|
||||
/// The default sink's node id for the "System default" device selection,
|
||||
/// resolved through the backend that is actually playing (D7). Empty when
|
||||
/// nothing enumerates — the probe then reports the fallback set honestly.
|
||||
fn default_output_node(backend: Option<AudioBackendType>) -> String {
|
||||
qbz_audio::backend::BackendManager::create_backend(
|
||||
backend.unwrap_or(AudioBackendType::PipeWire),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|b| b.enumerate_devices().ok())
|
||||
.and_then(|devs| devs.into_iter().find(|d| d.is_default))
|
||||
.map(|d| d.id)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tier_mapping_never_names_a_tier_the_device_cannot_play() {
|
||||
// >= 192 kHz → Hi-Res+ (no effective cap).
|
||||
assert_eq!(tier_for_max_rate_hz(384_000), Quality::UltraHiRes);
|
||||
assert_eq!(tier_for_max_rate_hz(192_000), Quality::UltraHiRes);
|
||||
// 176.4 kHz → Hi-Res, NOT Hi-Res+ (D4): Hi-Res+ may be delivered at
|
||||
// 192 kHz, which this device cannot play.
|
||||
assert_eq!(tier_for_max_rate_hz(176_400), Quality::HiRes);
|
||||
// 96 kHz → Hi-Res (its exact ceiling).
|
||||
assert_eq!(tier_for_max_rate_hz(96_000), Quality::HiRes);
|
||||
// 88.2 kHz → CD, NOT Hi-Res (D4): Hi-Res may be delivered at 96 kHz.
|
||||
assert_eq!(tier_for_max_rate_hz(88_200), Quality::Lossless);
|
||||
// <= 48 kHz → CD 16/44.1 (bit depth lost too — no 48 kHz tier).
|
||||
assert_eq!(tier_for_max_rate_hz(48_000), Quality::Lossless);
|
||||
assert_eq!(tier_for_max_rate_hz(44_100), Quality::Lossless);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_label_formats_whole_and_fractional_khz() {
|
||||
assert_eq!(rate_khz_label(192_000), "192 kHz");
|
||||
assert_eq!(rate_khz_label(44_100), "44.1 kHz");
|
||||
assert_eq!(rate_khz_label(176_400), "176.4 kHz");
|
||||
}
|
||||
|
||||
/// THE ONE THAT COST A DISK CACHE. `clear_audio_cache` unlinks every
|
||||
/// `<id>.audio` file on disk, and the cap cache starts empty — so the
|
||||
/// first refresh of a process ALWAYS looks like `None -> Some(tier)`.
|
||||
/// Classifying that as a change wiped the user's whole playback cache on
|
||||
/// every launch with the toggle on, and logged it as legitimate.
|
||||
#[test]
|
||||
fn the_first_refresh_of_a_process_is_never_a_cache_invalidating_change() {
|
||||
assert_eq!(
|
||||
classify(true, None, Some(Quality::Lossless)),
|
||||
CapChange::Seeded
|
||||
);
|
||||
// Even a first refresh that lands on a DIFFERENT tier than some past
|
||||
// session's is Seeded: this process fetched nothing under the old one.
|
||||
assert_eq!(
|
||||
classify(true, Some(Quality::UltraHiRes), Some(Quality::Lossless)),
|
||||
CapChange::Seeded
|
||||
);
|
||||
}
|
||||
|
||||
/// ...but once seeded, `None -> Some` is the toggle being switched ON,
|
||||
/// which is precisely when uncapped bytes must stop serving. Collapsing
|
||||
/// this into the boot case would make the feature inert on every track
|
||||
/// played before the toggle.
|
||||
#[test]
|
||||
fn turning_the_cap_on_after_boot_does_invalidate() {
|
||||
assert_eq!(
|
||||
classify(false, None, Some(Quality::Lossless)),
|
||||
CapChange::Changed
|
||||
);
|
||||
// And off again — bytes fetched under the cap are below what the
|
||||
// preference now requests, so they must not keep serving either.
|
||||
assert_eq!(
|
||||
classify(false, Some(Quality::Lossless), None),
|
||||
CapChange::Changed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_that_lands_on_the_same_tier_invalidates_nothing() {
|
||||
assert_eq!(
|
||||
classify(false, Some(Quality::HiRes), Some(Quality::HiRes)),
|
||||
CapChange::Unchanged
|
||||
);
|
||||
assert_eq!(classify(false, None, None), CapChange::Unchanged);
|
||||
}
|
||||
|
||||
/// The fallback rate set tops out at 192 kHz, so an UNDETECTED device is
|
||||
/// capped in name only (contract D6: accept and document). This test
|
||||
/// exists so the day someone tightens the fallback set, the consequence
|
||||
/// for the "The cap still applies" copy is visible instead of implicit.
|
||||
#[test]
|
||||
fn an_undetected_device_caps_at_hi_res_plus_which_is_no_clamp() {
|
||||
assert_eq!(tier_for_max_rate_hz(192_000), Quality::UltraHiRes);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@ pub struct RuntimeDiagnostics {
|
||||
pub audio_preferred_sample_rate: Option<u32>,
|
||||
pub audio_alsa_plugin: Option<String>,
|
||||
pub audio_alsa_hardware_volume: bool,
|
||||
pub audio_alsa_hardware_volume_controls: std::collections::HashMap<
|
||||
qbz_audio::alsa_hardware_volume::StableAlsaRouteKey,
|
||||
qbz_audio::alsa_hardware_volume::AlsaMixerControlId,
|
||||
>,
|
||||
/// Full read-only inventory for the active direct route, including
|
||||
/// rejected controls and their typed reasons.
|
||||
pub audio_alsa_hardware_volume_probe:
|
||||
Option<qbz_audio::alsa_hardware_volume::HardwareVolumeProbe>,
|
||||
pub audio_normalization_enabled: bool,
|
||||
pub audio_normalization_target_lufs: f32,
|
||||
pub audio_gapless_enabled: bool,
|
||||
@@ -121,6 +129,16 @@ pub fn runtime_diagnostics(i: &DiagnosticsInputs<'_>) -> RuntimeDiagnostics {
|
||||
audio_preferred_sample_rate: audio.preferred_sample_rate,
|
||||
audio_alsa_plugin: audio.alsa_plugin.map(|p| format!("{:?}", p)),
|
||||
audio_alsa_hardware_volume: audio.alsa_hardware_volume,
|
||||
audio_alsa_hardware_volume_controls: audio.alsa_hardware_volume_controls.clone(),
|
||||
audio_alsa_hardware_volume_probe: qbz_audio::alsa_direct::uses_alsa_direct_route(audio)
|
||||
.then(|| {
|
||||
qbz_audio::alsa_hardware_volume::enumerate_hardware_volume_controls(
|
||||
audio
|
||||
.output_device
|
||||
.as_deref()
|
||||
.expect("direct ALSA route has a device id"),
|
||||
)
|
||||
}),
|
||||
audio_normalization_enabled: audio.normalization_enabled,
|
||||
audio_normalization_target_lufs: audio.normalization_target_lufs,
|
||||
audio_gapless_enabled: audio.gapless_enabled,
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
pub mod device_cap;
|
||||
pub mod diagnostics;
|
||||
pub mod memory_watchdog;
|
||||
pub mod offline_mode;
|
||||
pub mod graphics_autoconfig;
|
||||
pub mod listen_log;
|
||||
pub mod playback_context;
|
||||
pub mod playback_driver;
|
||||
pub mod qconnect_identity;
|
||||
pub mod runtime;
|
||||
pub mod scrobble_timing;
|
||||
pub mod session_persist;
|
||||
pub mod session_store;
|
||||
pub mod subscription_lifecycle;
|
||||
pub mod shell;
|
||||
pub mod settings;
|
||||
pub mod user_data;
|
||||
|
||||
/// Install the rustls process-level `CryptoProvider` (aws-lc-rs) exactly once.
|
||||
///
|
||||
/// Idempotent (a second call is a no-op); harmless if some other component
|
||||
/// already installed a default (`install_default` then returns Err, ignored).
|
||||
pub fn ensure_crypto_provider() {
|
||||
use std::sync::Once;
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
if rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.is_err()
|
||||
{
|
||||
log::debug!("[app] rustls CryptoProvider already installed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
//! The async facade both hosts drive: a [`ListenTracker`] plus a
|
||||
//! [`ListenStore`], every store call inside `spawn_blocking`.
|
||||
//!
|
||||
//! Hosts call explicit lifecycle verbs (`track_started`, `tick`,
|
||||
//! `ended_naturally`, `stopped`, `handoff`, `shutdown`) and never touch SQLite
|
||||
//! or the state machine themselves. "Listening history OFF" (`paused`) is
|
||||
//! honoured HERE, in one place: a paused logger opens no row, so nothing
|
||||
//! downstream has to ask.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::store::ListenStore;
|
||||
use super::tracker::{ListenMeta, ListenTracker};
|
||||
|
||||
/// Where this process's rows come from (`origin_id`).
|
||||
pub enum Origin {
|
||||
/// The desktop app: a uuid generated once per install and kept in
|
||||
/// `listen_meta`.
|
||||
Install,
|
||||
/// The daemon: `qbzd:<hostname>`, fixed.
|
||||
Daemon { hostname: String },
|
||||
}
|
||||
|
||||
pub struct ListenLogger {
|
||||
store: Arc<Mutex<ListenStore>>,
|
||||
tracker: Mutex<ListenTracker>,
|
||||
app_session_id: String,
|
||||
origin_id: String,
|
||||
/// Mirror of `listen_meta.paused`, so the hot path (a tick per second)
|
||||
/// never opens the database to ask.
|
||||
paused: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
pub fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl ListenLogger {
|
||||
/// Open the per-user store, close any rows a previous run left open
|
||||
/// (`shutdown`), and mint this run's `app_session_id`.
|
||||
pub async fn open(user_dir: std::path::PathBuf, origin: Origin) -> Result<Arc<Self>, String> {
|
||||
let (store, origin_id, paused, orphans) = tokio::task::spawn_blocking(move || {
|
||||
let store = ListenStore::open_at(&user_dir)?;
|
||||
let origin_id = match origin {
|
||||
Origin::Install => store.origin_id_or_init(|| uuid::Uuid::new_v4().to_string())?,
|
||||
Origin::Daemon { hostname } => format!("qbzd:{hostname}"),
|
||||
};
|
||||
let paused = store.is_paused()?;
|
||||
let orphans = store.close_orphans_as_shutdown()?;
|
||||
Ok::<_, String>((store, origin_id, paused, orphans))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("listen log open task failed: {e}"))??;
|
||||
if orphans > 0 {
|
||||
log::info!(
|
||||
"[listen-log] closed {orphans} row(s) left open by a previous run as shutdown"
|
||||
);
|
||||
}
|
||||
log::info!("[listen-log] open (origin {origin_id}, paused={paused})");
|
||||
Ok(Arc::new(Self {
|
||||
store: Arc::new(Mutex::new(store)),
|
||||
tracker: Mutex::new(ListenTracker::new()),
|
||||
app_session_id: uuid::Uuid::new_v4().to_string(),
|
||||
origin_id,
|
||||
paused: std::sync::atomic::AtomicBool::new(paused),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.paused.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The "Listening history" toggle. Turning it OFF closes the row in
|
||||
/// flight (as `stop`) so nothing is written after the user said no.
|
||||
pub async fn set_paused(&self, paused: bool) -> Result<(), String> {
|
||||
self.paused
|
||||
.store(paused, std::sync::atomic::Ordering::Relaxed);
|
||||
if paused {
|
||||
let closed = self.tracker.lock().unwrap().stopped(now_unix(), false);
|
||||
if let Some(c) = closed {
|
||||
self.with_store(move |s| s.close_event(&c)).await?;
|
||||
}
|
||||
}
|
||||
self.with_store(move |s| s.set_paused(paused)).await
|
||||
}
|
||||
|
||||
/// "Clear listening history": DELETE + VACUUM. The row in flight is
|
||||
/// closed first so it does not survive the clear.
|
||||
pub async fn clear(&self) -> Result<(), String> {
|
||||
let closed = self.tracker.lock().unwrap().stopped(now_unix(), false);
|
||||
self.with_store(move |s| {
|
||||
if let Some(c) = closed {
|
||||
s.close_event(&c)?;
|
||||
}
|
||||
s.clear()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count(&self) -> Result<u64, String> {
|
||||
self.with_store(|s| s.count()).await
|
||||
}
|
||||
|
||||
/// A new track started (the host's de-duped track edge). Closes the row
|
||||
/// in flight as skip (or, with `infer_end`, as natural when it sat within
|
||||
/// 2 s of its end — for hosts without an explicit end edge).
|
||||
pub async fn track_started(&self, meta: ListenMeta, infer_end: bool) {
|
||||
let now = now_unix();
|
||||
if self.is_paused() {
|
||||
// Still close whatever was open before the pause landed.
|
||||
let closed = self.tracker.lock().unwrap().stopped(now, infer_end);
|
||||
if let Some(c) = closed {
|
||||
let _ = self.with_store(move |s| s.close_event(&c)).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (closed, meta) = {
|
||||
let mut t = self.tracker.lock().unwrap();
|
||||
if infer_end {
|
||||
t.track_started_inferring_end(meta, now)
|
||||
} else {
|
||||
t.track_started(meta, now)
|
||||
}
|
||||
};
|
||||
let session = self.app_session_id.clone();
|
||||
let origin = self.origin_id.clone();
|
||||
let result = self
|
||||
.with_store(move |s| {
|
||||
if let Some(c) = closed {
|
||||
s.close_event(&c)?;
|
||||
}
|
||||
s.open_event(&session, &origin, &meta, now)
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(id) => self.tracker.lock().unwrap().opened(id),
|
||||
Err(e) => log::warn!("[listen-log] open failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One playback observation (1 Hz). Flushes to disk every ~10 s of play.
|
||||
pub async fn tick(&self, position_ms: u64, playing: bool) {
|
||||
let flush = self.tracker.lock().unwrap().tick(position_ms, playing);
|
||||
if let Some(f) = flush {
|
||||
if let Err(e) = self.with_store(move |s| s.flush_progress(&f)).await {
|
||||
log::warn!("[listen-log] flush failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ended_naturally(&self) {
|
||||
let closed = self.tracker.lock().unwrap().ended_naturally(now_unix());
|
||||
self.close(closed).await;
|
||||
}
|
||||
|
||||
pub async fn stopped(&self, infer_natural: bool) {
|
||||
let closed = self
|
||||
.tracker
|
||||
.lock()
|
||||
.unwrap()
|
||||
.stopped(now_unix(), infer_natural);
|
||||
self.close(closed).await;
|
||||
}
|
||||
|
||||
pub async fn errored(&self) {
|
||||
let closed = self.tracker.lock().unwrap().errored(now_unix());
|
||||
self.close(closed).await;
|
||||
}
|
||||
|
||||
/// Playback authority moved away from this process. Close the owner row
|
||||
/// as `handoff`; delegated tracks are intentionally never opened here.
|
||||
/// Idempotent so repeated lifecycle observations cannot rewrite the row.
|
||||
pub async fn handoff(&self) {
|
||||
let closed = self.tracker.lock().unwrap().handoff(now_unix());
|
||||
self.close(closed).await;
|
||||
}
|
||||
|
||||
/// Orderly exit: close the row in flight as `shutdown`. SYNCHRONOUS on
|
||||
/// purpose — the hosts call it after their event loop is gone.
|
||||
pub fn shutdown_blocking(&self) {
|
||||
let closed = self.tracker.lock().unwrap().shutdown(now_unix());
|
||||
if let Some(c) = closed {
|
||||
if let Ok(store) = self.store.lock() {
|
||||
if let Err(e) = store.close_event(&c) {
|
||||
log::warn!("[listen-log] shutdown close failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a row is in flight (tests + diagnostics).
|
||||
pub fn has_open_row(&self) -> bool {
|
||||
self.tracker.lock().unwrap().open().is_some()
|
||||
}
|
||||
|
||||
/// Developer inspection: every row in id order.
|
||||
pub async fn rows(&self) -> Result<Vec<super::store::ListenRow>, String> {
|
||||
self.with_store(|s| s.rows()).await
|
||||
}
|
||||
|
||||
async fn close(&self, closed: Option<super::tracker::Closed>) {
|
||||
if let Some(c) = closed {
|
||||
if let Err(e) = self.with_store(move |s| s.close_event(&c)).await {
|
||||
log::warn!("[listen-log] close failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn with_store<T, F>(&self, f: F) -> Result<T, String>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&ListenStore) -> Result<T, String> + Send + 'static,
|
||||
{
|
||||
let store = Arc::clone(&self.store);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = store
|
||||
.lock()
|
||||
.map_err(|_| "listen log store lock poisoned".to_string())?;
|
||||
f(&guard)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("listen log task failed: {e}"))?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::rules::EndReason;
|
||||
use super::*;
|
||||
|
||||
fn meta(id: &str, duration_ms: u64) -> ListenMeta {
|
||||
ListenMeta {
|
||||
source: "qobuz".into(),
|
||||
source_item_id: id.into(),
|
||||
title: format!("T{id}"),
|
||||
artist: "A".into(),
|
||||
duration_ms,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn play_through_then_skip_then_shutdown() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(l.origin_id.len(), 36);
|
||||
|
||||
l.track_started(meta("1", 30_000), false).await;
|
||||
for s in 0..=29u64 {
|
||||
l.tick(s * 1_000, true).await;
|
||||
}
|
||||
l.ended_naturally().await;
|
||||
|
||||
l.track_started(meta("2", 200_000), false).await;
|
||||
for s in 0..=10u64 {
|
||||
l.tick(s * 1_000, true).await;
|
||||
}
|
||||
l.track_started(meta("3", 200_000), false).await;
|
||||
for s in 0..=25u64 {
|
||||
l.tick(s * 1_000, true).await;
|
||||
}
|
||||
l.shutdown_blocking();
|
||||
|
||||
let rows = l.rows().await.unwrap();
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].end_reason, Some(EndReason::Natural));
|
||||
assert_eq!(rows[0].played_ms, 29_000);
|
||||
assert_eq!(rows[1].end_reason, Some(EndReason::Skip));
|
||||
assert_eq!(rows[1].played_ms, 10_000);
|
||||
assert_eq!(rows[2].end_reason, Some(EndReason::Shutdown));
|
||||
assert_eq!(rows[2].played_ms, 25_000);
|
||||
assert!(rows.iter().all(|r| r.app_session_id == l.app_session_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crash_leaves_flushed_progress_and_reopen_closes_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let origin = {
|
||||
let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
l.track_started(meta("1", 200_000), false).await;
|
||||
for s in 0..=23u64 {
|
||||
l.tick(s * 1_000, true).await;
|
||||
}
|
||||
// No shutdown: the process died here. The last flush was at 20 s.
|
||||
l.origin_id.clone()
|
||||
};
|
||||
let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(l.origin_id, origin, "origin id survives restarts");
|
||||
let rows = l.rows().await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].end_reason, Some(EndReason::Shutdown));
|
||||
assert_eq!(rows[0].played_ms, 20_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paused_writes_nothing_and_resumes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
l.set_paused(true).await.unwrap();
|
||||
l.track_started(meta("1", 100_000), false).await;
|
||||
l.tick(0, true).await;
|
||||
l.tick(15_000, true).await;
|
||||
l.ended_naturally().await;
|
||||
assert_eq!(l.count().await.unwrap(), 0);
|
||||
assert!(!l.has_open_row());
|
||||
|
||||
// The flag is persisted: a reopen stays paused.
|
||||
let l2 = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(l2.is_paused());
|
||||
l2.set_paused(false).await.unwrap();
|
||||
l2.track_started(meta("2", 100_000), false).await;
|
||||
l2.tick(0, true).await;
|
||||
l2.tick(1_000, true).await;
|
||||
l2.stopped(false).await;
|
||||
let rows = l2.rows().await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].end_reason, Some(EndReason::Stop));
|
||||
assert_eq!(rows[0].played_ms, 1_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pausing_mid_track_closes_the_open_row_and_stops_writing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
l.track_started(meta("1", 100_000), false).await;
|
||||
l.tick(0, true).await;
|
||||
l.tick(1_000, true).await;
|
||||
l.set_paused(true).await.unwrap();
|
||||
l.tick(2_000, true).await;
|
||||
l.track_started(meta("2", 100_000), false).await;
|
||||
let rows = l.rows().await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].end_reason, Some(EndReason::Stop));
|
||||
assert_eq!(rows[0].played_ms, 1_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_removes_everything_including_the_open_row() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
l.track_started(meta("1", 100_000), false).await;
|
||||
l.ended_naturally().await;
|
||||
l.track_started(meta("2", 100_000), false).await;
|
||||
l.clear().await.unwrap();
|
||||
assert_eq!(l.count().await.unwrap(), 0);
|
||||
assert!(!l.has_open_row());
|
||||
// Logging continues after a clear.
|
||||
l.track_started(meta("3", 100_000), false).await;
|
||||
assert_eq!(l.count().await.unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daemon_origin_and_inferred_end() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let l = ListenLogger::open(
|
||||
dir.path().to_path_buf(),
|
||||
Origin::Daemon {
|
||||
hostname: "pi".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
l.track_started(meta("1", 100_000), true).await;
|
||||
l.tick(0, true).await;
|
||||
l.tick(99_000, true).await;
|
||||
// The bus only says "another track started": infer natural.
|
||||
l.track_started(meta("2", 100_000), true).await;
|
||||
l.tick(0, true).await;
|
||||
l.tick(5_000, true).await;
|
||||
l.stopped(true).await;
|
||||
let rows = l.rows().await.unwrap();
|
||||
assert_eq!(rows[0].origin_id, "qbzd:pi");
|
||||
assert_eq!(rows[0].end_reason, Some(EndReason::Natural));
|
||||
assert_eq!(rows[1].end_reason, Some(EndReason::Stop));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handoff_closes_the_owner_row_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
l.track_started(meta("1", 100_000), false).await;
|
||||
l.tick(0, true).await;
|
||||
l.tick(1_000, true).await;
|
||||
|
||||
l.handoff().await;
|
||||
l.handoff().await;
|
||||
|
||||
assert!(!l.has_open_row());
|
||||
let rows = l.rows().await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].end_reason, Some(EndReason::Handoff));
|
||||
assert_eq!(rows[0].played_ms, 1_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Listen log — the per-user record of what was actually heard.
|
||||
//!
|
||||
//! Contract: `qbz-nix-docs/research/2026-08-28-listening-log-offline-reco-analysis.md`
|
||||
//! §12.1. Capture only: this module has NO consumer in the app. No rail,
|
||||
//! mix, insight or suggestion reads it; the older stores
|
||||
//! (`recently_played.json`, `album_play_history.db`,
|
||||
//! `playlist_play_history.db`) keep running unchanged beside it.
|
||||
//!
|
||||
//! - [`store`] — the SQLite schema (PK = event) and its migrations.
|
||||
//! - [`tracker`] — the pure state machine (accumulator, end reasons).
|
||||
//! - [`rules`] — the reading thresholds (`is_play`, `is_skip`).
|
||||
//! - [`logger`] — the async facade the Qt poll loop and `qbzd` drive.
|
||||
|
||||
pub mod logger;
|
||||
pub mod rules;
|
||||
pub mod store;
|
||||
pub mod tracker;
|
||||
|
||||
pub use logger::{now_unix, ListenLogger, Origin};
|
||||
pub use rules::{is_play, is_skip, EndReason};
|
||||
pub use store::{ListenRow, ListenStore};
|
||||
pub use tracker::{ListenMeta, ListenTracker};
|
||||
|
||||
/// Ephemeral ids (CD / ad-hoc folder rows) live above 2^48 and never reach
|
||||
/// the log (owner rule, mirrored from `recently_qt.rs:329`).
|
||||
pub const EPHEMERAL_ID_FLOOR: u64 = 1 << 48;
|
||||
|
||||
/// Build the row snapshot from a queue track plus what the host knows about
|
||||
/// the stream. `source_item_id` is the NATIVE id when the queue carries one
|
||||
/// (media-server item ids, Plex rating keys, offline row ids travel in
|
||||
/// `source_item_id_hint` for local-tier rows); for Qobuz the hint is a
|
||||
/// container id and the track id IS the native id.
|
||||
pub fn meta_from_queue_track(
|
||||
track: &qbz_models::QueueTrack,
|
||||
bit_depth: Option<u32>,
|
||||
sample_rate_hz: Option<u32>,
|
||||
output_backend: Option<String>,
|
||||
) -> ListenMeta {
|
||||
let source = track
|
||||
.source
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if track.is_local {
|
||||
"local".into()
|
||||
} else {
|
||||
"qobuz".into()
|
||||
}
|
||||
});
|
||||
// "qobuz_download" is the offline cache's own source word in the local
|
||||
// library; the contract's vocabulary calls that tier `offline`.
|
||||
let source = if source == "qobuz_download" {
|
||||
"offline".to_string()
|
||||
} else {
|
||||
source
|
||||
};
|
||||
let source_item_id = match (&track.source_item_id_hint, track.is_local) {
|
||||
(Some(hint), true) if !hint.is_empty() => hint.clone(),
|
||||
_ => track.id.to_string(),
|
||||
};
|
||||
ListenMeta {
|
||||
source,
|
||||
source_item_id,
|
||||
track_id: Some(track.id as i64),
|
||||
album_id: track.album_id.clone().filter(|a| !a.is_empty()),
|
||||
artist_id: track.artist_id.map(|id| id.to_string()),
|
||||
isrc: track.isrc.clone().filter(|s| !s.is_empty()),
|
||||
recording_mbid: track.recording_mbid.clone().filter(|s| !s.is_empty()),
|
||||
title: track.title.clone(),
|
||||
artist: track.artist.clone(),
|
||||
album: Some(track.album.clone()).filter(|a| !a.is_empty()),
|
||||
album_artist: None,
|
||||
artwork_key: track.artwork_url.clone().filter(|a| !a.is_empty()),
|
||||
duration_ms: track.duration_secs * 1_000,
|
||||
context_kind: track.context_kind.clone().unwrap_or_default(),
|
||||
context_id: track.context_id.clone().unwrap_or_default(),
|
||||
bit_depth: bit_depth.or(track.bit_depth),
|
||||
sample_rate: sample_rate_hz
|
||||
.or_else(|| track.sample_rate.map(|khz| (khz * 1000.0).round() as u32)),
|
||||
output_backend,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track() -> qbz_models::QueueTrack {
|
||||
qbz_models::QueueTrack {
|
||||
id: 7,
|
||||
title: "Song".into(),
|
||||
version: None,
|
||||
artist: "Band".into(),
|
||||
album: "LP".into(),
|
||||
album_version: None,
|
||||
duration_secs: 200,
|
||||
artwork_url: Some("http://art".into()),
|
||||
hires: true,
|
||||
bit_depth: Some(24),
|
||||
sample_rate: Some(96.0),
|
||||
is_local: false,
|
||||
album_id: Some("alb".into()),
|
||||
artist_id: Some(9),
|
||||
streamable: true,
|
||||
source: Some("qobuz".into()),
|
||||
parental_warning: false,
|
||||
source_item_id_hint: Some("alb".into()),
|
||||
context_kind: Some("playlist".into()),
|
||||
context_id: Some("pl-1".into()),
|
||||
isrc: Some("USRC17607839".into()),
|
||||
recording_mbid: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qobuz_uses_the_track_id_not_the_container_hint() {
|
||||
let m = meta_from_queue_track(&track(), None, None, None);
|
||||
assert_eq!(m.source, "qobuz");
|
||||
assert_eq!(m.source_item_id, "7");
|
||||
assert_eq!(m.track_id, Some(7));
|
||||
assert_eq!(m.duration_ms, 200_000);
|
||||
assert_eq!(m.context_kind, "playlist");
|
||||
assert_eq!(m.context_id, "pl-1");
|
||||
assert_eq!(m.isrc.as_deref(), Some("USRC17607839"));
|
||||
assert_eq!(m.sample_rate, Some(96_000));
|
||||
assert_eq!(m.bit_depth, Some(24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tier_rows_use_the_native_hint_and_offline_is_renamed() {
|
||||
let mut t = track();
|
||||
t.is_local = true;
|
||||
t.source = Some("jellyfin".into());
|
||||
t.source_item_id_hint = Some("item-abc".into());
|
||||
let m = meta_from_queue_track(&t, Some(16), Some(44_100), Some("alsa".into()));
|
||||
assert_eq!(m.source, "jellyfin");
|
||||
assert_eq!(m.source_item_id, "item-abc");
|
||||
assert_eq!(m.bit_depth, Some(16));
|
||||
assert_eq!(m.sample_rate, Some(44_100));
|
||||
assert_eq!(m.output_backend.as_deref(), Some("alsa"));
|
||||
|
||||
t.source = Some("qobuz_download".into());
|
||||
assert_eq!(
|
||||
meta_from_queue_track(&t, None, None, None).source,
|
||||
"offline"
|
||||
);
|
||||
|
||||
t.source = None;
|
||||
t.source_item_id_hint = None;
|
||||
let m = meta_from_queue_track(&t, None, None, None);
|
||||
assert_eq!(m.source, "local");
|
||||
assert_eq!(m.source_item_id, "7");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Pure reading rules over listen events.
|
||||
//!
|
||||
//! The store applies NO threshold when it writes (LMS/Astra): a skip is a row
|
||||
//! with little `played_ms`, never an absent row. Whatever wants to count
|
||||
//! "plays" or "skips" applies its bar here, so two consumers can never
|
||||
//! disagree about what a play is because one of them wrote and the other
|
||||
//! read (the Amperfy/Retro trap).
|
||||
|
||||
/// Why a listen event closed. `Open` is the in-flight state (NULL on disk).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EndReason {
|
||||
/// The track played to its end.
|
||||
Natural,
|
||||
/// Replaced by another track before its end (next / previous / play X).
|
||||
Skip,
|
||||
/// Playback stopped with nothing following.
|
||||
Stop,
|
||||
/// The stream failed.
|
||||
Error,
|
||||
/// The app went away with the row open (closed on the next start, or on
|
||||
/// an orderly exit).
|
||||
Shutdown,
|
||||
/// Playback moved to another device (cast / peer). Reserved — v1 never
|
||||
/// records those tracks, but the word is fixed so readers can rely on it.
|
||||
Handoff,
|
||||
}
|
||||
|
||||
/// ONE list, read by both directions: `as_str` and `parse` must never drift
|
||||
/// (the `AudioFormat::Dsd` → `Unknown` lesson: a writer and a reader that each
|
||||
/// spell the enum are two lists).
|
||||
const END_REASONS: &[(EndReason, &str)] = &[
|
||||
(EndReason::Natural, "natural"),
|
||||
(EndReason::Skip, "skip"),
|
||||
(EndReason::Stop, "stop"),
|
||||
(EndReason::Error, "error"),
|
||||
(EndReason::Shutdown, "shutdown"),
|
||||
(EndReason::Handoff, "handoff"),
|
||||
];
|
||||
|
||||
impl EndReason {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
END_REASONS
|
||||
.iter()
|
||||
.find(|(r, _)| *r == self)
|
||||
.map(|(_, s)| *s)
|
||||
.expect("every EndReason is in END_REASONS")
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
END_REASONS.iter().find(|(_, t)| *t == s).map(|(r, _)| *r)
|
||||
}
|
||||
}
|
||||
|
||||
/// Last.fm / MusicBee bar: the track is longer than 30 s and at least half of
|
||||
/// it (capped at 4 minutes) was audible.
|
||||
pub fn is_play(played_ms: u64, duration_ms: u64) -> bool {
|
||||
duration_ms > 30_000 && played_ms >= (duration_ms / 2).min(240_000)
|
||||
}
|
||||
|
||||
/// beets bar: it was replaced before 85 % of it was heard. A track that was
|
||||
/// replaced at 90 % is a play that happened to end early, not a skip.
|
||||
pub fn is_skip(end_reason: Option<EndReason>, played_ms: u64, duration_ms: u64) -> bool {
|
||||
end_reason == Some(EndReason::Skip) && (played_ms as f64) < 0.85 * duration_ms as f64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn end_reason_round_trips_every_variant() {
|
||||
for (reason, word) in END_REASONS {
|
||||
assert_eq!(reason.as_str(), *word);
|
||||
assert_eq!(EndReason::parse(word), Some(*reason));
|
||||
}
|
||||
assert_eq!(EndReason::parse("nope"), None);
|
||||
assert_eq!(END_REASONS.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_play_thirty_second_floor() {
|
||||
// 30 s exactly is NOT longer than 30 s.
|
||||
assert!(!is_play(30_000, 30_000));
|
||||
// 30.001 s: half is 15 s (integer ms).
|
||||
assert!(is_play(15_000, 30_001));
|
||||
assert!(!is_play(14_999, 30_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_play_half_or_four_minutes() {
|
||||
// 100 s track: half = 50 s.
|
||||
assert!(is_play(50_000, 100_000));
|
||||
assert!(!is_play(49_999, 100_000));
|
||||
// 10 min track: half = 300 s, capped at 240 s.
|
||||
assert!(is_play(240_000, 600_000));
|
||||
assert!(!is_play(239_999, 600_000));
|
||||
// Exactly 480 s: half == cap.
|
||||
assert!(is_play(240_000, 480_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_play_unknown_duration_never_counts() {
|
||||
assert!(!is_play(1_000_000, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_skip_needs_skip_reason_and_under_85_percent() {
|
||||
let d = 200_000;
|
||||
assert!(is_skip(Some(EndReason::Skip), 10_000, d));
|
||||
assert!(is_skip(Some(EndReason::Skip), 169_999, d));
|
||||
// 85 % exactly is not a skip.
|
||||
assert!(!is_skip(Some(EndReason::Skip), 170_000, d));
|
||||
assert!(!is_skip(Some(EndReason::Natural), 10_000, d));
|
||||
assert!(!is_skip(Some(EndReason::Stop), 10_000, d));
|
||||
assert!(!is_skip(None, 10_000, d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_skip_zero_duration_is_never_a_skip() {
|
||||
// 0 < 0.85 * 0 is false: an unknown-duration row cannot be a skip.
|
||||
assert!(!is_skip(Some(EndReason::Skip), 0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//! The per-user listen-event store: `<user_dir>/listen/listen_log.db`.
|
||||
//!
|
||||
//! One row per listen EVENT (PK = event, never the track — the Tempo trap:
|
||||
//! a track-keyed table with REPLACE can only ever count to one). Counters
|
||||
//! are derived by readers, never written here.
|
||||
//!
|
||||
//! Synchronous `rusqlite`; the async facade (`logger.rs`) wraps every call
|
||||
//! in `spawn_blocking`. WAL + `synchronous=NORMAL` (ADR-002).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use super::rules::EndReason;
|
||||
use super::tracker::{Closed, Flush, ListenMeta};
|
||||
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// Sub-directory + file under the user dir.
|
||||
pub const DB_SUBDIR: &str = "listen";
|
||||
pub const DB_FILE: &str = "listen_log.db";
|
||||
|
||||
pub struct ListenStore {
|
||||
conn: Connection,
|
||||
#[allow(dead_code)]
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// One persisted row, as readers see it (tests + the developer query).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ListenRow {
|
||||
pub id: i64,
|
||||
pub app_session_id: String,
|
||||
pub origin_id: String,
|
||||
pub source: String,
|
||||
pub source_item_id: String,
|
||||
pub track_id: Option<i64>,
|
||||
pub isrc: Option<String>,
|
||||
pub recording_mbid: Option<String>,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub duration_ms: u64,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub played_ms: u64,
|
||||
pub end_position_ms: Option<u64>,
|
||||
pub end_reason: Option<EndReason>,
|
||||
pub context_kind: String,
|
||||
pub context_id: String,
|
||||
}
|
||||
|
||||
impl ListenStore {
|
||||
/// Open (or create) the store under `user_dir`.
|
||||
pub fn open_at(user_dir: &Path) -> Result<Self, String> {
|
||||
let dir = user_dir.join(DB_SUBDIR);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create listen dir: {e}"))?;
|
||||
let path = dir.join(DB_FILE);
|
||||
let conn =
|
||||
Connection::open(&path).map_err(|e| format!("Failed to open listen log: {e}"))?;
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=2000;",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set listen log pragmas: {e}"))?;
|
||||
let store = Self {
|
||||
conn,
|
||||
path: Some(path),
|
||||
};
|
||||
store.migrate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Self, String> {
|
||||
let conn = Connection::open_in_memory()
|
||||
.map_err(|e| format!("Failed to open in-memory listen log: {e}"))?;
|
||||
let store = Self { conn, path: None };
|
||||
store.migrate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Idempotent: creates the schema on a fresh file and records
|
||||
/// `schema_version`; a later version adds its `ALTER`s behind a version
|
||||
/// check here (a table is only new once — see `playlist_play_history`).
|
||||
fn migrate(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS listen_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS listen_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_session_id TEXT NOT NULL,
|
||||
origin_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
source_item_id TEXT NOT NULL,
|
||||
track_id INTEGER,
|
||||
album_id TEXT,
|
||||
artist_id TEXT,
|
||||
isrc TEXT,
|
||||
recording_mbid TEXT,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
album TEXT,
|
||||
album_artist TEXT,
|
||||
artwork_key TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
played_ms INTEGER NOT NULL DEFAULT 0,
|
||||
end_position_ms INTEGER,
|
||||
end_reason TEXT,
|
||||
context_kind TEXT NOT NULL DEFAULT '',
|
||||
context_id TEXT NOT NULL DEFAULT '',
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
output_backend TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_le_started ON listen_events(started_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_le_item ON listen_events(source, source_item_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_le_isrc ON listen_events(isrc) WHERE isrc IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS ix_le_open ON listen_events(id) WHERE end_reason IS NULL;
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to create listen log schema: {e}"))?;
|
||||
|
||||
let current = self
|
||||
.meta("schema_version")?
|
||||
.and_then(|v| v.parse::<i64>().ok());
|
||||
match current {
|
||||
None => self.set_meta("schema_version", &SCHEMA_VERSION.to_string())?,
|
||||
Some(v) if v > SCHEMA_VERSION => {
|
||||
return Err(format!(
|
||||
"listen log schema {v} is newer than this build ({SCHEMA_VERSION})"
|
||||
));
|
||||
}
|
||||
// Future: `Some(v) if v < SCHEMA_VERSION` → ALTERs, then bump.
|
||||
Some(_) => {}
|
||||
}
|
||||
// Defaults that must exist from day one so readers never special-case
|
||||
// an absent key: unlimited retention (owner decision §10.4).
|
||||
if self.meta("retention_days")?.is_none() {
|
||||
self.set_meta("retention_days", "0")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----- meta -----------------------------------------------------------
|
||||
|
||||
pub fn meta(&self, key: &str) -> Result<Option<String>, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT value FROM listen_meta WHERE key = ?1",
|
||||
params![key],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|v| v.flatten())
|
||||
.map_err(|e| format!("Failed to read listen meta {key}: {e}"))
|
||||
}
|
||||
|
||||
pub fn set_meta(&self, key: &str, value: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO listen_meta (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to write listen meta {key}: {e}"))
|
||||
}
|
||||
|
||||
pub fn schema_version(&self) -> Result<i64, String> {
|
||||
Ok(self
|
||||
.meta("schema_version")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The install's origin id, generated once and kept in `listen_meta`.
|
||||
/// `qbzd` passes its own fixed id instead (`qbzd:<host>`).
|
||||
pub fn origin_id_or_init(&self, generate: impl FnOnce() -> String) -> Result<String, String> {
|
||||
if let Some(id) = self.meta("origin_id")? {
|
||||
return Ok(id);
|
||||
}
|
||||
let id = generate();
|
||||
self.set_meta("origin_id", &id)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// `paused = 1` means "Listening history" is OFF: nothing is written
|
||||
/// (and nothing is deleted).
|
||||
pub fn is_paused(&self) -> Result<bool, String> {
|
||||
Ok(self.meta("paused")?.as_deref() == Some("1"))
|
||||
}
|
||||
|
||||
pub fn set_paused(&self, paused: bool) -> Result<(), String> {
|
||||
self.set_meta("paused", if paused { "1" } else { "0" })
|
||||
}
|
||||
|
||||
pub fn retention_days(&self) -> Result<u32, String> {
|
||||
Ok(self
|
||||
.meta("retention_days")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
// ----- events ---------------------------------------------------------
|
||||
|
||||
/// Insert an open row; returns its id.
|
||||
pub fn open_event(
|
||||
&self,
|
||||
app_session_id: &str,
|
||||
origin_id: &str,
|
||||
meta: &ListenMeta,
|
||||
started_at: i64,
|
||||
) -> Result<i64, String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO listen_events (
|
||||
app_session_id, origin_id, source, source_item_id, track_id,
|
||||
album_id, artist_id, isrc, recording_mbid,
|
||||
title, artist, album, album_artist, artwork_key,
|
||||
duration_ms, started_at, context_kind, context_id,
|
||||
bit_depth, sample_rate, output_backend
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)",
|
||||
params![
|
||||
app_session_id,
|
||||
origin_id,
|
||||
meta.source,
|
||||
meta.source_item_id,
|
||||
meta.track_id,
|
||||
meta.album_id,
|
||||
meta.artist_id,
|
||||
meta.isrc,
|
||||
meta.recording_mbid,
|
||||
meta.title,
|
||||
meta.artist,
|
||||
meta.album,
|
||||
meta.album_artist,
|
||||
meta.artwork_key,
|
||||
meta.duration_ms as i64,
|
||||
started_at,
|
||||
meta.context_kind,
|
||||
meta.context_id,
|
||||
meta.bit_depth,
|
||||
meta.sample_rate,
|
||||
meta.output_backend,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to open listen event: {e}"))?;
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Persist the accumulator of an OPEN row (a crash then loses at most
|
||||
/// one flush interval). Never touches a closed row.
|
||||
pub fn flush_progress(&self, f: &Flush) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE listen_events SET played_ms = ?1, end_position_ms = ?2
|
||||
WHERE id = ?3 AND end_reason IS NULL",
|
||||
params![
|
||||
f.played_ms as i64,
|
||||
f.end_position_ms.map(|v| v as i64),
|
||||
f.event_id
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to flush listen progress: {e}"))
|
||||
}
|
||||
|
||||
/// Close a row. Idempotent on an already-closed row (no-op).
|
||||
pub fn close_event(&self, c: &Closed) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE listen_events
|
||||
SET ended_at = ?1, played_ms = ?2, end_position_ms = ?3, end_reason = ?4
|
||||
WHERE id = ?5 AND end_reason IS NULL",
|
||||
params![
|
||||
c.ended_at,
|
||||
c.played_ms as i64,
|
||||
c.end_position_ms.map(|v| v as i64),
|
||||
c.reason.as_str(),
|
||||
c.event_id
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to close listen event: {e}"))
|
||||
}
|
||||
|
||||
/// Rows left open by a crash (or a kill) close as `shutdown` with the
|
||||
/// `played_ms` that reached disk, at startup. `ended_at` is unknowable —
|
||||
/// it is set to `started_at + played_ms` as the best lower bound.
|
||||
pub fn close_orphans_as_shutdown(&self) -> Result<usize, String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE listen_events
|
||||
SET end_reason = ?1, ended_at = started_at + (played_ms / 1000)
|
||||
WHERE end_reason IS NULL",
|
||||
params![EndReason::Shutdown.as_str()],
|
||||
)
|
||||
.map_err(|e| format!("Failed to close orphan listen events: {e}"))
|
||||
}
|
||||
|
||||
/// "Clear listening history": every event goes, the file is compacted.
|
||||
/// `listen_meta` (origin id, paused flag, retention) is kept.
|
||||
pub fn clear(&self) -> Result<(), String> {
|
||||
// Under WAL the vacuumed image lands in the -wal file first; the
|
||||
// TRUNCATE checkpoint is what makes the main file actually shrink
|
||||
// (otherwise "Clear" leaves the old size on disk until a later
|
||||
// checkpoint happens to run).
|
||||
self.conn
|
||||
.execute_batch("DELETE FROM listen_events; VACUUM; PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
.map_err(|e| format!("Failed to clear listen log: {e}"))
|
||||
}
|
||||
|
||||
pub fn count(&self) -> Result<u64, String> {
|
||||
self.conn
|
||||
.query_row("SELECT COUNT(*) FROM listen_events", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.map(|n| n as u64)
|
||||
.map_err(|e| format!("Failed to count listen events: {e}"))
|
||||
}
|
||||
|
||||
/// Every row in id order (tests + developer inspection; never a rail).
|
||||
pub fn rows(&self) -> Result<Vec<ListenRow>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT id, app_session_id, origin_id, source, source_item_id, track_id,
|
||||
isrc, recording_mbid, title, artist, duration_ms, started_at,
|
||||
ended_at, played_ms, end_position_ms, end_reason,
|
||||
context_kind, context_id
|
||||
FROM listen_events ORDER BY id",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare listen query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ListenRow {
|
||||
id: row.get(0)?,
|
||||
app_session_id: row.get(1)?,
|
||||
origin_id: row.get(2)?,
|
||||
source: row.get(3)?,
|
||||
source_item_id: row.get(4)?,
|
||||
track_id: row.get(5)?,
|
||||
isrc: row.get(6)?,
|
||||
recording_mbid: row.get(7)?,
|
||||
title: row.get(8)?,
|
||||
artist: row.get(9)?,
|
||||
duration_ms: row.get::<_, i64>(10)? as u64,
|
||||
started_at: row.get(11)?,
|
||||
ended_at: row.get(12)?,
|
||||
played_ms: row.get::<_, i64>(13)? as u64,
|
||||
end_position_ms: row.get::<_, Option<i64>>(14)?.map(|v| v as u64),
|
||||
end_reason: row
|
||||
.get::<_, Option<String>>(15)?
|
||||
.as_deref()
|
||||
.and_then(EndReason::parse),
|
||||
context_kind: row.get(16)?,
|
||||
context_id: row.get(17)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query listen events: {e}"))?;
|
||||
Ok(rows.flatten().collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta() -> ListenMeta {
|
||||
ListenMeta {
|
||||
source: "qobuz".into(),
|
||||
source_item_id: "42".into(),
|
||||
track_id: Some(42),
|
||||
title: "Song".into(),
|
||||
artist: "Band".into(),
|
||||
album: Some("LP".into()),
|
||||
duration_ms: 200_000,
|
||||
context_kind: "album".into(),
|
||||
context_id: "abc".into(),
|
||||
isrc: Some("USRC17607839".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_version_is_recorded_and_reopen_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
{
|
||||
let s = ListenStore::open_at(dir.path()).unwrap();
|
||||
assert_eq!(s.schema_version().unwrap(), SCHEMA_VERSION);
|
||||
assert_eq!(s.retention_days().unwrap(), 0);
|
||||
assert!(!s.is_paused().unwrap());
|
||||
}
|
||||
let s = ListenStore::open_at(dir.path()).unwrap();
|
||||
assert_eq!(s.schema_version().unwrap(), SCHEMA_VERSION);
|
||||
assert!(dir.path().join(DB_SUBDIR).join(DB_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_schema_refuses_to_open() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
{
|
||||
let s = ListenStore::open_at(dir.path()).unwrap();
|
||||
s.set_meta("schema_version", &(SCHEMA_VERSION + 1).to_string())
|
||||
.unwrap();
|
||||
}
|
||||
assert!(ListenStore::open_at(dir.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_id_is_generated_once() {
|
||||
let s = ListenStore::open_in_memory().unwrap();
|
||||
let a = s.origin_id_or_init(|| "first".into()).unwrap();
|
||||
let b = s.origin_id_or_init(|| "second".into()).unwrap();
|
||||
assert_eq!(a, "first");
|
||||
assert_eq!(b, "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_flush_close_round_trip() {
|
||||
let s = ListenStore::open_in_memory().unwrap();
|
||||
let id = s.open_event("sess", "origin", &meta(), 1_000).unwrap();
|
||||
s.flush_progress(&Flush {
|
||||
event_id: id,
|
||||
played_ms: 10_000,
|
||||
end_position_ms: Some(10_000),
|
||||
})
|
||||
.unwrap();
|
||||
let row = &s.rows().unwrap()[0];
|
||||
assert_eq!(row.played_ms, 10_000);
|
||||
assert_eq!(row.end_reason, None);
|
||||
assert_eq!(row.isrc.as_deref(), Some("USRC17607839"));
|
||||
assert_eq!(row.context_kind, "album");
|
||||
s.close_event(&Closed {
|
||||
event_id: id,
|
||||
reason: EndReason::Skip,
|
||||
played_ms: 12_000,
|
||||
end_position_ms: Some(12_000),
|
||||
ended_at: 1_012,
|
||||
})
|
||||
.unwrap();
|
||||
let row = &s.rows().unwrap()[0];
|
||||
assert_eq!(row.end_reason, Some(EndReason::Skip));
|
||||
assert_eq!(row.played_ms, 12_000);
|
||||
assert_eq!(row.ended_at, Some(1_012));
|
||||
// A late flush must not reopen or alter a closed row.
|
||||
s.flush_progress(&Flush {
|
||||
event_id: id,
|
||||
played_ms: 99_000,
|
||||
end_position_ms: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.rows().unwrap()[0].played_ms, 12_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphans_close_as_shutdown_with_the_flushed_progress() {
|
||||
let s = ListenStore::open_in_memory().unwrap();
|
||||
let a = s.open_event("sess", "o", &meta(), 1_000).unwrap();
|
||||
s.flush_progress(&Flush {
|
||||
event_id: a,
|
||||
played_ms: 30_000,
|
||||
end_position_ms: Some(30_000),
|
||||
})
|
||||
.unwrap();
|
||||
let b = s.open_event("sess", "o", &meta(), 2_000).unwrap();
|
||||
s.close_event(&Closed {
|
||||
event_id: b,
|
||||
reason: EndReason::Natural,
|
||||
played_ms: 200_000,
|
||||
end_position_ms: Some(200_000),
|
||||
ended_at: 2_200,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.close_orphans_as_shutdown().unwrap(), 1);
|
||||
let rows = s.rows().unwrap();
|
||||
assert_eq!(rows[0].end_reason, Some(EndReason::Shutdown));
|
||||
assert_eq!(rows[0].played_ms, 30_000);
|
||||
assert_eq!(rows[0].ended_at, Some(1_030));
|
||||
assert_eq!(rows[1].end_reason, Some(EndReason::Natural));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_events_and_keeps_meta() {
|
||||
let s = ListenStore::open_in_memory().unwrap();
|
||||
s.set_paused(false).unwrap();
|
||||
let _ = s.origin_id_or_init(|| "keep-me".into()).unwrap();
|
||||
s.open_event("sess", "o", &meta(), 1).unwrap();
|
||||
s.open_event("sess", "o", &meta(), 2).unwrap();
|
||||
assert_eq!(s.count().unwrap(), 2);
|
||||
s.clear().unwrap();
|
||||
assert_eq!(s.count().unwrap(), 0);
|
||||
assert_eq!(s.meta("origin_id").unwrap().as_deref(), Some("keep-me"));
|
||||
assert_eq!(s.schema_version().unwrap(), SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vacuum_compacts_the_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let s = ListenStore::open_at(dir.path()).unwrap();
|
||||
for i in 0..2_000 {
|
||||
s.open_event("sess", "o", &meta(), i).unwrap();
|
||||
}
|
||||
let path = dir.path().join(DB_SUBDIR).join(DB_FILE);
|
||||
// Checkpoint so the rows are in the main file, not only the WAL.
|
||||
s.conn
|
||||
.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
.unwrap();
|
||||
let before = std::fs::metadata(&path).unwrap().len();
|
||||
s.clear().unwrap();
|
||||
let after = std::fs::metadata(&path).unwrap().len();
|
||||
assert!(
|
||||
after < before,
|
||||
"vacuum did not shrink the file ({before} -> {after})"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! The listen tracker: a pure state machine over playback observations.
|
||||
//!
|
||||
//! No clock, no I/O — every input carries its own `now`, and every output is
|
||||
//! a value the caller persists. That is what makes the rules testable and
|
||||
//! what keeps the two hosts (the Qt poll loop and the `qbzd` event bus)
|
||||
//! from drifting: both feed the same machine.
|
||||
//!
|
||||
//! The one rule that matters (Codex §9, Feishin): `played_ms` accumulates
|
||||
//! ONLY while playing, from position deltas that are monotonic and at most
|
||||
//! [`MAX_DELTA_MS`]. A pause adds nothing; a seek (forward or back) is a
|
||||
//! delta outside that window and adds nothing; a stall that skips reporting
|
||||
//! for a while adds nothing. Wall-clock and the final position are never
|
||||
//! consulted — pauses, seeks, repeat and the old scrobble bug all made them
|
||||
//! lie.
|
||||
|
||||
use super::rules::EndReason;
|
||||
|
||||
/// A position jump larger than this is a seek or a stall, not audible time.
|
||||
/// The hosts observe at 1 Hz, so a genuine tick is ~1 s.
|
||||
pub const MAX_DELTA_MS: u64 = 5_000;
|
||||
|
||||
/// Persist the accumulator at least this often (in audible ms) so a crash
|
||||
/// loses at most this much of the open row.
|
||||
pub const FLUSH_EVERY_MS: u64 = 10_000;
|
||||
|
||||
/// Snapshot of the track a row is opened for. Text is captured here on
|
||||
/// purpose (Astra): the row must still make sense after the track leaves
|
||||
/// Qobuz or the Plex server it came from is gone.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ListenMeta {
|
||||
pub source: String,
|
||||
pub source_item_id: String,
|
||||
pub track_id: Option<i64>,
|
||||
pub album_id: Option<String>,
|
||||
pub artist_id: Option<String>,
|
||||
pub isrc: Option<String>,
|
||||
pub recording_mbid: Option<String>,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub artwork_key: Option<String>,
|
||||
pub duration_ms: u64,
|
||||
pub context_kind: String,
|
||||
pub context_id: String,
|
||||
pub bit_depth: Option<u32>,
|
||||
pub sample_rate: Option<u32>,
|
||||
pub output_backend: Option<String>,
|
||||
}
|
||||
|
||||
/// The open row, as the machine sees it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OpenListen {
|
||||
/// Store row id, set by the host once the row is inserted.
|
||||
pub event_id: i64,
|
||||
pub meta: ListenMeta,
|
||||
pub started_at: i64,
|
||||
pub played_ms: u64,
|
||||
/// Last position observed, whatever the play state.
|
||||
pub last_position_ms: Option<u64>,
|
||||
/// `played_ms` at the last flush the host acknowledged.
|
||||
flushed_ms: u64,
|
||||
}
|
||||
|
||||
/// A row to close, with everything the store needs.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Closed {
|
||||
pub event_id: i64,
|
||||
pub reason: EndReason,
|
||||
pub played_ms: u64,
|
||||
pub end_position_ms: Option<u64>,
|
||||
pub ended_at: i64,
|
||||
}
|
||||
|
||||
/// A progress checkpoint the host should persist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Flush {
|
||||
pub event_id: i64,
|
||||
pub played_ms: u64,
|
||||
pub end_position_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ListenTracker {
|
||||
open: Option<OpenListen>,
|
||||
}
|
||||
|
||||
impl ListenTracker {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn open(&self) -> Option<&OpenListen> {
|
||||
self.open.as_ref()
|
||||
}
|
||||
|
||||
/// A new track surfaced. Closes the previous row (if any) as a SKIP —
|
||||
/// unless the host already closed it as natural — and returns the meta
|
||||
/// the host must insert; call [`Self::opened`] with the row id.
|
||||
pub fn track_started(&mut self, meta: ListenMeta, now: i64) -> (Option<Closed>, ListenMeta) {
|
||||
let closed = self.close_with(EndReason::Skip, now);
|
||||
self.open = Some(OpenListen {
|
||||
event_id: 0,
|
||||
meta: meta.clone(),
|
||||
started_at: now,
|
||||
played_ms: 0,
|
||||
last_position_ms: None,
|
||||
flushed_ms: 0,
|
||||
});
|
||||
(closed, meta)
|
||||
}
|
||||
|
||||
/// The host inserted the row for the track from the last
|
||||
/// [`Self::track_started`]; from now on progress has somewhere to go.
|
||||
pub fn opened(&mut self, event_id: i64) {
|
||||
if let Some(open) = self.open.as_mut() {
|
||||
open.event_id = event_id;
|
||||
}
|
||||
}
|
||||
|
||||
/// One playback observation. Returns a flush when the accumulator has
|
||||
/// advanced by [`FLUSH_EVERY_MS`] since the last one.
|
||||
pub fn tick(&mut self, position_ms: u64, playing: bool) -> Option<Flush> {
|
||||
let open = self.open.as_mut()?;
|
||||
if let (true, Some(last)) = (playing, open.last_position_ms) {
|
||||
if position_ms > last {
|
||||
let delta = position_ms - last;
|
||||
if delta <= MAX_DELTA_MS {
|
||||
open.played_ms += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
open.last_position_ms = Some(position_ms);
|
||||
if open.event_id != 0 && open.played_ms.saturating_sub(open.flushed_ms) >= FLUSH_EVERY_MS {
|
||||
open.flushed_ms = open.played_ms;
|
||||
return Some(Flush {
|
||||
event_id: open.event_id,
|
||||
played_ms: open.played_ms,
|
||||
end_position_ms: open.last_position_ms,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The host's own natural-end predicate fired.
|
||||
pub fn ended_naturally(&mut self, now: i64) -> Option<Closed> {
|
||||
self.close_with(EndReason::Natural, now)
|
||||
}
|
||||
|
||||
/// Playback stopped with nothing following. A host without an explicit
|
||||
/// natural-end edge (qbzd) passes `infer_natural = true`: a stop whose
|
||||
/// last position sat within 2 s of the duration is the track ending.
|
||||
pub fn stopped(&mut self, now: i64, infer_natural: bool) -> Option<Closed> {
|
||||
let reason = if infer_natural && self.near_end() {
|
||||
EndReason::Natural
|
||||
} else {
|
||||
EndReason::Stop
|
||||
};
|
||||
self.close_with(reason, now)
|
||||
}
|
||||
|
||||
pub fn errored(&mut self, now: i64) -> Option<Closed> {
|
||||
self.close_with(EndReason::Error, now)
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self, now: i64) -> Option<Closed> {
|
||||
self.close_with(EndReason::Shutdown, now)
|
||||
}
|
||||
|
||||
/// Playback authority moved to another renderer. The owner row is closed
|
||||
/// explicitly and no delegated playback is folded into it. Calling this
|
||||
/// more than once is harmless: after the first close there is no open row.
|
||||
pub fn handoff(&mut self, now: i64) -> Option<Closed> {
|
||||
self.close_with(EndReason::Handoff, now)
|
||||
}
|
||||
|
||||
/// For hosts that only see "a different track started": was the previous
|
||||
/// one within 2 s of its end? Mirrors the desktop's
|
||||
/// `seen_position + 2 >= duration` (playback_driver.rs:226-264).
|
||||
pub fn near_end(&self) -> bool {
|
||||
let Some(open) = self.open.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let duration = open.meta.duration_ms;
|
||||
match open.last_position_ms {
|
||||
Some(pos) if duration > 0 => pos + 2_000 >= duration,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Close with an inferred reason: natural when near the end, else skip.
|
||||
/// The qbzd bus has no TrackEnded (nobody emits it), so this is its
|
||||
/// track-edge close.
|
||||
pub fn track_started_inferring_end(
|
||||
&mut self,
|
||||
meta: ListenMeta,
|
||||
now: i64,
|
||||
) -> (Option<Closed>, ListenMeta) {
|
||||
let reason = if self.near_end() {
|
||||
EndReason::Natural
|
||||
} else {
|
||||
EndReason::Skip
|
||||
};
|
||||
let closed = self.close_with(reason, now);
|
||||
self.open = Some(OpenListen {
|
||||
event_id: 0,
|
||||
meta: meta.clone(),
|
||||
started_at: now,
|
||||
played_ms: 0,
|
||||
last_position_ms: None,
|
||||
flushed_ms: 0,
|
||||
});
|
||||
(closed, meta)
|
||||
}
|
||||
|
||||
fn close_with(&mut self, reason: EndReason, now: i64) -> Option<Closed> {
|
||||
let open = self.open.take()?;
|
||||
// A row the host never managed to insert has nothing to close.
|
||||
if open.event_id == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Closed {
|
||||
event_id: open.event_id,
|
||||
reason,
|
||||
played_ms: open.played_ms,
|
||||
end_position_ms: open.last_position_ms,
|
||||
ended_at: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta(duration_ms: u64) -> ListenMeta {
|
||||
ListenMeta {
|
||||
source: "qobuz".into(),
|
||||
source_item_id: "1".into(),
|
||||
title: "T".into(),
|
||||
artist: "A".into(),
|
||||
duration_ms,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn started(t: &mut ListenTracker, id: i64) {
|
||||
let (_, _) = t.track_started(meta(200_000), 1_000);
|
||||
t.opened(id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulates_only_monotonic_small_deltas_while_playing() {
|
||||
let mut t = ListenTracker::new();
|
||||
started(&mut t, 7);
|
||||
assert_eq!(t.tick(0, true), None); // first observation: no delta
|
||||
t.tick(1_000, true);
|
||||
t.tick(2_000, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 2_000);
|
||||
// Seek forward 60 s: ignored, but the position is remembered.
|
||||
t.tick(62_000, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 2_000);
|
||||
t.tick(63_000, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 3_000);
|
||||
// Seek back: negative delta ignored.
|
||||
t.tick(10_000, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 3_000);
|
||||
t.tick(11_000, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 4_000);
|
||||
// Exactly MAX_DELTA counts; one more does not.
|
||||
t.tick(16_000, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 9_000);
|
||||
t.tick(21_001, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 9_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_ticks_add_nothing_and_survive_the_pause() {
|
||||
let mut t = ListenTracker::new();
|
||||
started(&mut t, 7);
|
||||
t.tick(0, true);
|
||||
t.tick(1_000, true);
|
||||
// Two minutes paused at the same position.
|
||||
for _ in 0..120 {
|
||||
t.tick(1_000, false);
|
||||
}
|
||||
assert_eq!(t.open().unwrap().played_ms, 1_000);
|
||||
// Resume: the next delta is 1 s again.
|
||||
t.tick(2_000, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 2_000);
|
||||
// A paused tick whose position moved (a seek while paused) counts nothing.
|
||||
t.tick(50_000, false);
|
||||
t.tick(51_000, true);
|
||||
assert_eq!(t.open().unwrap().played_ms, 3_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flushes_every_ten_audible_seconds() {
|
||||
let mut t = ListenTracker::new();
|
||||
started(&mut t, 7);
|
||||
t.tick(0, true);
|
||||
let mut flushes = Vec::new();
|
||||
for s in 1..=25u64 {
|
||||
if let Some(f) = t.tick(s * 1_000, true) {
|
||||
flushes.push(f);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
flushes,
|
||||
vec![
|
||||
Flush {
|
||||
event_id: 7,
|
||||
played_ms: 10_000,
|
||||
end_position_ms: Some(10_000)
|
||||
},
|
||||
Flush {
|
||||
event_id: 7,
|
||||
played_ms: 20_000,
|
||||
end_position_ms: Some(20_000)
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_track_closes_the_previous_as_skip_with_its_accumulator() {
|
||||
let mut t = ListenTracker::new();
|
||||
started(&mut t, 7);
|
||||
t.tick(0, true);
|
||||
for s in 1..=10u64 {
|
||||
t.tick(s * 1_000, true);
|
||||
}
|
||||
let (closed, next) = t.track_started(meta(100_000), 5_000);
|
||||
assert_eq!(
|
||||
closed,
|
||||
Some(Closed {
|
||||
event_id: 7,
|
||||
reason: EndReason::Skip,
|
||||
played_ms: 10_000,
|
||||
end_position_ms: Some(10_000),
|
||||
ended_at: 5_000,
|
||||
})
|
||||
);
|
||||
assert_eq!(next.duration_ms, 100_000);
|
||||
assert_eq!(t.open().unwrap().event_id, 0);
|
||||
t.opened(8);
|
||||
assert_eq!(t.open().unwrap().event_id, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_end_then_next_track_opens_without_a_second_close() {
|
||||
let mut t = ListenTracker::new();
|
||||
started(&mut t, 7);
|
||||
t.tick(0, true);
|
||||
t.tick(199_000, true);
|
||||
let closed = t.ended_naturally(9_000).unwrap();
|
||||
assert_eq!(closed.reason, EndReason::Natural);
|
||||
let (closed_again, _) = t.track_started(meta(100_000), 9_001);
|
||||
assert_eq!(closed_again, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_shutdown_error_handoff_reasons() {
|
||||
let mut t = ListenTracker::new();
|
||||
started(&mut t, 1);
|
||||
assert_eq!(t.stopped(2, false).unwrap().reason, EndReason::Stop);
|
||||
started(&mut t, 2);
|
||||
assert_eq!(t.shutdown(3).unwrap().reason, EndReason::Shutdown);
|
||||
started(&mut t, 3);
|
||||
assert_eq!(t.errored(4).unwrap().reason, EndReason::Error);
|
||||
started(&mut t, 4);
|
||||
assert_eq!(t.handoff(5).unwrap().reason, EndReason::Handoff);
|
||||
assert_eq!(t.handoff(6), None, "handoff is idempotent");
|
||||
assert_eq!(t.shutdown(7), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_end_uses_the_two_second_window() {
|
||||
let mut t = ListenTracker::new();
|
||||
started(&mut t, 1);
|
||||
t.tick(0, true);
|
||||
t.tick(198_000, true);
|
||||
assert!(t.near_end());
|
||||
let (closed, _) = t.track_started_inferring_end(meta(50_000), 9);
|
||||
assert_eq!(closed.unwrap().reason, EndReason::Natural);
|
||||
t.opened(2);
|
||||
t.tick(0, true);
|
||||
t.tick(10_000, true);
|
||||
assert!(!t.near_end());
|
||||
assert_eq!(t.stopped(10, true).unwrap().reason, EndReason::Stop);
|
||||
started(&mut t, 3);
|
||||
t.tick(198_500, true);
|
||||
assert_eq!(t.stopped(11, true).unwrap().reason, EndReason::Natural);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_that_was_never_inserted_closes_to_nothing() {
|
||||
let mut t = ListenTracker::new();
|
||||
let _ = t.track_started(meta(1), 1);
|
||||
// no `opened` call: the insert failed or paused
|
||||
t.tick(0, true);
|
||||
t.tick(1_000, true);
|
||||
assert_eq!(t.tick(11_000, true), None);
|
||||
assert_eq!(t.ended_naturally(2), None);
|
||||
assert!(t.open().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Memory-pressure watchdog.
|
||||
//!
|
||||
//! Every 15 s, reads the host's memory pressure from `qbz-core`'s
|
||||
//! `system_capabilities` and reacts:
|
||||
//!
|
||||
//! * `is_critical` (< 5 % available) — evict the L1 in-memory audio cache
|
||||
//! (the L2 disk files stay; disk is not the scarce resource) and latch
|
||||
//! [`prefetch_halted`] so no new prefetch downloads start.
|
||||
//! * `is_low` (< 15 % available) — pause HiRes prefetch for that tick via
|
||||
//! [`hires_prefetch_paused`]; a normal-lossless prefetch is ~4x smaller.
|
||||
//! * healthy again — clear both latches and log the recovery at debug.
|
||||
//! There is deliberately no fancier unlatching logic.
|
||||
//!
|
||||
//! Both frontends (`qbz`, `qbzd`) spawn the loop once at startup with the
|
||||
//! shared [`Player`] handle; the prefetch path in
|
||||
//! [`crate::playback_driver`] consults the latches before starting work.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use qbz_player::Player;
|
||||
|
||||
/// How often the watchdog samples /proc/meminfo.
|
||||
const TICK_SECS: u64 = 15;
|
||||
|
||||
/// Latched on critical pressure: no new prefetch downloads may start.
|
||||
static PREFETCH_HALTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Set on low/critical pressure ticks: prefetch downgrades HiRes to
|
||||
/// Lossless. Follows the latest tick rather than latching.
|
||||
static HIRES_PREFETCH_PAUSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// True while the watchdog has halted prefetch downloads (critical
|
||||
/// pressure). Checked by the prefetch path before starting any download.
|
||||
pub fn prefetch_halted() -> bool {
|
||||
PREFETCH_HALTED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// True when the latest watchdog tick saw low (or critical) memory
|
||||
/// pressure — prefetch should stay at Lossless this round.
|
||||
pub fn hires_prefetch_paused() -> bool {
|
||||
HIRES_PREFETCH_PAUSED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Spawn the watchdog loop on the current tokio runtime. Runs for the
|
||||
/// process lifetime; the returned handle lets a caller with an ordered
|
||||
/// shutdown abort it explicitly.
|
||||
pub fn spawn(player: Arc<Player>) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(TICK_SECS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// None where no memory probe exists (/proc/meminfo on unix,
|
||||
// GlobalMemoryStatusEx on Windows) — nothing to do.
|
||||
let Some(pressure) = qbz_core::system_capabilities::read_memory_pressure() else {
|
||||
continue;
|
||||
};
|
||||
if pressure.is_critical {
|
||||
log::warn!(
|
||||
"[memwatch] critical memory pressure: {:.1}% available ({} MB of {} MB) — evicting L1 audio cache, halting prefetch",
|
||||
pressure.available_pct,
|
||||
pressure.mem_available_kb / 1024,
|
||||
pressure.mem_total_kb / 1024,
|
||||
);
|
||||
player.evict_l1_audio_cache();
|
||||
PREFETCH_HALTED.store(true, Ordering::Relaxed);
|
||||
HIRES_PREFETCH_PAUSED.store(true, Ordering::Relaxed);
|
||||
} else if pressure.is_low {
|
||||
HIRES_PREFETCH_PAUSED.store(true, Ordering::Relaxed);
|
||||
log::debug!(
|
||||
"[memwatch] low memory pressure: {:.1}% available — hires prefetch paused this tick",
|
||||
pressure.available_pct,
|
||||
);
|
||||
} else {
|
||||
let was_halted = PREFETCH_HALTED.swap(false, Ordering::Relaxed);
|
||||
let was_paused = HIRES_PREFETCH_PAUSED.swap(false, Ordering::Relaxed);
|
||||
if was_halted || was_paused {
|
||||
log::debug!(
|
||||
"[memwatch] memory pressure recovered ({:.1}% available) — prefetch re-enabled",
|
||||
pressure.available_pct,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -86,7 +86,11 @@ const PROBES: &[ProbeEndpoint] = &[
|
||||
expect: ProbeExpect::Status204,
|
||||
},
|
||||
ProbeEndpoint {
|
||||
url: "https://www.msftconnecttest.com/connecttest.txt",
|
||||
// Microsoft NCSI defines this probe as plain HTTP. HTTPS is not a
|
||||
// supported equivalent: the CDN can answer with an Akamai certificate
|
||||
// that is not valid for www.msftconnecttest.com, producing a rustls
|
||||
// error before the strict body check can run.
|
||||
url: "http://www.msftconnecttest.com/connecttest.txt",
|
||||
expect: ProbeExpect::BodyContains("Microsoft Connect Test"),
|
||||
},
|
||||
];
|
||||
@@ -595,4 +599,21 @@ mod tests {
|
||||
assert!(!ipv6_has_default_route(non_default));
|
||||
assert!(!ipv6_has_default_route(lo_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn microsoft_ncsi_probe_uses_its_defined_http_transport() {
|
||||
let endpoint = PROBES
|
||||
.iter()
|
||||
.find(|endpoint| {
|
||||
matches!(
|
||||
endpoint.expect,
|
||||
ProbeExpect::BodyContains("Microsoft Connect Test")
|
||||
)
|
||||
})
|
||||
.expect("Microsoft NCSI probe");
|
||||
assert_eq!(
|
||||
endpoint.url,
|
||||
"http://www.msftconnecttest.com/connecttest.txt"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -160,20 +160,27 @@ impl OfflineModeEngine {
|
||||
store.get_settings()
|
||||
}
|
||||
|
||||
/// Persist the network-folders-in-manual-offline policy flag.
|
||||
///
|
||||
/// NOTE (2026-06-10): no UI calls this anymore — the Slint "Show Network
|
||||
/// Folder Content" toggle was removed when library visibility stopped
|
||||
/// depending on offline mode (owner verdict; see qbz-slint's
|
||||
/// NETWORK-FOLDER VISIBILITY note). Kept (pub, no dead-code warning in a
|
||||
/// lib crate) because the store column must stay Tauri-DB-compatible.
|
||||
pub fn set_show_network_folders(&self, enabled: bool) -> Result<(), String> {
|
||||
/// Restore the Tauri manual-offline policy: submit scrobbles through the
|
||||
/// network immediately even while the Qobuz offline gate is induced.
|
||||
/// Enabling it atomically disables accumulated mode in the shared store.
|
||||
pub fn set_allow_immediate_scrobbling(&self, enabled: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("offline store lock poisoned: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session")?;
|
||||
store.set_show_network_folders_in_manual_offline(enabled)
|
||||
store.set_allow_immediate_scrobbling(enabled)
|
||||
}
|
||||
|
||||
/// Restore the Tauri physical-offline policy: retain scrobbles until the
|
||||
/// network is usable. Enabling it atomically disables immediate mode.
|
||||
pub fn set_allow_accumulated_scrobbling(&self, enabled: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("offline store lock poisoned: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session")?;
|
||||
store.set_allow_accumulated_scrobbling(enabled)
|
||||
}
|
||||
|
||||
/// Flip induced offline (Settings toggle). Always succeeds in either
|
||||
|
||||
@@ -3,29 +3,45 @@
|
||||
//! Opens the SAME `offline_settings.db` Tauri's `src-tauri/src/offline/mod.rs`
|
||||
//! uses (identical creation SQL + additive migrations), so the file stays
|
||||
//! frontend-portable like `library.db`/`index.db`. This shared store exposes
|
||||
//! only the subset the offline-MODE port consumes:
|
||||
//! the subset the current desktop frontends consume:
|
||||
//!
|
||||
//! - `manual_offline_mode` — the induced-offline flag (persisted; D1).
|
||||
//! - `show_network_folders_in_manual_offline` — network-mount policy (D9).
|
||||
//! - `pre_offline_stream_first_track` — the issue #279 snapshot of
|
||||
//! `audio_settings.stream_first_track` taken on entering induced offline.
|
||||
//! - the Tauri-era immediate/accumulated scrobbling policy flags.
|
||||
//!
|
||||
//! The legacy columns/tables (cast/scrobbling flags, `pending_playlist_sync`,
|
||||
//! `scrobble_queue`, `cache_limit_bytes`) are still CREATED for byte-level
|
||||
//! compatibility with the Tauri schema, but get no API here: the dead toggles
|
||||
//! are not ported (spec §1) and offline playlist creation is replaced by
|
||||
//! first-class local playlists (D7/D8).
|
||||
//! The remaining legacy columns/tables (`allow_cast_while_offline`,
|
||||
//! `pending_playlist_sync`, `cache_limit_bytes`) are still CREATED for
|
||||
//! byte-level compatibility with the Tauri schema. Offline playlist creation
|
||||
//! is replaced by first-class local playlists (D7/D8).
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// The offline-mode settings the port consumes.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OfflineModeSettings {
|
||||
pub manual_offline_mode: bool,
|
||||
pub show_network_folders_in_manual_offline: bool,
|
||||
/// Permit network submission while induced/manual offline is active.
|
||||
pub allow_immediate_scrobbling: bool,
|
||||
/// Retain scrobbles when they cannot be sent and flush them later.
|
||||
pub allow_accumulated_scrobbling: bool,
|
||||
}
|
||||
|
||||
impl Default for OfflineModeSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
manual_offline_mode: false,
|
||||
show_network_folders_in_manual_offline: false,
|
||||
// Historical Tauri defaults (f9ca1bd25).
|
||||
allow_immediate_scrobbling: false,
|
||||
allow_accumulated_scrobbling: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the Last.fm offline scrobble queue (`scrobble_queue`). Mirrors
|
||||
@@ -120,13 +136,17 @@ impl OfflineModeStore {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT manual_offline_mode,
|
||||
COALESCE(show_network_folders_in_manual_offline, 0)
|
||||
COALESCE(show_network_folders_in_manual_offline, 0),
|
||||
COALESCE(allow_immediate_scrobbling, 0),
|
||||
COALESCE(allow_accumulated_scrobbling, 1)
|
||||
FROM offline_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(OfflineModeSettings {
|
||||
manual_offline_mode: row.get::<_, i64>(0)? != 0,
|
||||
show_network_folders_in_manual_offline: row.get::<_, i64>(1)? != 0,
|
||||
allow_immediate_scrobbling: row.get::<_, i64>(2)? != 0,
|
||||
allow_accumulated_scrobbling: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -153,6 +173,38 @@ impl OfflineModeStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tauri `set_allow_immediate_scrobbling`, with the later a68230fad
|
||||
/// mutual-exclusion rule made atomic instead of two UI-side writes.
|
||||
pub fn set_allow_immediate_scrobbling(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE offline_settings
|
||||
SET allow_immediate_scrobbling = ?1,
|
||||
allow_accumulated_scrobbling = CASE
|
||||
WHEN ?1 != 0 THEN 0 ELSE allow_accumulated_scrobbling END
|
||||
WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set allow immediate scrobbling: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tauri `set_allow_accumulated_scrobbling`, with the later a68230fad
|
||||
/// mutual-exclusion rule made atomic instead of two UI-side writes.
|
||||
pub fn set_allow_accumulated_scrobbling(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE offline_settings
|
||||
SET allow_accumulated_scrobbling = ?1,
|
||||
allow_immediate_scrobbling = CASE
|
||||
WHEN ?1 != 0 THEN 0 ELSE allow_immediate_scrobbling END
|
||||
WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set allow accumulated scrobbling: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Issue #279 snapshot: the user's `stream_first_track` preference stashed
|
||||
/// when entering induced offline. `None` = no snapshot active.
|
||||
pub fn get_pre_offline_stream_first_track(&self) -> Result<Option<bool>, String> {
|
||||
@@ -313,6 +365,8 @@ mod tests {
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert!(!settings.manual_offline_mode);
|
||||
assert!(!settings.show_network_folders_in_manual_offline);
|
||||
assert!(!settings.allow_immediate_scrobbling);
|
||||
assert!(settings.allow_accumulated_scrobbling);
|
||||
assert_eq!(store.get_pre_offline_stream_first_track().unwrap(), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
@@ -347,6 +401,30 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrobbling_policies_round_trip_and_are_mutually_exclusive() {
|
||||
let dir = unique_test_dir("offline-store-scrobble-policy");
|
||||
let store = OfflineModeStore::new_at(&dir).unwrap();
|
||||
|
||||
store.set_allow_immediate_scrobbling(true).unwrap();
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert!(settings.allow_immediate_scrobbling);
|
||||
assert!(!settings.allow_accumulated_scrobbling);
|
||||
|
||||
// Turning a mode OFF does not silently opt into the other one.
|
||||
store.set_allow_immediate_scrobbling(false).unwrap();
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert!(!settings.allow_immediate_scrobbling);
|
||||
assert!(!settings.allow_accumulated_scrobbling);
|
||||
|
||||
store.set_allow_accumulated_scrobbling(true).unwrap();
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert!(!settings.allow_immediate_scrobbling);
|
||||
assert!(settings.allow_accumulated_scrobbling);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrobble_queue_round_trips_and_marks_sent() {
|
||||
let dir = unique_test_dir("offline-store-scrobbles");
|
||||
@@ -420,6 +498,8 @@ mod tests {
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert!(settings.manual_offline_mode, "Tauri-era flag must survive");
|
||||
assert!(!settings.show_network_folders_in_manual_offline);
|
||||
assert!(!settings.allow_immediate_scrobbling);
|
||||
assert!(settings.allow_accumulated_scrobbling);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,467 @@
|
||||
//! Queue + active-track persistence — SHARED.
|
||||
//!
|
||||
//! Lifted out of `crates/qbz/src/session_persist.rs` (the Slint binary), where
|
||||
//! the only thing tying it to Slint was one type alias:
|
||||
//! `type Runtime = Arc<AppRuntime<SlintAdapter>>`. Everything it touches —
|
||||
//! `session_store`, `settings::playback`, `shell::AppRuntime`, `qbz_models` —
|
||||
//! already lives in shared crates, so the Qt port had no session persistence
|
||||
//! for no structural reason at all. ADR-006.
|
||||
//!
|
||||
//! The crash-chain level is injected by the host rather than read from a
|
||||
//! frontend-local static; this module only needs the numeric level.
|
||||
|
||||
//! What it is: the glue for the `persist_session` /
|
||||
//! `resume_playback_position` playback preferences.
|
||||
//!
|
||||
//! The on-disk store is the frontend-agnostic [`SessionStore`] in
|
||||
//! `qbz-app` (a per-user `session.db`, already built + tested). This module is
|
||||
//! the thin frontend glue: it owns a process-global store handle (bound at
|
||||
//! session activation), captures the live queue + playback state into the
|
||||
//! persisted snapshot at meaningful edges, and restores it at startup.
|
||||
//!
|
||||
//! Gating: `persist_session` (restore the queue/session) and
|
||||
//! `resume_playback_position` (also restore the exact position) are per-user
|
||||
//! playback preferences. Both are cached here so the hot save path never reopens
|
||||
//! the prefs DB; [`set_gates`] refreshes the cache when the toggles change, and
|
||||
//! [`init_for_user`] seeds them synchronously when the store opens (no race with
|
||||
//! the async settings snapshot load).
|
||||
//!
|
||||
//! Restore is PAUSED: the queue + current track come back — it touches
|
||||
//! NO protected-audio code beyond threading an existing `start_position_secs`.
|
||||
//! The saved position rides along via [`take_resume_for`] and is consumed on the
|
||||
//! first play of the restored track, reusing the player's session-resume offset.
|
||||
//! Nothing here touches the protected audio path beyond threading an existing
|
||||
//! `start_position_secs` argument.
|
||||
|
||||
|
||||
/// Host-set crash-chain level (see the callers' probe). 1 = clean previous boot.
|
||||
static CRASH_CHAIN: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(1);
|
||||
|
||||
/// The frontend adapter bound to the runtime. Named locally so the signatures
|
||||
/// below read as one bound instead of repeating the whole where-clause.
|
||||
pub trait Adapter: qbz_models::traits::FrontendAdapter + Send + Sync + 'static {}
|
||||
impl<T: qbz_models::traits::FrontendAdapter + Send + Sync + 'static> Adapter for T {}
|
||||
|
||||
/// Publish this boot's crash-chain level. Call once, after arming the probe.
|
||||
pub fn set_crash_chain_level(level: u8) {
|
||||
CRASH_CHAIN.store(level, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn crash_chain_level() -> u8 {
|
||||
CRASH_CHAIN.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use crate::session_store::{
|
||||
PersistedPlaybackSession, PersistedQueueExtras, PersistedQueueHistoryEntry,
|
||||
PersistedQueueTrack, PersistedQueueTrackEdition, PersistedSessionSnapshot,
|
||||
PersistedShellViewState, SessionStore,
|
||||
};
|
||||
use crate::settings::playback::PlaybackPreferencesStore;
|
||||
use crate::shell::AppRuntime;
|
||||
use qbz_models::{QueueTrack, RepeatMode};
|
||||
|
||||
/// Per-user session store, bound at activation (None before login / on failure).
|
||||
static STORE: Mutex<Option<SessionStore>> = Mutex::new(None);
|
||||
/// Cached `persist_session` gate — captured/restored only when true.
|
||||
static PERSIST_SESSION: AtomicBool = AtomicBool::new(false);
|
||||
/// Cached `resume_playback_position` gate — primes [`PENDING_RESUME`] at restore.
|
||||
static RESUME_POSITION: AtomicBool = AtomicBool::new(false);
|
||||
/// Position (secs) to resume the restored current track at on the first play.
|
||||
/// 0 = none. Written at restore (when resume is on); consumed on first play.
|
||||
static PENDING_RESUME: AtomicU64 = AtomicU64::new(0);
|
||||
/// Track id the pending resume position applies to, so ONLY the restored current
|
||||
/// track resumes — playing any other track first starts from 0. 0 = none.
|
||||
static PENDING_RESUME_TRACK: AtomicU64 = AtomicU64::new(0);
|
||||
/// Runtime + tokio handle captured at shell entry, so the synchronous window
|
||||
/// close handlers can flush a final full snapshot before the loop quits.
|
||||
/// Type-ERASED on purpose. A `static` cannot name `Arc<AppRuntime<A>>` now that
|
||||
/// `A` is a parameter, so what gets stored is the finished job — a closure that
|
||||
/// already owns the runtime and the handle — instead of the ingredients.
|
||||
type ExitSaver = Box<dyn Fn() + Send + Sync + 'static>;
|
||||
static EXIT_CTX: OnceLock<ExitSaver> = OnceLock::new();
|
||||
|
||||
/// Bind the runtime + tokio handle for [`save_on_exit`]. Called once at shell
|
||||
/// entry (idempotent — later calls are ignored by `OnceLock`).
|
||||
pub fn bind_exit_ctx<A: Adapter>(runtime: Arc<AppRuntime<A>>, handle: tokio::runtime::Handle) {
|
||||
let _ = EXIT_CTX.set(Box::new(move || {
|
||||
handle.block_on(capture_and_save(&runtime));
|
||||
}));
|
||||
}
|
||||
|
||||
/// Flush a final full snapshot synchronously (the window close handlers run on
|
||||
/// the UI thread, off the tokio runtime, so we `block_on`). No-op until the exit
|
||||
/// context is bound or unless `persist_session` is on.
|
||||
pub fn save_on_exit() {
|
||||
if !persist_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(save) = EXIT_CTX.get() {
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the per-user session store and seed the gate flags synchronously from
|
||||
/// the playback preferences. Called at session activation alongside the other
|
||||
/// `init_for_user` stores. Failures degrade to "no persistence" (logged).
|
||||
pub fn init_for_user(base_dir: &Path) {
|
||||
let opened = match SessionStore::new_at(base_dir) {
|
||||
Ok(store) => {
|
||||
*STORE.lock().unwrap() = Some(store);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[session] persist: open failed: {e}");
|
||||
*STORE.lock().unwrap() = None;
|
||||
false
|
||||
}
|
||||
};
|
||||
// Seed the gates from the per-user playback prefs so capture/restore work
|
||||
// before the async settings snapshot has had a chance to call set_gates.
|
||||
match PlaybackPreferencesStore::new_at(base_dir).and_then(|s| s.get_preferences()) {
|
||||
Ok(prefs) => set_gates(prefs.persist_session, prefs.resume_playback_position),
|
||||
Err(e) => {
|
||||
log::warn!("[session] persist: prefs read failed, gates off: {e}");
|
||||
set_gates(false, false);
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"[session] persist: init at {} (store_open={opened}, persist={}, resume={})",
|
||||
base_dir.display(),
|
||||
PERSIST_SESSION.load(Ordering::Relaxed),
|
||||
RESUME_POSITION.load(Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresh the cached gate flags (called by the Settings toggle handlers and the
|
||||
/// settings snapshot load so the cache tracks live preference changes).
|
||||
pub fn set_gates(persist_session: bool, resume_position: bool) {
|
||||
PERSIST_SESSION.store(persist_session, Ordering::Relaxed);
|
||||
RESUME_POSITION.store(resume_position, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Set ONE gate. The two toggles live on different Settings rows and each
|
||||
/// handler only knows its own new value; making it read the sibling pref back
|
||||
/// off disk just to call `set_gates` is a round trip that can also disagree
|
||||
/// with what is on screen.
|
||||
pub fn set_persist_gate(on: bool) {
|
||||
PERSIST_SESSION.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Sibling of [`set_persist_gate`] for the resume-position row.
|
||||
pub fn set_resume_gate(on: bool) {
|
||||
RESUME_POSITION.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether session persistence is currently enabled.
|
||||
pub fn persist_enabled() -> bool {
|
||||
PERSIST_SESSION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Take + clear the pending resume position IF it applies to `track_id` (the
|
||||
/// restored current track). Returns the saved position once, then 0 forever —
|
||||
/// and 0 for any other track, so playing something else first never resumes.
|
||||
pub fn take_resume_for(track_id: u64) -> u64 {
|
||||
if track_id != 0 && PENDING_RESUME_TRACK.swap(0, Ordering::Relaxed) == track_id {
|
||||
PENDING_RESUME.swap(0, Ordering::Relaxed)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Peek the pending resume position WITHOUT consuming it (so the seek bar can be
|
||||
/// seeded at restore while the actual resume still fires on first play). 0 = none.
|
||||
pub fn pending_resume_position() -> u64 {
|
||||
if PENDING_RESUME_TRACK.load(Ordering::Relaxed) != 0 {
|
||||
PENDING_RESUME.load(Ordering::Relaxed)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn repeat_to_str(mode: RepeatMode) -> &'static str {
|
||||
match mode {
|
||||
RepeatMode::Off => "off",
|
||||
RepeatMode::All => "all",
|
||||
RepeatMode::One => "one",
|
||||
}
|
||||
}
|
||||
|
||||
fn repeat_from_str(s: &str) -> RepeatMode {
|
||||
match s {
|
||||
"all" => RepeatMode::All,
|
||||
"one" => RepeatMode::One,
|
||||
_ => RepeatMode::Off,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_persisted(t: &QueueTrack) -> PersistedQueueTrack {
|
||||
PersistedQueueTrack {
|
||||
id: t.id,
|
||||
title: t.title.clone(),
|
||||
artist: t.artist.clone(),
|
||||
album: t.album.clone(),
|
||||
duration_secs: t.duration_secs,
|
||||
artwork_url: t.artwork_url.clone(),
|
||||
hires: t.hires,
|
||||
bit_depth: t.bit_depth,
|
||||
sample_rate: t.sample_rate,
|
||||
is_local: t.is_local,
|
||||
album_id: t.album_id.clone(),
|
||||
artist_id: t.artist_id,
|
||||
streamable: t.streamable,
|
||||
source: t.source.clone(),
|
||||
parental_warning: t.parental_warning,
|
||||
source_item_id_hint: t.source_item_id_hint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_persisted(t: PersistedQueueTrack) -> QueueTrack {
|
||||
QueueTrack {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
// The portable session shape predates edition subtitles. The additive
|
||||
// queue-extras snapshot patches these two fields after conversion.
|
||||
version: None,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
album_version: None,
|
||||
duration_secs: t.duration_secs,
|
||||
artwork_url: t.artwork_url,
|
||||
hires: t.hires,
|
||||
bit_depth: t.bit_depth,
|
||||
sample_rate: t.sample_rate,
|
||||
is_local: t.is_local,
|
||||
album_id: t.album_id,
|
||||
artist_id: t.artist_id,
|
||||
streamable: t.streamable,
|
||||
source: t.source,
|
||||
parental_warning: t.parental_warning,
|
||||
source_item_id_hint: t.source_item_id_hint,
|
||||
// Not persisted in the session schema — a restored track carries no
|
||||
// container origin, so the "playing from" button falls back to the
|
||||
// track's own album until the next container play re-stamps the queue.
|
||||
context_kind: None,
|
||||
context_id: None,
|
||||
isrc: None,
|
||||
recording_mbid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture the live queue + playback state and persist it. No-op unless
|
||||
/// `persist_session` is on and the store is open. Async (reads the queue lock).
|
||||
pub async fn capture_and_save<A: Adapter>(runtime: &Arc<AppRuntime<A>>) {
|
||||
if !persist_enabled() {
|
||||
return;
|
||||
}
|
||||
let (tracks, current_index, history_indices) =
|
||||
runtime.core().get_persistable_queue_state().await;
|
||||
// Crash-chain level >=3 bypassed the restore this boot, so the queue on
|
||||
// disk is the GOOD copy the user wants back on a healthy start — don't
|
||||
// clobber it with this session's empty queue at exit. A queue the user
|
||||
// actually built during the recovered boot still saves normally.
|
||||
if tracks.is_empty() && crash_chain_level() >= 3 {
|
||||
log::info!(
|
||||
"[session] persist: crash-chain recovery boot with empty queue — \
|
||||
keeping the preserved snapshot on disk"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let full = runtime.core().get_queue_state_full().await;
|
||||
let pb = runtime.core().get_playback_state();
|
||||
// POSITION: the engine reports 0 until something actually loads, so a
|
||||
// session that was restored and then closed WITHOUT playing would write
|
||||
// that idle 0 over the resume point the restore had just armed — one
|
||||
// open-and-close and the user silently loses their place, with the queue
|
||||
// still there to make it look like persistence worked.
|
||||
//
|
||||
// An UNCONSUMED pending resume is the truthful value in exactly that case.
|
||||
// `pending_resume_position` self-clears the moment the first play takes it
|
||||
// (`take_resume_for` zeroes the track id), so this can never resurrect a
|
||||
// stale position: once the track has played, the engine's own number wins.
|
||||
let position = if pb.position == 0 && !pb.is_playing {
|
||||
let pending = pending_resume_position();
|
||||
if pending > 0 {
|
||||
log::info!(
|
||||
"[session] persist: keeping the un-consumed resume position {pending}s (engine idle at 0 — nothing was played this session)"
|
||||
);
|
||||
}
|
||||
pending
|
||||
} else {
|
||||
pb.position
|
||||
};
|
||||
let snapshot = PersistedSessionSnapshot {
|
||||
playback: PersistedPlaybackSession {
|
||||
queue_tracks: tracks.iter().map(to_persisted).collect(),
|
||||
current_index,
|
||||
current_position_secs: position,
|
||||
volume: pb.volume,
|
||||
shuffle_enabled: full.shuffle,
|
||||
repeat_mode: repeat_to_str(full.repeat).to_string(),
|
||||
was_playing: pb.is_playing,
|
||||
saved_at: 0, // set inside save_session
|
||||
},
|
||||
// Shell-view restoration is handled separately (ui_prefs startup_page);
|
||||
// keep the Tauri view columns at their defaults so the schema round-trips.
|
||||
shell_view: PersistedShellViewState::default(),
|
||||
};
|
||||
let extras = PersistedQueueExtras {
|
||||
editions: tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(position, track)| PersistedQueueTrackEdition {
|
||||
position,
|
||||
track_id: track.id,
|
||||
version: track.version.clone(),
|
||||
album_version: track.album_version.clone(),
|
||||
})
|
||||
.collect(),
|
||||
history: history_indices
|
||||
.into_iter()
|
||||
.filter_map(|position| {
|
||||
tracks
|
||||
.get(position)
|
||||
.map(|track| PersistedQueueHistoryEntry {
|
||||
position,
|
||||
track_id: track.id,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let track_count = snapshot.playback.queue_tracks.len();
|
||||
let history_count = extras.history.len();
|
||||
if let Some(store) = STORE.lock().unwrap().as_ref() {
|
||||
match store.save_session(&snapshot) {
|
||||
Ok(()) => match store.save_queue_extras(&extras) {
|
||||
Ok(()) => log::info!(
|
||||
"[session] persist: saved {track_count} queue tracks and {history_count} history entries (pos {}s, playing {})",
|
||||
snapshot.playback.current_position_secs,
|
||||
snapshot.playback.was_playing
|
||||
),
|
||||
Err(e) => log::warn!("[session] persist: queue extras save failed: {e}"),
|
||||
},
|
||||
Err(e) => log::warn!("[session] persist: save failed: {e}"),
|
||||
}
|
||||
} else {
|
||||
log::warn!("[session] persist: capture skipped (store not open)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick position-only save (a single cheap UPDATE) — for the poll loop's
|
||||
/// throttled tick and the pause edge, so a crash keeps a near-current position.
|
||||
pub fn save_position(position_secs: u64) {
|
||||
if !persist_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(store) = STORE.lock().unwrap().as_ref() {
|
||||
let _ = store.save_position(position_secs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the persisted queue at startup. Returns true if a non-empty queue was
|
||||
/// restored (so the caller refreshes the now-playing bar). Restores PAUSED; when
|
||||
/// `resume_playback_position` is on, primes [`PENDING_RESUME`] for Phase B.
|
||||
pub async fn restore<A: Adapter>(runtime: &Arc<AppRuntime<A>>) -> bool {
|
||||
if !persist_enabled() {
|
||||
log::info!("[session] persist: restore skipped (persist_session off)");
|
||||
return false;
|
||||
}
|
||||
let (snapshot, extras) = {
|
||||
let guard = STORE.lock().unwrap();
|
||||
let Some(store) = guard.as_ref() else {
|
||||
log::warn!("[session] persist: restore skipped (store not open)");
|
||||
return false;
|
||||
};
|
||||
let snapshot = match store.load_session() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("[session] persist: load failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let extras = match store.load_queue_extras() {
|
||||
Ok(extras) => extras,
|
||||
Err(e) => {
|
||||
log::warn!("[session] persist: queue extras load failed: {e}");
|
||||
PersistedQueueExtras::default()
|
||||
}
|
||||
};
|
||||
(snapshot, extras)
|
||||
};
|
||||
let pb_sess = snapshot.playback;
|
||||
if pb_sess.queue_tracks.is_empty() {
|
||||
log::info!("[session] persist: nothing to restore (saved queue is empty)");
|
||||
return false;
|
||||
}
|
||||
let position = pb_sess.current_position_secs;
|
||||
let count = pb_sess.queue_tracks.len();
|
||||
let index = pb_sess.current_index;
|
||||
let mut tracks: Vec<QueueTrack> = pb_sess
|
||||
.queue_tracks
|
||||
.into_iter()
|
||||
.map(from_persisted)
|
||||
.collect();
|
||||
// Extras and the portable snapshot are committed independently for
|
||||
// backward compatibility. Accept them only when every saved position/id
|
||||
// still names this exact queue; this prevents a crash between commits from
|
||||
// attaching stale editions or history to a newer session.
|
||||
let extras_match = extras.editions.len() == tracks.len()
|
||||
&& extras.editions.iter().all(|edition| {
|
||||
tracks
|
||||
.get(edition.position)
|
||||
.is_some_and(|track| track.id == edition.track_id)
|
||||
});
|
||||
let restored_history = if extras_match {
|
||||
for edition in &extras.editions {
|
||||
if let Some(track) = tracks.get_mut(edition.position) {
|
||||
track.version = edition.version.clone();
|
||||
track.album_version = edition.album_version.clone();
|
||||
}
|
||||
}
|
||||
extras
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
tracks
|
||||
.get(entry.position)
|
||||
.filter(|track| track.id == entry.track_id)
|
||||
.map(|_| entry.position)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
if !extras.editions.is_empty() || !extras.history.is_empty() {
|
||||
log::warn!("[session] persist: stale queue extras discarded");
|
||||
}
|
||||
Vec::new()
|
||||
};
|
||||
// The current track's id, so the resume position is applied ONLY when this
|
||||
// exact track is the first one played after the restore.
|
||||
let current_track_id = index.and_then(|i| tracks.get(i)).map(|t| t.id).unwrap_or(0);
|
||||
runtime
|
||||
.core()
|
||||
.set_queue_with_order(tracks, index, pb_sess.shuffle_enabled, None)
|
||||
.await;
|
||||
let history_count = restored_history.len();
|
||||
runtime.core().restore_queue_history(restored_history).await;
|
||||
runtime
|
||||
.core()
|
||||
.set_repeat_mode(repeat_from_str(&pb_sess.repeat_mode))
|
||||
.await;
|
||||
// The queue session carries the authoritative last volume; apply it to the
|
||||
// player (the slider also seeds from ui_prefs, but this keeps them in step).
|
||||
let _ = runtime.core().set_volume(pb_sess.volume);
|
||||
if RESUME_POSITION.load(Ordering::Relaxed) && position > 0 && current_track_id != 0 {
|
||||
PENDING_RESUME.store(position, Ordering::Relaxed);
|
||||
PENDING_RESUME_TRACK.store(current_track_id, Ordering::Relaxed);
|
||||
}
|
||||
log::info!(
|
||||
"[session] persist: restored {count} queue tracks and {} history entries (index {index:?}), paused; \
|
||||
resume position {position}s (consumed on first play when enabled)",
|
||||
history_count
|
||||
);
|
||||
true
|
||||
}
|
||||
@@ -30,6 +30,14 @@ pub struct PersistedQueueTrack {
|
||||
pub is_local: bool,
|
||||
pub album_id: Option<String>,
|
||||
pub artist_id: Option<u64>,
|
||||
/// Resolved availability — absence was already interpreted upstream by
|
||||
/// `qbz_models::Track::is_streamable`, so this stays a plain `bool` while
|
||||
/// the catalog model is an `Option<bool>`, and `default_streamable` (TRUE)
|
||||
/// covers a JSON snapshot written before the column existed. Values written
|
||||
/// before the model became tri-state are untrustworthy and are cleared once
|
||||
/// by the `user_version = 1` migration in `open_at`; from that point on
|
||||
/// `false` here means only "Qobuz said no", which is a state a DOWNLOADED
|
||||
/// track legitimately persists in.
|
||||
#[serde(default = "default_streamable")]
|
||||
pub streamable: bool,
|
||||
#[serde(default)]
|
||||
@@ -52,6 +60,31 @@ pub struct PersistedPlaybackSession {
|
||||
pub saved_at: i64,
|
||||
}
|
||||
|
||||
/// Additive queue fields that the legacy portable session shape cannot carry
|
||||
/// without breaking older frontend struct literals. Positions refer to the
|
||||
/// simultaneously persisted `queue_tracks` vector.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PersistedQueueTrackEdition {
|
||||
pub position: usize,
|
||||
pub track_id: u64,
|
||||
pub version: Option<String>,
|
||||
pub album_version: Option<String>,
|
||||
}
|
||||
|
||||
/// One oldest-first play-history entry. `track_id` validates that `position`
|
||||
/// still refers to the same queue snapshot before the entry is restored.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PersistedQueueHistoryEntry {
|
||||
pub position: usize,
|
||||
pub track_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PersistedQueueExtras {
|
||||
pub editions: Vec<PersistedQueueTrackEdition>,
|
||||
pub history: Vec<PersistedQueueHistoryEntry>,
|
||||
}
|
||||
|
||||
impl Default for PersistedPlaybackSession {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -153,6 +186,19 @@ impl SessionStore {
|
||||
source TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queue_track_extras (
|
||||
position INTEGER PRIMARY KEY,
|
||||
track_id INTEGER NOT NULL,
|
||||
version TEXT,
|
||||
album_version TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queue_history (
|
||||
sequence INTEGER PRIMARY KEY,
|
||||
position INTEGER NOT NULL,
|
||||
track_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO player_state (id, current_position_secs, volume, shuffle_enabled, repeat_mode, was_playing, saved_at)
|
||||
VALUES (1, 0, 0.75, 0, 'off', 0, 0);
|
||||
",
|
||||
@@ -252,6 +298,51 @@ impl SessionStore {
|
||||
);
|
||||
}
|
||||
|
||||
// ── One-shot: distrust every `streamable` written before the model
|
||||
// became tri-state ────────────────────────────────────────────────
|
||||
//
|
||||
// `queue_tracks.streamable` is persisted from `QueueTrack.streamable`,
|
||||
// which was fed from `qbz_models::Track.streamable` — a plain `bool`
|
||||
// whose `#[serde(default)]` turned an ABSENT key into `false`. So a row
|
||||
// queued from any endpoint that omits the key was stored as `0` meaning
|
||||
// "the payload was terse", not "Qobuz pulled this track".
|
||||
//
|
||||
// The unavailability work gives `0` a new, destructive meaning: an
|
||||
// unavailable track never enters the queue. Left alone, those legacy
|
||||
// zeroes would silently delete tracks from every restored session, for
|
||||
// good — on a player whose entire point is not losing the user's music.
|
||||
//
|
||||
// The original value is unrecoverable (nothing on disk records WHY a
|
||||
// row is 0), so the only honest repair is to clear the poison once and
|
||||
// let the live API re-establish the truth on the next listing fetch,
|
||||
// with the reactive auto-skip as the backstop for anything genuinely
|
||||
// dead. Erring toward "available" here is the same asymmetry
|
||||
// `Track::is_streamable` documents: a wasted round trip is cheap, a
|
||||
// vanished track is not.
|
||||
//
|
||||
// It must be ONE-SHOT rather than a coercion on read, because after the
|
||||
// model change a `0` is meaningful again: a track Qobuz pulled but the
|
||||
// user already DOWNLOADED stays in the queue and plays from disk, and
|
||||
// it stays there with `streamable = 0`. Coercing on read would
|
||||
// resurrect that row as available forever and re-hide the very state
|
||||
// the render is meant to distinguish.
|
||||
//
|
||||
// `PRAGMA user_version` is the guard — it is 0 on every database that
|
||||
// predates this build and on a freshly created one, where the UPDATE
|
||||
// simply matches no rows. The column-probe idiom used by the migrations
|
||||
// above cannot express this: nothing is being added, only rewritten.
|
||||
let schema_version: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
if schema_version < 1 {
|
||||
let _ = conn.execute_batch(
|
||||
"
|
||||
UPDATE queue_tracks SET streamable = 1 WHERE streamable = 0;
|
||||
PRAGMA user_version = 1;
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
@@ -269,6 +360,14 @@ impl SessionStore {
|
||||
let _ = self.conn.execute("ROLLBACK", []);
|
||||
return Err(format!("Failed to clear queue: {}", e));
|
||||
}
|
||||
if let Err(e) = self.conn.execute("DELETE FROM queue_track_extras", []) {
|
||||
let _ = self.conn.execute("ROLLBACK", []);
|
||||
return Err(format!("Failed to clear queue track extras: {e}"));
|
||||
}
|
||||
if let Err(e) = self.conn.execute("DELETE FROM queue_history", []) {
|
||||
let _ = self.conn.execute("ROLLBACK", []);
|
||||
return Err(format!("Failed to clear queue history: {e}"));
|
||||
}
|
||||
|
||||
for (pos, track) in session.playback.queue_tracks.iter().enumerate() {
|
||||
if let Err(e) = self.conn.execute(
|
||||
@@ -431,6 +530,95 @@ impl SessionStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist edition subtitles and oldest-first playback history alongside
|
||||
/// the portable session snapshot. Kept additive so older frontends that
|
||||
/// construct `PersistedPlaybackSession` remain source-compatible.
|
||||
pub fn save_queue_extras(&self, extras: &PersistedQueueExtras) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| format!("Failed to begin queue-extras transaction: {e}"))?;
|
||||
|
||||
let save = (|| -> Result<(), String> {
|
||||
self.conn
|
||||
.execute("DELETE FROM queue_track_extras", [])
|
||||
.map_err(|e| format!("Failed to clear queue track extras: {e}"))?;
|
||||
self.conn
|
||||
.execute("DELETE FROM queue_history", [])
|
||||
.map_err(|e| format!("Failed to clear queue history: {e}"))?;
|
||||
|
||||
for edition in &extras.editions {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO queue_track_extras (position, track_id, version, album_version) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
edition.position as i64,
|
||||
edition.track_id as i64,
|
||||
edition.version.as_deref(),
|
||||
edition.album_version.as_deref(),
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save queue track extra: {e}"))?;
|
||||
}
|
||||
|
||||
for (sequence, entry) in extras.history.iter().enumerate() {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO queue_history (sequence, position, track_id) VALUES (?1, ?2, ?3)",
|
||||
params![sequence as i64, entry.position as i64, entry.track_id as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save queue history: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(error) = save {
|
||||
let _ = self.conn.execute("ROLLBACK", []);
|
||||
return Err(error);
|
||||
}
|
||||
self.conn
|
||||
.execute("COMMIT", [])
|
||||
.map_err(|e| format!("Failed to commit queue-extras transaction: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_queue_extras(&self) -> Result<PersistedQueueExtras, String> {
|
||||
let mut editions_stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT position, track_id, version, album_version FROM queue_track_extras ORDER BY position",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare queue track extras query: {e}"))?;
|
||||
let editions = editions_stmt
|
||||
.query_map([], |row| {
|
||||
Ok(PersistedQueueTrackEdition {
|
||||
position: row.get::<_, i64>(0)? as usize,
|
||||
track_id: row.get::<_, i64>(1)? as u64,
|
||||
version: row.get(2)?,
|
||||
album_version: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query queue track extras: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to read queue track extras: {e}"))?;
|
||||
|
||||
let mut history_stmt = self
|
||||
.conn
|
||||
.prepare("SELECT position, track_id FROM queue_history ORDER BY sequence")
|
||||
.map_err(|e| format!("Failed to prepare queue history query: {e}"))?;
|
||||
let history = history_stmt
|
||||
.query_map([], |row| {
|
||||
Ok(PersistedQueueHistoryEntry {
|
||||
position: row.get::<_, i64>(0)? as usize,
|
||||
track_id: row.get::<_, i64>(1)? as u64,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query queue history: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to read queue history: {e}"))?;
|
||||
|
||||
Ok(PersistedQueueExtras { editions, history })
|
||||
}
|
||||
|
||||
pub fn save_position(&self, position_secs: u64) -> Result<(), String> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -473,6 +661,12 @@ impl SessionStore {
|
||||
self.conn
|
||||
.execute("DELETE FROM queue_tracks", [])
|
||||
.map_err(|e| format!("Failed to clear queue: {}", e))?;
|
||||
self.conn
|
||||
.execute("DELETE FROM queue_track_extras", [])
|
||||
.map_err(|e| format!("Failed to clear queue track extras: {e}"))?;
|
||||
self.conn
|
||||
.execute("DELETE FROM queue_history", [])
|
||||
.map_err(|e| format!("Failed to clear queue history: {e}"))?;
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE player_state SET current_index = NULL, current_position_secs = 0, was_playing = 0, last_view = 'home', view_context_id = NULL, view_context_type = NULL WHERE id = 1",
|
||||
@@ -591,8 +785,124 @@ mod tests {
|
||||
assert!(loaded.playback.was_playing);
|
||||
assert!(loaded.playback.saved_at > 0);
|
||||
assert_eq!(loaded.shell_view.last_view, "album");
|
||||
assert_eq!(loaded.shell_view.view_context_id.as_deref(), Some("album-1"));
|
||||
assert_eq!(loaded.shell_view.view_context_type.as_deref(), Some("album"));
|
||||
assert_eq!(
|
||||
loaded.shell_view.view_context_id.as_deref(),
|
||||
Some("album-1")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.shell_view.view_context_type.as_deref(),
|
||||
Some("album")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_store_round_trips_queue_editions_and_history() {
|
||||
let dir = unique_test_dir("session-queue-extras");
|
||||
let store = SessionStore::new_at(&dir).expect("open store");
|
||||
let session = PersistedSessionSnapshot {
|
||||
playback: PersistedPlaybackSession {
|
||||
queue_tracks: vec![sample_track()],
|
||||
current_index: Some(0),
|
||||
..PersistedPlaybackSession::default()
|
||||
},
|
||||
shell_view: PersistedShellViewState::default(),
|
||||
};
|
||||
let extras = PersistedQueueExtras {
|
||||
editions: vec![PersistedQueueTrackEdition {
|
||||
position: 0,
|
||||
track_id: 42,
|
||||
version: Some("Backing Track / Bonus Track".to_string()),
|
||||
album_version: Some("Remastered 2014".to_string()),
|
||||
}],
|
||||
history: vec![PersistedQueueHistoryEntry {
|
||||
position: 0,
|
||||
track_id: 42,
|
||||
}],
|
||||
};
|
||||
|
||||
store.save_session(&session).expect("save session");
|
||||
store.save_queue_extras(&extras).expect("save queue extras");
|
||||
drop(store);
|
||||
|
||||
let reopened = SessionStore::new_at(&dir).expect("reopen store");
|
||||
assert_eq!(
|
||||
reopened.load_queue_extras().expect("load queue extras"),
|
||||
extras
|
||||
);
|
||||
|
||||
reopened.clear_session().expect("clear session");
|
||||
assert_eq!(
|
||||
reopened.load_queue_extras().expect("load cleared extras"),
|
||||
PersistedQueueExtras::default()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The `user_version = 1` repair clears legacy `streamable = 0` rows ONCE,
|
||||
/// and never fires again.
|
||||
///
|
||||
/// Both halves matter and they pull in opposite directions. A `0` written
|
||||
/// by an older build is untrustworthy — it may only mean the endpoint was
|
||||
/// terse — and under the new queue filter it would silently delete the
|
||||
/// track from every restored session. A `0` written AFTER the repair is
|
||||
/// real: it is a track Qobuz pulled that the user has downloaded, which
|
||||
/// stays in the queue and plays from disk. A coercion on read would satisfy
|
||||
/// the first half and destroy the second, which is why this is a migration.
|
||||
#[test]
|
||||
fn legacy_streamable_zeroes_are_cleared_once_then_honoured() {
|
||||
let dir = unique_test_dir("session-streamable-migration");
|
||||
|
||||
let session_with_sample = || {
|
||||
let mut session = PersistedSessionSnapshot::default();
|
||||
session.playback.queue_tracks = vec![sample_track()];
|
||||
session.playback.current_index = Some(0);
|
||||
session
|
||||
};
|
||||
|
||||
// A database as an older build left it: a row at `streamable = 0` and
|
||||
// no schema stamp. Rewinding the pragma is what makes it "older" —
|
||||
// `sample_track()` already carries `streamable: false`.
|
||||
{
|
||||
let store = SessionStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.save_session(&session_with_sample())
|
||||
.expect("save legacy session");
|
||||
store
|
||||
.conn
|
||||
.execute_batch("PRAGMA user_version = 0;")
|
||||
.expect("rewind schema stamp");
|
||||
}
|
||||
|
||||
// Reopening runs the repair: the untrustworthy 0 is cleared.
|
||||
{
|
||||
let store = SessionStore::new_at(&dir).expect("reopen store");
|
||||
let loaded = store.load_session().expect("load repaired session");
|
||||
assert!(
|
||||
loaded.playback.queue_tracks[0].streamable,
|
||||
"a pre-migration 0 is untrustworthy and must be cleared, not \
|
||||
read as 'Qobuz pulled this track'"
|
||||
);
|
||||
|
||||
// Now write a 0 that IS trustworthy — the downloaded-but-pulled
|
||||
// state the render distinguishes.
|
||||
store
|
||||
.save_session(&session_with_sample())
|
||||
.expect("save post-migration session");
|
||||
}
|
||||
|
||||
// Reopening again must leave it alone: the repair is one-shot.
|
||||
{
|
||||
let store = SessionStore::new_at(&dir).expect("reopen store again");
|
||||
let loaded = store.load_session().expect("load session");
|
||||
assert!(
|
||||
!loaded.playback.queue_tracks[0].streamable,
|
||||
"the repair must not fire twice — a post-migration 0 is real \
|
||||
and resurrecting it would hide a downloaded-but-pulled track"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
@@ -52,30 +52,46 @@ fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
)?;
|
||||
// PLAYBACK CONTEXT of the event — "album" | "playlist" | "" (unknown).
|
||||
//
|
||||
// Additive, and it has to be: this .db is shared with existing installs,
|
||||
// whose tree is frozen and whose INSERT names its columns explicitly
|
||||
// (`INSERT INTO album_play_events (album_id, occurred_at)`), so an extra
|
||||
// column with a DEFAULT is invisible to it and it simply writes `''`.
|
||||
// Every legacy row is `''` too, and the ranking query treats `''` as
|
||||
// "album" — so no history is lost or reinterpreted by this migration.
|
||||
//
|
||||
// SQLite has no `ADD COLUMN IF NOT EXISTS`; running it and swallowing the
|
||||
// duplicate-column error is the idiom, and it is cheaper than a
|
||||
// `PRAGMA table_info` round trip on every open.
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE album_play_events ADD COLUMN context_kind TEXT NOT NULL DEFAULT '';",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_db() -> Option<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("[qbz-slint] album_play_history dir create failed: {e}");
|
||||
log::warn!("[qbz-app] album_play_history dir create failed: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let conn = match Connection::open(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] album_play_history open failed: {e}");
|
||||
log::warn!("[qbz-app] album_play_history open failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// ADR-002: WAL for any SQLite store touched off the UI thread.
|
||||
if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") {
|
||||
log::warn!("[qbz-slint] album_play_history pragma failed: {e}");
|
||||
if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") {
|
||||
log::warn!("[qbz-app] album_play_history pragma failed: {e}");
|
||||
}
|
||||
if let Err(e) = init_schema(&conn) {
|
||||
log::warn!("[qbz-slint] album_play_history schema failed: {e}");
|
||||
log::warn!("[qbz-app] album_play_history schema failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(conn)
|
||||
@@ -103,6 +119,11 @@ pub struct AlbumPlayMeta<'a> {
|
||||
pub quality_label: &'a str,
|
||||
pub year: &'a str,
|
||||
pub source: &'a str,
|
||||
/// What the user was PLAYING FROM when this event happened: `"album"`,
|
||||
/// `"playlist"`, or `""` when nothing was stamped. NOT the same axis as
|
||||
/// [`Self::source`], which is where the audio comes from (qobuz / local /
|
||||
/// plex) — an album can be local AND played from a playlist.
|
||||
pub context_kind: &'a str,
|
||||
}
|
||||
|
||||
/// One ranked album for the "Most Played Albums" rail / View-all page.
|
||||
@@ -124,10 +145,11 @@ pub struct AlbumPlayRow {
|
||||
/// Internal so tests can drive it against an in-memory connection.
|
||||
fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
|
||||
if let Err(e) = conn.execute(
|
||||
"INSERT INTO album_play_events (album_id, occurred_at) VALUES (?, ?)",
|
||||
params![m.album_id, now],
|
||||
"INSERT INTO album_play_events (album_id, occurred_at, context_kind) \
|
||||
VALUES (?, ?, ?)",
|
||||
params![m.album_id, now, m.context_kind],
|
||||
) {
|
||||
log::warn!("[qbz-slint] album_play_history insert event failed: {e}");
|
||||
log::warn!("[qbz-app] album_play_history insert event failed: {e}");
|
||||
}
|
||||
if let Err(e) = conn.execute(
|
||||
r#"
|
||||
@@ -159,7 +181,7 @@ fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
|
||||
now
|
||||
],
|
||||
) {
|
||||
log::warn!("[qbz-slint] album_play_history upsert meta failed: {e}");
|
||||
log::warn!("[qbz-app] album_play_history upsert meta failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +196,13 @@ fn query_on(conn: &Connection, limit: Option<u32>) -> Vec<AlbumPlayRow> {
|
||||
JOIN (
|
||||
SELECT album_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
|
||||
FROM album_play_events
|
||||
-- Only plays that were ABOUT the album. A playlist of 40 tracks
|
||||
-- from 40 albums used to add one play to each of them, which is
|
||||
-- how "Most Played Albums" came to be a list of whatever playlist
|
||||
-- the user had on. `''` is a legacy row or one written by the
|
||||
-- other frontend, and is counted — dropping it would erase
|
||||
-- history that is almost entirely genuine album listening.
|
||||
WHERE context_kind IN ('', 'album')
|
||||
GROUP BY album_id
|
||||
) p ON p.album_id = m.album_id
|
||||
ORDER BY p.plays DESC, p.last_at DESC
|
||||
@@ -207,7 +236,6 @@ fn query_on(conn: &Connection, limit: Option<u32>) -> Vec<AlbumPlayRow> {
|
||||
/// Record a play. Called from `playback::record_recent` when a track starts
|
||||
/// audible playback. No-op when the album id is empty (some local/Plex
|
||||
/// sources carry none — same guard as the recently-played rail).
|
||||
#[allow(dead_code)] // wired by playback::record_recent
|
||||
pub fn record_album_play(m: AlbumPlayMeta) {
|
||||
if m.album_id.is_empty() {
|
||||
return;
|
||||
@@ -223,17 +251,45 @@ pub fn record_album_play(m: AlbumPlayMeta) {
|
||||
}
|
||||
|
||||
/// The top `limit` most-played albums (the carousel).
|
||||
#[allow(dead_code)] // wired by home/foryou
|
||||
pub fn top_albums(limit: u32) -> Vec<AlbumPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, Some(limit)))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every played album, ranked (the "View all" page).
|
||||
#[allow(dead_code)] // wired by the View-all loader
|
||||
pub fn all_albums() -> Vec<AlbumPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, None))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Drop every trace of the given albums — the twin of
|
||||
/// `playlist_play_history::prune_playlists`, for albums whose library folder
|
||||
/// was removed. Until this existed "Most Played Albums" kept cards that
|
||||
/// opened onto deleted tracks while Recently Played had already pruned them.
|
||||
/// Returns how many meta rows went.
|
||||
pub fn prune_albums(album_ids: &[String]) -> usize {
|
||||
if album_ids.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
with_db(|conn| Some(prune_on(conn, album_ids)))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn prune_on(conn: &Connection, album_ids: &[String]) -> usize {
|
||||
let mut gone = 0usize;
|
||||
for id in album_ids {
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM album_play_events WHERE album_id = ?",
|
||||
rusqlite::params![id],
|
||||
);
|
||||
gone += conn
|
||||
.execute(
|
||||
"DELETE FROM album_meta WHERE album_id = ?",
|
||||
rusqlite::params![id],
|
||||
)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
gone
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -249,6 +305,7 @@ mod tests {
|
||||
quality_label: "Hi-Res",
|
||||
year: "2024",
|
||||
source: "qobuz",
|
||||
context_kind: "album",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ impl BlacklistService {
|
||||
.map_err(|e| format!("Failed to open blacklist database: {}", e))?;
|
||||
|
||||
// Enable WAL mode for better concurrent access (ADR-002).
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||
|
||||
let service = Self {
|
||||
@@ -179,7 +179,7 @@ impl BlacklistService {
|
||||
.map_err(|e| format!("Failed to prepare blacklist query: {}", e))?;
|
||||
|
||||
let ids: Vec<u64> = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.query_map([], |row| row.get::<_, i64>(0).map(|v| v as u64))
|
||||
.map_err(|e| format!("Failed to query blacklist: {}", e))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! A portable blacklist: the blocked artists and albums as one JSON file a
|
||||
//! user can hand to another QBZ user, or carry to another account.
|
||||
//!
|
||||
//! Frontend-agnostic on purpose (ADR 006): the Qt shell and the daemon both
|
||||
//! read and write the same document. The import is ADDITIVE — it never
|
||||
//! removes an entry, never rewrites one that is already blocked, and never
|
||||
//! touches the enabled flag — so importing a friend's file twice, or your
|
||||
//! own old file over a newer list, is always safe.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::artist_blacklist::BlacklistService;
|
||||
|
||||
/// The `kind` discriminator; a file without it is not ours.
|
||||
pub const KIND: &str = "qbz-blacklist";
|
||||
/// Bumped only when a reader would misread an older file. Readers accept
|
||||
/// any version up to their own.
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PortableArtist {
|
||||
pub artist_id: u64,
|
||||
pub artist_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PortableAlbum {
|
||||
pub album_id: String,
|
||||
pub album_title: String,
|
||||
#[serde(default)]
|
||||
pub artist_name: String,
|
||||
#[serde(default)]
|
||||
pub cover_url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BlacklistBundle {
|
||||
pub kind: String,
|
||||
pub schema_version: u32,
|
||||
/// RFC 3339, informational.
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub artists: Vec<PortableArtist>,
|
||||
#[serde(default)]
|
||||
pub albums: Vec<PortableAlbum>,
|
||||
}
|
||||
|
||||
/// What an import did. `existing` entries were already blocked and were
|
||||
/// left exactly as they were (their own notes and dates win).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportReport {
|
||||
pub artists_added: usize,
|
||||
pub artists_existing: usize,
|
||||
pub albums_added: usize,
|
||||
pub albums_existing: usize,
|
||||
}
|
||||
|
||||
impl ImportReport {
|
||||
pub fn added(&self) -> usize {
|
||||
self.artists_added + self.albums_added
|
||||
}
|
||||
pub fn existing(&self) -> usize {
|
||||
self.artists_existing + self.albums_existing
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the store into a bundle. Ids, names and notes only: the
|
||||
/// `added_at` stamps stay local (the importer stamps its own).
|
||||
pub fn export(service: &BlacklistService) -> Result<BlacklistBundle, String> {
|
||||
let artists = service
|
||||
.get_all()?
|
||||
.into_iter()
|
||||
.map(|a| PortableArtist {
|
||||
artist_id: a.artist_id,
|
||||
artist_name: a.artist_name,
|
||||
notes: a.notes,
|
||||
})
|
||||
.collect();
|
||||
let albums = service
|
||||
.get_all_albums()?
|
||||
.into_iter()
|
||||
.map(|a| PortableAlbum {
|
||||
album_id: a.album_id,
|
||||
album_title: a.album_title,
|
||||
artist_name: a.artist_name,
|
||||
cover_url: a.cover_url,
|
||||
notes: a.notes,
|
||||
})
|
||||
.collect();
|
||||
Ok(BlacklistBundle {
|
||||
kind: KIND.to_string(),
|
||||
schema_version: SCHEMA_VERSION,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
artists,
|
||||
albums,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse and validate a bundle. A file of another kind or from a newer
|
||||
/// schema is refused rather than half-read.
|
||||
pub fn parse(text: &str) -> Result<BlacklistBundle, String> {
|
||||
let bundle: BlacklistBundle =
|
||||
serde_json::from_str(text).map_err(|e| format!("not a QBZ blacklist file: {e}"))?;
|
||||
if bundle.kind != KIND {
|
||||
return Err(format!("not a QBZ blacklist file (kind `{}`)", bundle.kind));
|
||||
}
|
||||
if bundle.schema_version > SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"blacklist file schema {} is newer than this QBZ understands ({})",
|
||||
bundle.schema_version, SCHEMA_VERSION
|
||||
));
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
/// Additive merge into the store. Entries already blocked are skipped
|
||||
/// (`INSERT OR REPLACE` would otherwise overwrite their notes and dates);
|
||||
/// the enabled flag is not touched.
|
||||
pub fn import(
|
||||
service: &BlacklistService,
|
||||
bundle: &BlacklistBundle,
|
||||
) -> Result<ImportReport, String> {
|
||||
// Existence by STORED id, not `is_blacklisted`: that predicate also
|
||||
// folds in the enabled flag, and a disabled blacklist must still keep
|
||||
// its rows through an import.
|
||||
let existing_artists: std::collections::HashSet<u64> = service
|
||||
.get_all()?
|
||||
.into_iter()
|
||||
.map(|a| a.artist_id)
|
||||
.collect();
|
||||
let existing_albums: std::collections::HashSet<String> = service
|
||||
.get_all_albums()?
|
||||
.into_iter()
|
||||
.map(|a| a.album_id)
|
||||
.collect();
|
||||
let mut report = ImportReport::default();
|
||||
for artist in &bundle.artists {
|
||||
if existing_artists.contains(&artist.artist_id) {
|
||||
report.artists_existing += 1;
|
||||
continue;
|
||||
}
|
||||
service.add(
|
||||
artist.artist_id,
|
||||
&artist.artist_name,
|
||||
artist.notes.as_deref(),
|
||||
)?;
|
||||
report.artists_added += 1;
|
||||
}
|
||||
for album in &bundle.albums {
|
||||
if album.album_id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if existing_albums.contains(&album.album_id) {
|
||||
report.albums_existing += 1;
|
||||
continue;
|
||||
}
|
||||
service.add_album(
|
||||
&album.album_id,
|
||||
&album.album_title,
|
||||
&album.artist_name,
|
||||
&album.cover_url,
|
||||
album.notes.as_deref(),
|
||||
)?;
|
||||
report.albums_added += 1;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// `qbz-blacklist-YYYYMMDD.json`, next to the settings bundle's naming.
|
||||
pub fn default_filename() -> String {
|
||||
format!("qbz-blacklist-{}.json", chrono::Utc::now().format("%Y%m%d"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> BlacklistService {
|
||||
BlacklistService::new_in_memory().expect("in-memory blacklist")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_then_import_into_an_empty_store_is_lossless() {
|
||||
let src = store();
|
||||
src.add(1, "Anthrax", Some("wrong Anthrax")).unwrap();
|
||||
src.add(2, "Nickelback", None).unwrap();
|
||||
src.add_album(
|
||||
"abc123",
|
||||
"Bad Album",
|
||||
"Someone",
|
||||
"https://x/cover.jpg",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let bundle = export(&src).unwrap();
|
||||
assert_eq!(bundle.kind, KIND);
|
||||
assert_eq!(bundle.artists.len(), 2);
|
||||
assert_eq!(bundle.albums.len(), 1);
|
||||
|
||||
let text = serde_json::to_string(&bundle).unwrap();
|
||||
let dst = store();
|
||||
let report = import(&dst, &parse(&text).unwrap()).unwrap();
|
||||
assert_eq!(report.artists_added, 2);
|
||||
assert_eq!(report.albums_added, 1);
|
||||
assert_eq!(report.existing(), 0);
|
||||
assert!(dst.is_blacklisted(1));
|
||||
assert!(dst.is_album_blacklisted("abc123"));
|
||||
let anthrax = dst
|
||||
.get_all()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|a| a.artist_id == 1)
|
||||
.unwrap();
|
||||
assert_eq!(anthrax.notes.as_deref(), Some("wrong Anthrax"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_is_additive_and_leaves_existing_entries_alone() {
|
||||
let dst = store();
|
||||
dst.add(1, "Anthrax (thrash)", Some("mine")).unwrap();
|
||||
dst.set_enabled(false).unwrap();
|
||||
let bundle = BlacklistBundle {
|
||||
kind: KIND.into(),
|
||||
schema_version: SCHEMA_VERSION,
|
||||
created_at: String::new(),
|
||||
artists: vec![
|
||||
PortableArtist {
|
||||
artist_id: 1,
|
||||
artist_name: "Anthrax".into(),
|
||||
notes: Some("theirs".into()),
|
||||
},
|
||||
PortableArtist {
|
||||
artist_id: 3,
|
||||
artist_name: "Creed".into(),
|
||||
notes: None,
|
||||
},
|
||||
],
|
||||
albums: vec![],
|
||||
};
|
||||
let report = import(&dst, &bundle).unwrap();
|
||||
assert_eq!(report.artists_added, 1);
|
||||
assert_eq!(report.artists_existing, 1);
|
||||
// The existing row kept its own name and notes; the flag is untouched.
|
||||
let mine = dst
|
||||
.get_all()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|a| a.artist_id == 1)
|
||||
.unwrap();
|
||||
assert_eq!(mine.artist_name, "Anthrax (thrash)");
|
||||
assert_eq!(mine.notes.as_deref(), Some("mine"));
|
||||
assert!(!dst.is_enabled());
|
||||
// Importing the same file again changes nothing.
|
||||
let again = import(&dst, &bundle).unwrap();
|
||||
assert_eq!(again.added(), 0);
|
||||
assert_eq!(again.existing(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_refuses_other_files_and_newer_schemas() {
|
||||
assert!(parse("{}").is_err());
|
||||
assert!(parse(r#"{"kind":"qbz-settings","schema_version":1,"created_at":""}"#).is_err());
|
||||
assert!(parse(&format!(
|
||||
r#"{{"kind":"qbz-blacklist","schema_version":{},"created_at":""}}"#,
|
||||
SCHEMA_VERSION + 1
|
||||
))
|
||||
.is_err());
|
||||
let ok = parse(r#"{"kind":"qbz-blacklist","schema_version":1,"created_at":"x"}"#).unwrap();
|
||||
assert!(ok.artists.is_empty() && ok.albums.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -736,6 +736,7 @@ fn plan_audio(
|
||||
| "output_device"
|
||||
| "alsa_plugin"
|
||||
| "alsa_hardware_volume"
|
||||
| "alsa_hardware_volume_controls"
|
||||
| "dsd_mode"
|
||||
)
|
||||
|| k.eq_ignore_ascii_case("volume");
|
||||
@@ -922,15 +923,22 @@ fn plan_audio_machine(
|
||||
}
|
||||
}
|
||||
|
||||
// alsa_plugin / alsa_hardware_volume — apply only with a validated ALSA device.
|
||||
// ALSA plugin / hardware-volume flag / exact route map — apply only with
|
||||
// a validated ALSA device. Imported identities are data only; the active
|
||||
// route is probed again before any mixer write.
|
||||
let resolved_backend_alsa = resolved_backend_is_alsa(map, current, fallback, forced_device);
|
||||
for key in ["alsa_plugin", "alsa_hardware_volume"] {
|
||||
for key in [
|
||||
"alsa_plugin",
|
||||
"alsa_hardware_volume",
|
||||
"alsa_hardware_volume_controls",
|
||||
] {
|
||||
let Some(v) = map.get(key) else { continue };
|
||||
let no_change = alsa_field_no_change(current, key, v);
|
||||
if no_change {
|
||||
applied_line(plan, &format!("audio.{key}"), v, "");
|
||||
} else if device_survives && resolved_backend_alsa {
|
||||
applied_line(plan, &format!("audio.{key}"), v, "rides the ALSA device");
|
||||
plan.routing_critical_changed = true;
|
||||
} else {
|
||||
plan.skipped.push(skip_line(
|
||||
&format!("audio.{key}"),
|
||||
@@ -978,6 +986,7 @@ fn backend_name(b: AudioBackendType) -> &'static str {
|
||||
AudioBackendType::Alsa => "Alsa",
|
||||
AudioBackendType::Pulse => "Pulse",
|
||||
AudioBackendType::Jack => "Jack",
|
||||
AudioBackendType::WasapiExclusive => "WasapiExclusive",
|
||||
AudioBackendType::SystemDefault => "SystemDefault",
|
||||
}
|
||||
}
|
||||
@@ -996,6 +1005,12 @@ fn intent_flag_current(current: &AudioSettings, flag: &str) -> bool {
|
||||
fn alsa_field_no_change(current: &AudioSettings, key: &str, v: &Value) -> bool {
|
||||
match key {
|
||||
"alsa_hardware_volume" => v.as_bool() == Some(current.alsa_hardware_volume),
|
||||
"alsa_hardware_volume_controls" => {
|
||||
serde_json::to_value(¤t.alsa_hardware_volume_controls)
|
||||
.ok()
|
||||
.as_ref()
|
||||
== Some(v)
|
||||
}
|
||||
"alsa_plugin" => {
|
||||
let cur = serde_json::to_value(current.alsa_plugin).unwrap_or(Value::Null);
|
||||
*v == cur
|
||||
@@ -1165,6 +1180,11 @@ fn apply_audio_writes(data_root: &Path, writes: &[(&str, &Value)]) -> Result<(),
|
||||
store.set_alsa_plugin(p)?;
|
||||
}
|
||||
"alsa_hardware_volume" => store.set_alsa_hardware_volume(as_bool(value))?,
|
||||
"alsa_hardware_volume_controls" => {
|
||||
let controls = serde_json::from_value((*value).clone())
|
||||
.map_err(|e| format!("alsa_hardware_volume_controls: {e}"))?;
|
||||
store.set_alsa_hardware_volume_controls(controls)?;
|
||||
}
|
||||
"exclusive_mode" => store.set_exclusive_mode(as_bool(value))?,
|
||||
"dac_passthrough" => store.set_dac_passthrough(as_bool(value))?,
|
||||
"pw_force_bitperfect" => store.set_pw_force_bitperfect(as_bool(value))?,
|
||||
@@ -1425,7 +1445,7 @@ fn qconnect_kv_read(db: &Path, key: &str) -> Option<String> {
|
||||
|
||||
fn qconnect_kv_write(db: &Path, key: &str, value: Option<&str>) -> Result<(), String> {
|
||||
let conn = rusqlite::Connection::open(db).map_err(|e| e.to_string())?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
||||
@@ -1512,17 +1532,63 @@ fn desktop_paths() -> ProfilePaths {
|
||||
}
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
std::env::var("HOSTNAME")
|
||||
.ok()
|
||||
.filter(|h| !h.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::fs::read_to_string("/etc/hostname")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
/// The machine's name, for user-facing device labels.
|
||||
///
|
||||
/// `HOSTNAME` is a SHELL variable rather than an environment one on most Linux
|
||||
/// setups, hence the file fallback. Windows exports `COMPUTERNAME` and has no
|
||||
/// `/etc/hostname`, so every Windows install used to land on the same
|
||||
/// "unknown" and collide on Qobuz Connect.
|
||||
///
|
||||
/// The single implementation: `qbz-qt` and `qbzd` both call it and keep only
|
||||
/// their own last-resort word, which differ on purpose.
|
||||
pub fn hostname() -> String {
|
||||
for var in ["HOSTNAME", "COMPUTERNAME"] {
|
||||
if let Ok(h) = std::env::var(var) {
|
||||
let h = h.trim();
|
||||
if !h.is_empty() {
|
||||
return h.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
|
||||
let h = h.trim();
|
||||
if !h.is_empty() {
|
||||
return h.to_string();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hostname_tests {
|
||||
/// ONE test, not two: the environment is process-global and cargo runs
|
||||
/// tests on parallel threads, so a sibling that sets HOSTNAME races a
|
||||
/// sibling that clears it. Splitting these failed exactly that way.
|
||||
#[test]
|
||||
fn computername_is_the_windows_source_and_hostname_still_wins() {
|
||||
let saved_host = std::env::var("HOSTNAME").ok();
|
||||
let saved_computer = std::env::var("COMPUTERNAME").ok();
|
||||
|
||||
std::env::remove_var("HOSTNAME");
|
||||
std::env::set_var("COMPUTERNAME", "WINBOX");
|
||||
assert_eq!(super::hostname(), "WINBOX", "Windows has only COMPUTERNAME");
|
||||
|
||||
std::env::set_var("HOSTNAME", "gentoo-box");
|
||||
assert_eq!(
|
||||
super::hostname(),
|
||||
"gentoo-box",
|
||||
"HOSTNAME keeps precedence, so Linux behaviour does not move"
|
||||
);
|
||||
|
||||
match saved_host {
|
||||
Some(v) => std::env::set_var("HOSTNAME", v),
|
||||
None => std::env::remove_var("HOSTNAME"),
|
||||
}
|
||||
match saved_computer {
|
||||
Some(v) => std::env::set_var("COMPUTERNAME", v),
|
||||
None => std::env::remove_var("COMPUTERNAME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_rfc3339() -> String {
|
||||
|
||||
@@ -483,6 +483,43 @@ fn apply_writes_are_idempotent_and_persist() {
|
||||
cleanup(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_alsa_mixer_route_map_survives_bundle_plan_and_apply() {
|
||||
let p = scratch("alsa-mixer-map");
|
||||
let route = "alsa-route-v1|card=by-id:usb-D50-00|pcm=hw|DEV=0";
|
||||
let bundle = bundle_with(json!({
|
||||
"audio": {
|
||||
"output_device": "hw:1,0",
|
||||
"backend_type": "Alsa",
|
||||
"alsa_hardware_volume": false,
|
||||
"alsa_hardware_volume_controls": {
|
||||
(route): { "name": "D50 III", "index": 0 }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let plan = plan(&bundle, &p, &ImportOptions::default(), &live()).expect("plan");
|
||||
assert!(
|
||||
find(&plan.applied, "audio.alsa_hardware_volume_controls").is_some(),
|
||||
"exact route map must be classified"
|
||||
);
|
||||
assert!(plan.routing_critical_changed);
|
||||
apply(&plan, &p, None).expect("apply map");
|
||||
|
||||
let audio = AudioSettingsStore::new_at(&p.data_root).unwrap().get_settings().unwrap();
|
||||
let route_key =
|
||||
serde_json::from_value::<qbz_audio::alsa_hardware_volume::StableAlsaRouteKey>(json!(route))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
audio
|
||||
.alsa_hardware_volume_controls
|
||||
.get(&route_key)
|
||||
.map(|control| (control.name.as_str(), control.index)),
|
||||
Some(("D50 III", 0))
|
||||
);
|
||||
cleanup(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_json_roundtrips_flat() {
|
||||
// The on-disk shape is flat (§2.9): header + domains at the top level.
|
||||
|
||||
@@ -16,6 +16,12 @@ pub struct DaemonPrefs {
|
||||
/// (CONSOLE ext). Default ON; the `QBZD_MPRIS` env var overrides it when
|
||||
/// set. Toggling this needs a daemon restart (the service spawns at boot).
|
||||
pub mpris_enabled: bool,
|
||||
/// Absolute path of an executable the daemon runs on playback/session
|
||||
/// events (CONSOLE ext), with the event in `QBZ_*` environment variables.
|
||||
/// Empty = hooks off (the default). The `QBZD_HOOK` env var overrides it
|
||||
/// when set. Changing it needs a daemon restart (the dispatcher spawns at
|
||||
/// boot). Machine-local by design — NEVER part of the settings bundle.
|
||||
pub hook_script: String,
|
||||
}
|
||||
impl Default for DaemonPrefs {
|
||||
fn default() -> Self {
|
||||
@@ -23,6 +29,7 @@ impl Default for DaemonPrefs {
|
||||
streaming_quality: "hires_plus".into(),
|
||||
volume: 0.5,
|
||||
mpris_enabled: true,
|
||||
hook_script: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +42,11 @@ pub fn load_at(data_root: &Path) -> DaemonPrefs {
|
||||
// forward-compatible: unknown fields ignored WITH a warning (01 §10.3)
|
||||
if let Some(obj) = v.as_object() {
|
||||
for k in obj.keys() {
|
||||
if k != "streaming_quality" && k != "volume" && k != "mpris_enabled" {
|
||||
if k != "streaming_quality"
|
||||
&& k != "volume"
|
||||
&& k != "mpris_enabled"
|
||||
&& k != "hook_script"
|
||||
{
|
||||
log::warn!("[daemon_prefs] unknown field ignored: {k}");
|
||||
}
|
||||
}
|
||||
@@ -92,6 +103,8 @@ mod tests {
|
||||
// Non-default (default is true) so the roundtrip actually exercises
|
||||
// the field, not just its presence.
|
||||
mpris_enabled: false,
|
||||
// Non-default (default is empty) for the same reason.
|
||||
hook_script: "/usr/local/bin/qbz-hook.sh".into(),
|
||||
};
|
||||
|
||||
save_at(&prefs, &dir).expect("save daemon prefs");
|
||||
@@ -100,6 +113,7 @@ mod tests {
|
||||
assert_eq!(loaded.streaming_quality, "cd");
|
||||
assert_eq!(loaded.volume, 0.72);
|
||||
assert!(!loaded.mpris_enabled);
|
||||
assert_eq!(loaded.hook_script, "/usr/local/bin/qbz-hook.sh");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ impl DeveloperSettingsStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open developer settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for developer settings database: {}",
|
||||
|
||||
@@ -88,6 +88,16 @@ pub enum DiscoverySectionId {
|
||||
/// "Most Played Albums" — top albums by local play count
|
||||
/// (`qbz_app::settings::album_play_history`). Home + For You, default off.
|
||||
MostPlayedAlbums,
|
||||
/// "Recently Played Playlists" — the playlists the user actually chose to
|
||||
/// play, by last play (`qbz_app::settings::playlist_play_history`).
|
||||
///
|
||||
/// Its sibling `RecentlyPlayedAlbums` used to absorb these: every track
|
||||
/// start wrote its ALBUM into the recent list whatever the user had put on,
|
||||
/// so playing one 40-track playlist filled a 24-entry album rail with that
|
||||
/// playlist's contents. Plays are separated by context now, and this is
|
||||
/// where the playlist half surfaces. Home + For You, default ON — it is the
|
||||
/// answer to a question the album rail was silently answering wrong.
|
||||
RecentlyPlayedPlaylists,
|
||||
}
|
||||
|
||||
impl DiscoverySectionId {
|
||||
@@ -115,6 +125,7 @@ impl DiscoverySectionId {
|
||||
ArtistSpotlight => "artistSpotlight",
|
||||
Pinned => "pinned",
|
||||
MostPlayedAlbums => "mostPlayedAlbums",
|
||||
RecentlyPlayedPlaylists => "recentlyPlayedPlaylists",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +153,7 @@ impl DiscoverySectionId {
|
||||
"artistSpotlight" => ArtistSpotlight,
|
||||
"pinned" => Pinned,
|
||||
"mostPlayedAlbums" => MostPlayedAlbums,
|
||||
"recentlyPlayedPlaylists" => RecentlyPlayedPlaylists,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -192,6 +204,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(PressAwards, true),
|
||||
pref(QobuzPlaylists, true),
|
||||
pref(RecentlyPlayedAlbums, true),
|
||||
pref(RecentlyPlayedPlaylists, true),
|
||||
pref(ContinueListening, true),
|
||||
pref(IdealDiscography, true),
|
||||
pref(MostStreamed, true),
|
||||
@@ -223,6 +236,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(RadioStations, true),
|
||||
pref(ContinueListening, true),
|
||||
pref(RecentlyPlayedAlbums, true),
|
||||
pref(RecentlyPlayedPlaylists, true),
|
||||
pref(TopArtists, true),
|
||||
pref(FavoriteAlbums, true),
|
||||
pref(SimilarAlbums, true),
|
||||
@@ -418,6 +432,24 @@ pub fn reconcile_list(persisted: Option<&Vec<Value>>, fallback: &[SectionPref])
|
||||
// SQLite store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The rail sizes the UI offers, IN THE ORDER THE UI OFFERS THEM — the selector
|
||||
/// sends back an index into this array. Reordering is safe ONLY because what is
|
||||
/// STORED is the size and never the index (`save_rail_size` takes the count,
|
||||
/// `rail_size_index_of` derives the index back), so a stored 15 keeps meaning
|
||||
/// 15 whatever position it sits in. Keep that invariant or this comment becomes
|
||||
/// a trap.
|
||||
///
|
||||
/// There is no "uncapped" entry, and that is deliberate. It used to read "All",
|
||||
/// which was a lie twice over: an "all" in Qobuz terms is what the View-all
|
||||
/// page holds, and /discover/index hands each rail a DIFFERENT number anyway —
|
||||
/// measured on a live response, new_releases 26, ideal_discography 26,
|
||||
/// playlists 20, most_streamed 15, and qobuzissims / album_of_the_week /
|
||||
/// press_awards 10. One label could not be honest about all of them. So the
|
||||
/// default is now a real, uniform cap of ten and the entry says so.
|
||||
pub const RAIL_SIZE_PRESETS: [i64; 4] = [10, 15, 20, 25];
|
||||
|
||||
pub const DEFAULT_RAIL_SIZE: i64 = 0;
|
||||
|
||||
pub struct DiscoverPrefsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
@@ -431,7 +463,7 @@ impl DiscoverPrefsStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open discover prefs database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to enable WAL for discover prefs database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
@@ -448,6 +480,33 @@ impl DiscoverPrefsStore {
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize discover prefs: {}", e))?;
|
||||
|
||||
// Rail size lives in its OWN TABLE, not as a key inside `prefs_json`,
|
||||
// and that is a data-safety decision rather than a stylistic one.
|
||||
//
|
||||
// `DiscoverPrefs::to_json` rebuilds the blob FROM SCRATCH out of the
|
||||
// keys the running binary knows, and `save` overwrites the whole
|
||||
// column with it. This .db is SHARED with the Slint build, whose tree
|
||||
// is frozen and will not be recompiled — so the installed Slint binary
|
||||
// has the old `to_json` permanently. A new key inside the blob would
|
||||
// therefore survive exactly until the user touched any toggle over
|
||||
// there, and then vanish, with nothing logged anywhere.
|
||||
//
|
||||
// A separate table cannot be hit that way: the old binary never names
|
||||
// it in any statement, and `CREATE TABLE IF NOT EXISTS` is idempotent
|
||||
// in whichever build opens the file first. WAL + busy_timeout are
|
||||
// already set above, so two binaries on one file is the existing
|
||||
// design, not something this adds.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS discover_rail_sizes (
|
||||
section_id TEXT PRIMARY KEY,
|
||||
rail_size INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create discover rail size table: {}", e))?;
|
||||
// No seed row: ABSENT means the default, which is uncapped. Writing a
|
||||
// row per section at open would put twenty rows in the file to say
|
||||
// "unchanged", and would then have to be kept in step with the enum.
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
@@ -480,6 +539,55 @@ impl DiscoverPrefsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many items each Discover rail may show, by section id. `0` = no
|
||||
/// cap, and a section ABSENT from the map is uncapped too.
|
||||
///
|
||||
/// Never an error to the caller: a missing table (an older file) or a value
|
||||
/// outside the presets yields an empty map / drops that row.
|
||||
pub fn load_rail_sizes(&self) -> std::collections::HashMap<String, i64> {
|
||||
let out = (|| -> Option<std::collections::HashMap<String, i64>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT section_id, rail_size FROM discover_rail_sizes")
|
||||
.ok()?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})
|
||||
.ok()?;
|
||||
Some(
|
||||
rows.flatten()
|
||||
.filter(|(_, n)| RAIL_SIZE_PRESETS.contains(n))
|
||||
.collect(),
|
||||
)
|
||||
})();
|
||||
out.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist ONE rail's size. Values outside the presets are rejected rather
|
||||
/// than stored, so what loads back and the UI's index can never disagree.
|
||||
/// The DEFAULT deletes the row instead of storing it — the file then only
|
||||
/// carries the rails the user actually changed.
|
||||
pub fn save_rail_size(&self, section_id: &str, n: i64) -> Result<(), String> {
|
||||
if !RAIL_SIZE_PRESETS.contains(&n) {
|
||||
return Err(format!("rail size {n} is not one of {RAIL_SIZE_PRESETS:?}"));
|
||||
}
|
||||
let sql = if n == DEFAULT_RAIL_SIZE {
|
||||
self.conn.execute(
|
||||
"DELETE FROM discover_rail_sizes WHERE section_id = ?1",
|
||||
params![section_id],
|
||||
)
|
||||
} else {
|
||||
self.conn.execute(
|
||||
"INSERT INTO discover_rail_sizes (section_id, rail_size) VALUES (?1, ?2)
|
||||
ON CONFLICT(section_id) DO UPDATE SET rail_size = excluded.rail_size",
|
||||
params![section_id, n],
|
||||
)
|
||||
};
|
||||
sql.map_err(|e| format!("Failed to save discover rail size: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the whole prefs blob (upsert row 1).
|
||||
pub fn save(&self, prefs: &DiscoverPrefs) -> Result<(), String> {
|
||||
self.conn
|
||||
@@ -525,18 +633,20 @@ mod tests {
|
||||
#[test]
|
||||
fn defaults_match_spec_exactly() {
|
||||
let d = default_prefs();
|
||||
// home: 15 entries, first 8 ON (Tauri sectionPrefs.ts + Slint `pinned`
|
||||
// + the local mostPlayedAlbums, default off).
|
||||
// home: 16 entries, first 9 ON (Tauri sectionPrefs.ts + Slint `pinned`
|
||||
// + the local mostPlayedAlbums, default off, + recentlyPlayedPlaylists
|
||||
// sitting right after its album sibling, default on).
|
||||
assert_eq!(
|
||||
ids(&d.home),
|
||||
vec![
|
||||
NewReleases, PressAwards, QobuzPlaylists, RecentlyPlayedAlbums,
|
||||
RecentlyPlayedPlaylists,
|
||||
ContinueListening, IdealDiscography, MostStreamed, Pinned,
|
||||
QobuzMixes, ReleaseWatch, EditorPicks, Qobuzissimes, TopArtists,
|
||||
FavoriteAlbums, MostPlayedAlbums,
|
||||
]
|
||||
);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::Home), 8);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::Home), 9);
|
||||
assert!(d.is_enabled(DiscoveryTab::Home, MostStreamed));
|
||||
assert!(!d.is_enabled(DiscoveryTab::Home, Qobuzissimes));
|
||||
// editorPicks: 7 entries, all ON.
|
||||
@@ -545,13 +655,15 @@ mod tests {
|
||||
vec![NewReleases, EditorPicks, Qobuzissimes, PressAwards, MostStreamed, IdealDiscography, QobuzPlaylists]
|
||||
);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::EditorPicks), 7);
|
||||
// forYou: 14 entries, qobuzMixes first, pinned second; the 13
|
||||
// Tauri+Slint ones ON, mostPlayedAlbums (local addition) OFF.
|
||||
assert_eq!(d.for_you.len(), 14);
|
||||
// forYou: 15 entries, qobuzMixes first, pinned second; the 13
|
||||
// Tauri+Slint ones plus recentlyPlayedPlaylists ON, mostPlayedAlbums
|
||||
// (local addition) OFF.
|
||||
assert_eq!(d.for_you.len(), 15);
|
||||
assert_eq!(d.for_you[0].id, QobuzMixes);
|
||||
assert_eq!(d.for_you[1].id, Pinned);
|
||||
assert_eq!(d.for_you[13].id, MostPlayedAlbums);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 13);
|
||||
assert_eq!(d.for_you[6].id, RecentlyPlayedPlaylists);
|
||||
assert_eq!(d.for_you[14].id, MostPlayedAlbums);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 14);
|
||||
}
|
||||
|
||||
// --- Group 2: reconcile_list ---
|
||||
|
||||
@@ -42,7 +42,7 @@ impl FavoritesPreferencesStore {
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open favorites preferences database: {}", e))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to set WAL mode on favorites preferences: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
|
||||
@@ -25,7 +25,7 @@ impl FavoritesCacheStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open favorites cache database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to enable WAL for favorites cache database: {}", e))?;
|
||||
|
||||
// Create tables
|
||||
|
||||
@@ -86,7 +86,7 @@ impl GraphicsSettingsStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open graphics settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to enable WAL for graphics settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Headless local-favorites service.
|
||||
//!
|
||||
//! Frontend-agnostic store for favoriting LOCAL library items (genuine local
|
||||
//! files + Plex — never the Qobuz offline cache). Mirrors `pinned_items.rs`
|
||||
//! Frontend-agnostic store for favoriting LOCAL LIBRARY items (genuine local
|
||||
//! files plus configured media servers — never the Qobuz offline cache).
|
||||
//! Mirrors `pinned_items.rs`
|
||||
//! (same pragmas, error style, in-memory `(kind, id)` set) per ADR-006; the
|
||||
//! per-user lifecycle lives in the `qbz` crate wrapper (`crate::local_favorites`).
|
||||
//!
|
||||
//! Rows carry a display snapshot (title/subtitle/artwork) taken at favorite
|
||||
//! time plus a denormalized `artist` (for per-artist counts) and `source`
|
||||
//! (`local` | `plex`). The `CHECK` on `source` refuses `qobuz_download` at
|
||||
//! (`local` | `plex` | `jellyfin` | `subsonic`). The `CHECK` on `source`
|
||||
//! refuses `qobuz_download` at
|
||||
//! write time, so the mixed-library feed built from this store is inherently
|
||||
//! free of Qobuz-offline duplicates.
|
||||
|
||||
@@ -22,9 +24,9 @@ pub const DB_FILE_NAME: &str = "local_favorites.db";
|
||||
|
||||
/// A favorited local item with its display snapshot.
|
||||
///
|
||||
/// Ids are Strings: album = the local group key (`plex:…` / contains `|`/`/`),
|
||||
/// artist = the artist NAME (local artists have no numeric id), track =
|
||||
/// `file_path` (local) or `plex:<file_path>` (Plex) — the stable key.
|
||||
/// Ids are Strings: album = the source-aware group key (`plex:…`,
|
||||
/// `jellyfin:…`, `subsonic:…` or a local key containing `|`/`/`), artist =
|
||||
/// the artist NAME, track = a source-aware stable key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalFavItem {
|
||||
/// "album" | "artist" | "track".
|
||||
@@ -35,7 +37,8 @@ pub struct LocalFavItem {
|
||||
pub artwork_url: String,
|
||||
/// Denormalized artist name (for per-artist counts); empty for kind="artist".
|
||||
pub artist: String,
|
||||
/// "local" | "plex" — never "qobuz_download".
|
||||
/// "local" | "plex" | "jellyfin" | "subsonic" — never a Qobuz
|
||||
/// download spelling.
|
||||
pub source: String,
|
||||
/// Unix seconds; the ordering key (newest first).
|
||||
pub favorited_at: i64,
|
||||
@@ -56,7 +59,9 @@ impl LocalFavoritesService {
|
||||
let conn = Connection::open(db_path)
|
||||
.map_err(|e| format!("Failed to open local favorites database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||
|
||||
let service = Self {
|
||||
@@ -97,17 +102,68 @@ impl LocalFavoritesService {
|
||||
subtitle TEXT,
|
||||
artwork_url TEXT,
|
||||
artist TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('local','plex')),
|
||||
source TEXT NOT NULL CHECK (
|
||||
source IN ('local','plex','jellyfin','subsonic')
|
||||
),
|
||||
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
PRIMARY KEY (kind, id)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize local favorites schema: {}", e))?;
|
||||
|
||||
// v1 accepted only local/Plex. SQLite cannot widen a CHECK in place,
|
||||
// so rebuild the tiny snapshot table atomically while preserving every
|
||||
// existing favorite. Index creation deliberately follows the rebuild:
|
||||
// ALTER TABLE carries the old index names to the legacy table.
|
||||
let table_sql = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='local_favorites'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to inspect local favorites schema: {e}"))?;
|
||||
if !table_sql.contains("'jellyfin'") || !table_sql.contains("'subsonic'") {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
BEGIN IMMEDIATE;
|
||||
ALTER TABLE local_favorites RENAME TO local_favorites_v1;
|
||||
CREATE TABLE local_favorites (
|
||||
kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')),
|
||||
id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
artwork_url TEXT,
|
||||
artist TEXT,
|
||||
source TEXT NOT NULL CHECK (
|
||||
source IN ('local','plex','jellyfin','subsonic')
|
||||
),
|
||||
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
PRIMARY KEY (kind, id)
|
||||
);
|
||||
INSERT INTO local_favorites(
|
||||
kind,id,title,subtitle,artwork_url,artist,source,favorited_at
|
||||
)
|
||||
SELECT kind,id,title,subtitle,artwork_url,artist,source,favorited_at
|
||||
FROM local_favorites_v1;
|
||||
DROP TABLE local_favorites_v1;
|
||||
COMMIT;
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to migrate local favorites schema: {e}"))?;
|
||||
}
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_local_favorites_at
|
||||
ON local_favorites(favorited_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_favorites_artist
|
||||
ON local_favorites(kind, artist);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize local favorites schema: {}", e))?;
|
||||
.map_err(|e| format!("Failed to index local favorites schema: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -298,10 +354,48 @@ mod tests {
|
||||
#[test]
|
||||
fn source_check_rejects_offline() {
|
||||
let s = LocalFavoritesService::new_in_memory().expect("svc");
|
||||
s.favorite(&item("album", "jf", "J", "A", "jellyfin"))
|
||||
.expect("Jellyfin belongs to the Local Library favorite domain");
|
||||
s.favorite(&item("album", "sub", "S", "A", "subsonic"))
|
||||
.expect("Subsonic belongs to the Local Library favorite domain");
|
||||
assert!(
|
||||
s.favorite(&item("album", "x", "X", "A", "qobuz_download"))
|
||||
.is_err(),
|
||||
"the source CHECK refuses qobuz-offline rows"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_schema_migrates_without_losing_existing_favorites() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE local_favorites (
|
||||
kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')),
|
||||
id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
artwork_url TEXT,
|
||||
artist TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('local','plex')),
|
||||
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
PRIMARY KEY (kind, id)
|
||||
);
|
||||
INSERT INTO local_favorites(
|
||||
kind,id,title,subtitle,artwork_url,artist,source,favorited_at
|
||||
) VALUES ('album','plex:old','Old','','','Artist','plex',42);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let service = LocalFavoritesService {
|
||||
conn,
|
||||
keys: RwLock::new(HashSet::new()),
|
||||
};
|
||||
service.init_schema().unwrap();
|
||||
service.load_from_db().unwrap();
|
||||
assert!(service.is_favorite("album", "plex:old"));
|
||||
service
|
||||
.favorite(&item("album", "jellyfin:new", "New", "Artist", "jellyfin"))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Per-user connection settings for the media servers QBZ reads.
|
||||
//!
|
||||
//! ONE store for Jellyfin and Subsonic, keyed by a `server` column — the same
|
||||
//! decision `qbz-media-cache` makes about their tracks, for the same reason:
|
||||
//! the two carry the same fields, and a store per server means every future one
|
||||
//! copies a file. Plex keeps its own ([`super::plex`]) until it folds into the
|
||||
//! shared pair; see that crate's header for why that is a separate change.
|
||||
//!
|
||||
//! Mirrors [`super::plex`]'s shape: a small SQLite table, opened globally via
|
||||
//! [`MediaServerStore::new`] and re-pointed at the active user's directory by
|
||||
//! [`MediaServerState::init_at`] at login, so credentials are scoped per Qobuz
|
||||
//! user.
|
||||
//!
|
||||
//! # What is stored, and what deliberately is not
|
||||
//!
|
||||
//! **Jellyfin** stores its `access_token` — the server issues it and the
|
||||
//! password is never needed again. **Subsonic has no session**: every request
|
||||
//! carries `md5(password + salt)`, so the password itself must be kept.
|
||||
//!
|
||||
//! That asymmetry is the protocol's, not a shortcut. It is also why `salt` is
|
||||
//! persisted rather than generated per request: a rolling salt would make every
|
||||
//! cover URL unique per request, and the artwork cache keys on the URL, so each
|
||||
//! pass would re-download every cover. The salt is not a secret — it travels in
|
||||
//! clear beside the token — its job is to keep one password from hashing to the
|
||||
//! same value on two installs.
|
||||
//!
|
||||
//! `device_id` is Jellyfin's, and it is load-bearing: the server keys its
|
||||
//! SESSION on it, and re-authenticating under the same one revokes the previous
|
||||
//! token (measured against 10.11.11). Generated once and persisted, so two QBZ
|
||||
//! installs never log each other out and a relaunch does not drop the token.
|
||||
|
||||
use log::info;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Which server a settings row describes. The wire values are the same words
|
||||
/// `qbz_source::SourceId` and `qbz_media_cache::RemoteSource` use.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MediaServerKind {
|
||||
Jellyfin,
|
||||
Subsonic,
|
||||
}
|
||||
|
||||
impl MediaServerKind {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
MediaServerKind::Jellyfin => "jellyfin",
|
||||
MediaServerKind::Subsonic => "subsonic",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_word(w: &str) -> Option<Self> {
|
||||
match w {
|
||||
"jellyfin" => Some(MediaServerKind::Jellyfin),
|
||||
"subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => {
|
||||
Some(MediaServerKind::Subsonic)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [MediaServerKind; 2] = [MediaServerKind::Jellyfin, MediaServerKind::Subsonic];
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MediaServerSettings {
|
||||
/// Master toggle. Default OFF — integrations are opt-in.
|
||||
pub enabled: bool,
|
||||
/// Collapse chevron state for the settings panel.
|
||||
pub ui_collapsed: bool,
|
||||
/// `proto://host:port`, resolved. Empty = not configured.
|
||||
pub base_url: String,
|
||||
/// Display name reported by the server, for the panel's "connected to …".
|
||||
pub server_name: String,
|
||||
/// The server's own id, threaded into the cache as `server_id`.
|
||||
pub server_id: String,
|
||||
/// Jellyfin: the account name. Subsonic: the account name, sent on every
|
||||
/// request.
|
||||
pub username: String,
|
||||
/// Jellyfin: the issued `AccessToken`. Empty for Subsonic.
|
||||
pub token: String,
|
||||
/// Subsonic ONLY: the password, because the protocol re-derives its token
|
||||
/// on every request. Empty for Jellyfin, which never needs it again.
|
||||
pub password: String,
|
||||
/// Subsonic ONLY: the FIXED per-install salt. See the module header.
|
||||
pub salt: String,
|
||||
/// Jellyfin ONLY: the stable per-install `DeviceId`. See the module header.
|
||||
pub device_id: String,
|
||||
/// Picked library / music-folder ids. Empty = none chosen yet, which the
|
||||
/// sync reads as "all of them" on its first run.
|
||||
pub selected_libraries: Vec<String>,
|
||||
/// Unix seconds of the last COMPLETED sweep. 0 = never swept.
|
||||
///
|
||||
/// Only a completed sweep updates it, because it is what a delta sync asks
|
||||
/// the server about — stamping it after a partial one would silently skip
|
||||
/// whatever the interrupted run never saw.
|
||||
pub last_sync_at: i64,
|
||||
/// Tracks in the cache at the end of that sweep, for the panel's summary.
|
||||
pub last_sync_tracks: i64,
|
||||
}
|
||||
|
||||
impl MediaServerSettings {
|
||||
/// Is this server usable right now — enabled, addressed, and carrying
|
||||
/// whatever credential its protocol needs?
|
||||
///
|
||||
/// The two protocols disagree about what "credentialed" means, and this is
|
||||
/// the ONE place that knows: Jellyfin needs its issued token, Subsonic
|
||||
/// needs a username and password.
|
||||
pub fn is_configured(&self, kind: MediaServerKind) -> bool {
|
||||
if !self.enabled || self.base_url.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
match kind {
|
||||
MediaServerKind::Jellyfin => !self.token.trim().is_empty(),
|
||||
MediaServerKind::Subsonic => {
|
||||
!self.username.trim().is_empty() && !self.password.is_empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MediaServerStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl MediaServerStore {
|
||||
fn open_at(dir: &Path) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {e}"))?;
|
||||
let conn = Connection::open(dir.join("media_servers.db"))
|
||||
.map_err(|e| format!("Failed to open media server settings: {e}"))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set pragmas: {e}"))?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS media_server_settings (
|
||||
server TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
ui_collapsed INTEGER NOT NULL DEFAULT 0,
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
server_name TEXT NOT NULL DEFAULT '',
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
salt TEXT NOT NULL DEFAULT '',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
selected_libraries TEXT NOT NULL DEFAULT '',
|
||||
last_sync_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_sync_tracks INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to init media server schema: {e}"))?;
|
||||
info!("[MediaServers] Database initialized");
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&dir)
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir)
|
||||
}
|
||||
|
||||
/// Read one server's settings. A row that has never been written comes back
|
||||
/// as defaults (disabled, unconfigured) rather than an error — "the user
|
||||
/// has not set this up" is not a failure.
|
||||
pub fn get(&self, kind: MediaServerKind) -> Result<MediaServerSettings, String> {
|
||||
let row = self.conn.query_row(
|
||||
"SELECT enabled, ui_collapsed, base_url, server_name, server_id, username,
|
||||
token, password, salt, device_id, selected_libraries,
|
||||
last_sync_at, last_sync_tracks
|
||||
FROM media_server_settings WHERE server = ?1",
|
||||
params![kind.as_str()],
|
||||
|r| {
|
||||
Ok(MediaServerSettings {
|
||||
enabled: r.get::<_, i64>(0)? != 0,
|
||||
ui_collapsed: r.get::<_, i64>(1)? != 0,
|
||||
base_url: r.get(2)?,
|
||||
server_name: r.get(3)?,
|
||||
server_id: r.get(4)?,
|
||||
username: r.get(5)?,
|
||||
token: r.get(6)?,
|
||||
password: r.get(7)?,
|
||||
salt: r.get(8)?,
|
||||
device_id: r.get(9)?,
|
||||
selected_libraries: split_ids(&r.get::<_, String>(10)?),
|
||||
last_sync_at: r.get(11)?,
|
||||
last_sync_tracks: r.get(12)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
match row {
|
||||
Ok(s) => Ok(s),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(MediaServerSettings::default()),
|
||||
Err(e) => Err(format!("Failed to read media server settings: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one server's settings wholesale.
|
||||
///
|
||||
/// One UPSERT rather than a setter per field: the settings panel edits a
|
||||
/// form and saves it, and a dozen single-column setters is how `plex.rs`
|
||||
/// grew to 723 lines.
|
||||
pub fn put(&self, kind: MediaServerKind, s: &MediaServerSettings) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO media_server_settings
|
||||
(server, enabled, ui_collapsed, base_url, server_name, server_id,
|
||||
username, token, password, salt, device_id, selected_libraries,
|
||||
last_sync_at, last_sync_tracks)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)
|
||||
ON CONFLICT(server) DO UPDATE SET
|
||||
enabled=excluded.enabled, ui_collapsed=excluded.ui_collapsed,
|
||||
base_url=excluded.base_url, server_name=excluded.server_name,
|
||||
server_id=excluded.server_id, username=excluded.username,
|
||||
token=excluded.token, password=excluded.password,
|
||||
salt=excluded.salt, device_id=excluded.device_id,
|
||||
selected_libraries=excluded.selected_libraries,
|
||||
last_sync_at=excluded.last_sync_at,
|
||||
last_sync_tracks=excluded.last_sync_tracks",
|
||||
params![
|
||||
kind.as_str(),
|
||||
i64::from(s.enabled),
|
||||
i64::from(s.ui_collapsed),
|
||||
s.base_url.trim(),
|
||||
s.server_name,
|
||||
s.server_id,
|
||||
s.username.trim(),
|
||||
s.token.trim(),
|
||||
s.password,
|
||||
s.salt,
|
||||
s.device_id,
|
||||
s.selected_libraries.join(","),
|
||||
s.last_sync_at,
|
||||
s.last_sync_tracks,
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to write media server settings: {e}"))
|
||||
}
|
||||
|
||||
/// Forget one server's credentials, keeping the row so its UI state and its
|
||||
/// picked libraries survive a reconnect.
|
||||
pub fn disconnect(&self, kind: MediaServerKind) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE media_server_settings
|
||||
SET enabled = 0, token = '', password = '', server_id = '',
|
||||
server_name = '', last_sync_at = 0, last_sync_tracks = 0
|
||||
WHERE server = ?1",
|
||||
params![kind.as_str()],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to disconnect media server: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn split_ids(raw: &str) -> Vec<String> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The process-global store, re-pointed at the active user at login.
|
||||
#[derive(Clone)]
|
||||
pub struct MediaServerState {
|
||||
store: Arc<Mutex<Option<MediaServerStore>>>,
|
||||
}
|
||||
|
||||
impl Default for MediaServerState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaServerState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind to the active user's directory. Called from the auth path.
|
||||
pub fn init_at(&self, dir: &Path) {
|
||||
match MediaServerStore::new_at(dir) {
|
||||
Ok(s) => {
|
||||
if let Ok(mut slot) = self.store.lock() {
|
||||
*slot = Some(s);
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("[MediaServers] init failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the handle (logout).
|
||||
pub fn reset(&self) {
|
||||
if let Ok(mut slot) = self.store.lock() {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Read. Unbound or unreadable answers with DEFAULTS — i.e. "disabled" —
|
||||
/// which is the safe direction: a settings read that fails must not make an
|
||||
/// unconfigured server look configured.
|
||||
pub fn get(&self, kind: MediaServerKind) -> MediaServerSettings {
|
||||
self.store
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().and_then(|s| s.get(kind).ok()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn put(&self, kind: MediaServerKind, s: &MediaServerSettings) {
|
||||
if let Ok(g) = self.store.lock() {
|
||||
if let Some(store) = g.as_ref() {
|
||||
if let Err(e) = store.put(kind, s) {
|
||||
log::error!("[MediaServers] write failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect(&self, kind: MediaServerKind) {
|
||||
if let Ok(g) = self.store.lock() {
|
||||
if let Some(store) = g.as_ref() {
|
||||
let _ = store.disconnect(kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every server the user has switched on AND finished configuring.
|
||||
///
|
||||
/// This is what the Local Library union filters on, so "enabled" alone is
|
||||
/// not enough: a server that is toggled on but has no credentials yet would
|
||||
/// contribute a `source IN (...)` arm over rows that were never swept.
|
||||
pub fn configured_words(&self) -> Vec<&'static str> {
|
||||
MediaServerKind::ALL
|
||||
.iter()
|
||||
.filter(|k| self.get(**k).is_configured(**k))
|
||||
.map(|k| k.as_str())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn store() -> (TempDir, MediaServerStore) {
|
||||
let t = TempDir::new().unwrap();
|
||||
let s = MediaServerStore::new_at(t.path()).unwrap();
|
||||
(t, s)
|
||||
}
|
||||
|
||||
/// A server the user has never touched reads as defaults, not as an error.
|
||||
#[test]
|
||||
fn an_unwritten_server_reads_as_disabled() {
|
||||
let (_t, s) = store();
|
||||
let jf = s.get(MediaServerKind::Jellyfin).unwrap();
|
||||
assert!(!jf.enabled);
|
||||
assert!(jf.base_url.is_empty());
|
||||
assert!(!jf.is_configured(MediaServerKind::Jellyfin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip_per_server_without_bleeding() {
|
||||
let (_t, s) = store();
|
||||
let jf = MediaServerSettings {
|
||||
enabled: true,
|
||||
base_url: "http://jf:8096".into(),
|
||||
username: "admin".into(),
|
||||
token: "tok".into(),
|
||||
device_id: "qbz-abc".into(),
|
||||
selected_libraries: vec!["lib1".into(), "lib2".into()],
|
||||
..Default::default()
|
||||
};
|
||||
s.put(MediaServerKind::Jellyfin, &jf).unwrap();
|
||||
|
||||
let got = s.get(MediaServerKind::Jellyfin).unwrap();
|
||||
assert_eq!(got.base_url, "http://jf:8096");
|
||||
assert_eq!(got.token, "tok");
|
||||
assert_eq!(got.selected_libraries, vec!["lib1", "lib2"]);
|
||||
// The other server is untouched.
|
||||
assert!(!s.get(MediaServerKind::Subsonic).unwrap().enabled);
|
||||
}
|
||||
|
||||
/// The two protocols disagree about what "credentialed" means, and this is
|
||||
/// the one place that knows. Getting it backwards would either refuse a
|
||||
/// working server or hand the sync a connection it cannot authenticate.
|
||||
#[test]
|
||||
fn configured_means_a_different_thing_for_each_protocol() {
|
||||
// Jellyfin: an issued token, and the password is never needed again.
|
||||
let mut jf = MediaServerSettings {
|
||||
enabled: true,
|
||||
base_url: "http://jf:8096".into(),
|
||||
username: "admin".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!jf.is_configured(MediaServerKind::Jellyfin), "no token yet");
|
||||
jf.token = "tok".into();
|
||||
assert!(jf.is_configured(MediaServerKind::Jellyfin));
|
||||
|
||||
// Subsonic: no session at all, so the PASSWORD is what it needs.
|
||||
let mut sub = MediaServerSettings {
|
||||
enabled: true,
|
||||
base_url: "http://nd:4533".into(),
|
||||
username: "admin".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!sub.is_configured(MediaServerKind::Subsonic), "no password yet");
|
||||
sub.password = "pw".into();
|
||||
assert!(sub.is_configured(MediaServerKind::Subsonic));
|
||||
// ...and a Jellyfin-style token does NOT make it configured.
|
||||
let token_only = MediaServerSettings {
|
||||
token: "tok".into(),
|
||||
..sub.clone()
|
||||
};
|
||||
let token_only = MediaServerSettings {
|
||||
password: String::new(),
|
||||
..token_only
|
||||
};
|
||||
assert!(!token_only.is_configured(MediaServerKind::Subsonic));
|
||||
}
|
||||
|
||||
/// A disabled or unaddressed server is never configured, whatever else it
|
||||
/// holds — that is what makes the master toggle actually remove its rows
|
||||
/// from the Local Library union.
|
||||
#[test]
|
||||
fn the_master_toggle_and_the_address_both_gate_it() {
|
||||
let full = MediaServerSettings {
|
||||
enabled: true,
|
||||
base_url: "http://jf:8096".into(),
|
||||
token: "tok".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(full.is_configured(MediaServerKind::Jellyfin));
|
||||
assert!(!MediaServerSettings {
|
||||
enabled: false,
|
||||
..full.clone()
|
||||
}
|
||||
.is_configured(MediaServerKind::Jellyfin));
|
||||
assert!(!MediaServerSettings {
|
||||
base_url: " ".into(),
|
||||
..full
|
||||
}
|
||||
.is_configured(MediaServerKind::Jellyfin));
|
||||
}
|
||||
|
||||
/// Disconnect clears the CREDENTIALS and keeps the picked libraries, so a
|
||||
/// reconnect does not make the user choose them again.
|
||||
#[test]
|
||||
fn disconnect_forgets_the_credentials_but_not_the_choices() {
|
||||
let (_t, s) = store();
|
||||
s.put(
|
||||
MediaServerKind::Subsonic,
|
||||
&MediaServerSettings {
|
||||
enabled: true,
|
||||
base_url: "http://nd:4533".into(),
|
||||
username: "admin".into(),
|
||||
password: "pw".into(),
|
||||
salt: "fixed".into(),
|
||||
selected_libraries: vec!["1".into()],
|
||||
last_sync_tracks: 6678,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
s.disconnect(MediaServerKind::Subsonic).unwrap();
|
||||
let got = s.get(MediaServerKind::Subsonic).unwrap();
|
||||
assert!(!got.enabled);
|
||||
assert!(got.password.is_empty(), "the password survived a disconnect");
|
||||
assert_eq!(got.last_sync_tracks, 0);
|
||||
// Kept on purpose.
|
||||
assert_eq!(got.base_url, "http://nd:4533");
|
||||
assert_eq!(got.username, "admin");
|
||||
assert_eq!(got.salt, "fixed");
|
||||
assert_eq!(got.selected_libraries, vec!["1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brand_spellings_fold_to_subsonic() {
|
||||
for w in ["subsonic", "navidrome", "gonic", "airsonic", "astiga"] {
|
||||
assert_eq!(
|
||||
MediaServerKind::from_word(w),
|
||||
Some(MediaServerKind::Subsonic),
|
||||
"{w}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
MediaServerKind::from_word("jellyfin"),
|
||||
Some(MediaServerKind::Jellyfin)
|
||||
);
|
||||
assert_eq!(MediaServerKind::from_word("plex"), None);
|
||||
assert_eq!(MediaServerKind::from_word("emby"), None);
|
||||
}
|
||||
|
||||
/// Only a server that is enabled AND finished gets into the union filter.
|
||||
/// A half-configured one would otherwise widen the SQL over rows that were
|
||||
/// never swept.
|
||||
#[test]
|
||||
fn only_fully_configured_servers_reach_the_union_filter() {
|
||||
let t = TempDir::new().unwrap();
|
||||
let state = MediaServerState::new();
|
||||
state.init_at(t.path());
|
||||
assert!(state.configured_words().is_empty());
|
||||
|
||||
state.put(
|
||||
MediaServerKind::Jellyfin,
|
||||
&MediaServerSettings {
|
||||
enabled: true,
|
||||
base_url: "http://jf:8096".into(),
|
||||
token: "tok".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
// Enabled but with no password — not configured.
|
||||
state.put(
|
||||
MediaServerKind::Subsonic,
|
||||
&MediaServerSettings {
|
||||
enabled: true,
|
||||
base_url: "http://nd:4533".into(),
|
||||
username: "admin".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(state.configured_words(), vec!["jellyfin"]);
|
||||
}
|
||||
|
||||
/// An UNBOUND state reads as disabled rather than panicking or looking
|
||||
/// configured — the same safe direction the rest of the app takes.
|
||||
#[test]
|
||||
fn an_unbound_state_reports_nothing_configured() {
|
||||
let state = MediaServerState::new();
|
||||
assert!(!state.get(MediaServerKind::Jellyfin).enabled);
|
||||
assert!(state.configured_words().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod artist_blacklist;
|
||||
pub mod blacklist_portable;
|
||||
pub mod bundle;
|
||||
pub mod daemon_prefs;
|
||||
pub mod developer;
|
||||
@@ -7,8 +8,11 @@ pub mod favorites;
|
||||
pub mod favorites_cache;
|
||||
pub mod graphics;
|
||||
pub mod local_favorites;
|
||||
pub mod payload_cache;
|
||||
pub mod pinned_items;
|
||||
pub mod playback;
|
||||
pub mod playlist_play_history;
|
||||
pub mod media_servers;
|
||||
pub mod plex;
|
||||
pub mod album_play_history;
|
||||
pub mod reco_store;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! A tiny in-memory, insertion-order LRU for FRONTEND-SHAPED payloads.
|
||||
//!
|
||||
//! ## Why this exists, when `search_cache` already does
|
||||
//!
|
||||
//! `search_cache` (CAPA A) caches `SearchAllResults` — the raw API shape. It
|
||||
//! has never been read by anything: `SearchService::cached` has zero non-test
|
||||
//! callers in either frontend, and `git log -S` shows the read side was never
|
||||
//! wired in any commit. Reviving it would still leave the expensive half on
|
||||
//! the table, because a cortinilla payload is not the API response: it is the
|
||||
//! response after ranking, per-category truncation, i18n section titles, the
|
||||
//! top-result pick and — the part no API cache can shortcut — the local
|
||||
//! library query and its derived album/artist grouping.
|
||||
//!
|
||||
//! So this caches the FINISHED payload instead, and it is deliberately
|
||||
//! generic: the store never inspects `T`. That matters, because the two
|
||||
//! frontends' payload types have diverged (the Slint carries one raw
|
||||
//! `artwork_url`, the Qt port an `art_url` / `art_path` pair) and unifying
|
||||
//! them is a refactor neither needs in order to share this.
|
||||
//!
|
||||
//! ## Deliberately NOT persisted
|
||||
//!
|
||||
//! Process lifetime IS the TTL. A payload embeds local-library rows and
|
||||
//! resolved artwork paths, so a persisted entry would resurrect albums the
|
||||
//! user has since deleted and covers that have since moved. There is no
|
||||
//! timestamp and no staleness rule, and that is the point: nothing here
|
||||
//! outlives the process, so nothing can go stale across one.
|
||||
//!
|
||||
//! The caller is responsible for clearing on the events that invalidate
|
||||
//! content within a session — a user switch, and any mutation of the local
|
||||
//! library.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::settings::search_cache::normalize_query;
|
||||
|
||||
/// Default bound. Small on purpose: the value is "the user is refining a
|
||||
/// query", and a refinement session is a handful of prefixes, not a history.
|
||||
pub const DEFAULT_CAPACITY: usize = 20;
|
||||
|
||||
/// An insertion-order LRU keyed by the normalized query.
|
||||
///
|
||||
/// The key goes through [`normalize_query`] — the SAME function
|
||||
/// `search_cache` and `search_ranking` use — so `" Pink Floyd "` and
|
||||
/// `"pink floyd"` are one entry, here and in the ranking buckets alike.
|
||||
pub struct PayloadCache<T> {
|
||||
capacity: usize,
|
||||
entries: HashMap<String, T>,
|
||||
order: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl<T> PayloadCache<T> {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
capacity: capacity.max(1),
|
||||
entries: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached payload for `query`, if any. Does NOT reorder — this is an
|
||||
/// insertion-order bound, not a true recency LRU, matching `search_cache`.
|
||||
pub fn get(&self, query: &str) -> Option<&T> {
|
||||
self.entries.get(&normalize_query(query))
|
||||
}
|
||||
|
||||
/// Insert or replace. Re-inserting an existing key keeps its original
|
||||
/// position, so a repeatedly refreshed query cannot pin itself forever.
|
||||
pub fn put(&mut self, query: &str, value: T) {
|
||||
let key = normalize_query(query);
|
||||
if self.entries.insert(key.clone(), value).is_none() {
|
||||
self.order.push_back(key);
|
||||
}
|
||||
while self.order.len() > self.capacity {
|
||||
if let Some(oldest) = self.order.pop_front() {
|
||||
self.entries.remove(&oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop everything. Called on a user switch, on teardown, and on any
|
||||
/// local-library mutation.
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.order.clear();
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for PayloadCache<T> {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_CAPACITY)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A value type with NO `PartialEq`, NO `Clone` and NO `Serialize`, to
|
||||
/// prove the store never inspects `T` — which is what lets both frontends
|
||||
/// share it despite their payload types having diverged.
|
||||
struct Opaque(#[allow(dead_code)] u32);
|
||||
|
||||
#[test]
|
||||
fn payload_cache_bounds_normalizes_and_evicts() {
|
||||
let mut c: PayloadCache<Opaque> = PayloadCache::new(3);
|
||||
assert!(c.is_empty());
|
||||
|
||||
c.put("alpha", Opaque(1));
|
||||
c.put("beta", Opaque(2));
|
||||
assert_eq!(c.len(), 2);
|
||||
assert!(c.get("alpha").is_some());
|
||||
|
||||
// The key is normalized: whitespace collapsed, trimmed, lowercased.
|
||||
c.put(" Pink Floyd ", Opaque(3));
|
||||
assert!(
|
||||
c.get("pink floyd").is_some(),
|
||||
"the normalized key must match"
|
||||
);
|
||||
assert_eq!(c.len(), 3);
|
||||
|
||||
// Past the bound the OLDEST goes, not the newest.
|
||||
c.put("gamma", Opaque(4));
|
||||
assert_eq!(c.len(), 3, "never grows past the capacity");
|
||||
assert!(c.get("alpha").is_none(), "the oldest key was evicted");
|
||||
assert!(c.get("gamma").is_some());
|
||||
|
||||
// Re-inserting an existing key must not push a second order entry, or
|
||||
// a refreshed query would evict the cache one slot at a time.
|
||||
c.put("gamma", Opaque(5));
|
||||
assert_eq!(c.len(), 3);
|
||||
assert_eq!(c.order.len(), 3, "no duplicate order entry");
|
||||
|
||||
c.clear();
|
||||
assert!(c.is_empty());
|
||||
assert!(c.get("gamma").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_cache_capacity_is_never_zero() {
|
||||
let mut c: PayloadCache<Opaque> = PayloadCache::new(0);
|
||||
c.put("only", Opaque(1));
|
||||
assert_eq!(c.len(), 1, "a zero capacity would make put() a no-op");
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ impl PinnedItemsService {
|
||||
.map_err(|e| format!("Failed to open pinned items database: {}", e))?;
|
||||
|
||||
// Enable WAL mode for better concurrent access (ADR-002).
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||
|
||||
let service = Self {
|
||||
|
||||
@@ -85,7 +85,7 @@ impl PlaybackPreferencesStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open playback preferences database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for playback preferences database: {}",
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Local PLAYLIST play history — source of truth for the "Recently Played
|
||||
//! Playlists" rail.
|
||||
//!
|
||||
//! The structural twin of [`crate::settings::album_play_history`], and it
|
||||
//! exists because that module answered the wrong question. Playing a playlist
|
||||
//! of 40 tracks drawn from 40 albums used to write 40 ALBUM plays, so
|
||||
//! "Recently Played" became a list of whatever playlist was on and the
|
||||
//! playlist itself was recorded nowhere. Contexts are now separated at the
|
||||
//! write edge (`recently_qt::record_queue_track` reads `QueueTrack
|
||||
//! ::context_kind`), and this is where the playlist half lands.
|
||||
//!
|
||||
//! # Why a NEW file and not a column somewhere
|
||||
//!
|
||||
//! `recently_played.json` is a DESTRUCTIVE round trip between the two
|
||||
//! frontends: the Slint build deserializes it into its own structs, serde
|
||||
//! drops the fields it does not know, and its next write puts the truncated
|
||||
//! object back. Anything Qt added there would disappear the first time the
|
||||
//! other build played a track, intermittently and with nothing logged. A .db
|
||||
//! the frozen tree has never heard of cannot be destroyed that way — it never
|
||||
//! opens it.
|
||||
//!
|
||||
//! One event per TRACK-START, exactly like the album store (see its header):
|
||||
//! the de-dup for "recently played" happens in the `GROUP BY`, so listening to
|
||||
//! forty tracks of one playlist yields ONE card with `plays = 40`.
|
||||
//!
|
||||
//! SQLite is opened lazily and every read/write swallows its errors into a
|
||||
//! `log::warn!`; a fresh user with no .db yet gets an empty list, so the rail
|
||||
//! self-hides.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
static DB: OnceLock<Mutex<Option<Connection>>> = OnceLock::new();
|
||||
|
||||
fn db_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("playlist_play_history.db"))
|
||||
}
|
||||
|
||||
/// Create the tables + index on a fresh connection (shared by the lazy opener
|
||||
/// and the in-memory test connections).
|
||||
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS playlist_play_events (
|
||||
playlist_id TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS playlist_play_events_pl
|
||||
ON playlist_play_events(playlist_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS playlist_meta (
|
||||
playlist_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
owner_id TEXT NOT NULL DEFAULT '',
|
||||
artwork_url TEXT NOT NULL DEFAULT '',
|
||||
track_count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
// COVERS + OWN_IMAGE ARE A MIGRATION, not part of the CREATE above, and
|
||||
// the difference is the whole bug this fixes.
|
||||
//
|
||||
// They were originally inlined into the CREATE. `CREATE TABLE IF NOT
|
||||
// EXISTS` does NOTHING when the table is already there, so anyone who had
|
||||
// run the previous build kept the old six-column table, every upsert then
|
||||
// failed on "no such column: covers" — into a swallowed `log::warn!` — and
|
||||
// `playlist_meta` stayed empty while `playlist_play_events` filled up
|
||||
// normally. The rail JOINs the two, so it rendered its empty state through
|
||||
// eight tracks, a restart and a manual refresh, with the plays sitting
|
||||
// right there in the other table.
|
||||
//
|
||||
// A brand-new table is only new ONCE: the moment a build runs anywhere, its
|
||||
// schema is legacy and every later column is a migration. Same idempotent
|
||||
// ADD COLUMN the album history already uses.
|
||||
//
|
||||
// Up to four MEMBER covers as a JSON array, for the card's mosaic arm —
|
||||
// most playlists have no graphic of their own, which is exactly why the
|
||||
// detail page falls back to a collage.
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE playlist_meta ADD COLUMN covers TEXT NOT NULL DEFAULT '[]';",
|
||||
);
|
||||
// The playlist HAS a single graphic of its own (or a custom cover), which
|
||||
// the card contain-fits instead of building a mosaic.
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE playlist_meta ADD COLUMN own_image INTEGER NOT NULL DEFAULT 0;",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_db() -> Option<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = match Connection::open(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz] playlist_play_history open failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// ADR-002: WAL, so a read from the render path never blocks on a write.
|
||||
let _ = conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;",
|
||||
);
|
||||
if let Err(e) = init_schema(&conn) {
|
||||
log::warn!("[qbz] playlist_play_history schema failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(conn)
|
||||
}
|
||||
|
||||
fn with_db<T, F>(f: F) -> Option<T>
|
||||
where
|
||||
F: FnOnce(&Connection) -> Option<T>,
|
||||
{
|
||||
let cell = DB.get_or_init(|| Mutex::new(open_db()));
|
||||
let guard = cell.lock().ok()?;
|
||||
let conn = guard.as_ref()?;
|
||||
f(conn)
|
||||
}
|
||||
|
||||
/// Playlist metadata captured at play time (refreshed on every play, so
|
||||
/// renames and cover changes converge).
|
||||
pub struct PlaylistPlayMeta<'a> {
|
||||
/// The Qobuz numeric id as text, or `"local:<uuid>"` for a first-class
|
||||
/// local playlist. It is whatever `PlayContext::playlist(..)` stamped, so
|
||||
/// the event and the card can never key differently.
|
||||
pub playlist_id: &'a str,
|
||||
pub title: &'a str,
|
||||
pub owner: &'a str,
|
||||
pub owner_id: &'a str,
|
||||
pub artwork_url: &'a str,
|
||||
pub track_count: u32,
|
||||
/// `"qobuz"` | `"local"` — where the playlist itself lives, NOT where its
|
||||
/// audio comes from (a Qobuz playlist can hold local rows).
|
||||
pub source: &'a str,
|
||||
/// Member covers for the mosaic (up to four). Remote urls for a Qobuz
|
||||
/// playlist, local file paths for a local one — the artwork cache resolves
|
||||
/// both, so the card does not care which it got.
|
||||
pub covers: &'a [String],
|
||||
/// True when [`Self::artwork_url`] is the playlist's OWN single graphic
|
||||
/// (or a custom cover) rather than a stand-in member cover.
|
||||
pub own_image: bool,
|
||||
}
|
||||
|
||||
/// One playlist row for the rail / a View-all page.
|
||||
#[derive(Clone, Default, Debug, PartialEq)]
|
||||
pub struct PlaylistPlayRow {
|
||||
pub playlist_id: String,
|
||||
pub title: String,
|
||||
pub owner: String,
|
||||
pub owner_id: String,
|
||||
pub artwork_url: String,
|
||||
pub track_count: u32,
|
||||
pub source: String,
|
||||
pub covers: Vec<String>,
|
||||
pub own_image: bool,
|
||||
pub plays: u32,
|
||||
}
|
||||
|
||||
/// Upsert the meta WITHOUT recording a play.
|
||||
///
|
||||
/// The two halves are separate on purpose. The meta is known by the view that
|
||||
/// starts playback (it has the header in hand); the event is written at the
|
||||
/// track-start edge, which only ever sees a `QueueTrack` and its
|
||||
/// `context_id`. Splitting them keeps the edge from having to resolve a
|
||||
/// playlist id back into a title on every single track change.
|
||||
fn upsert_meta_on(conn: &Connection, m: &PlaylistPlayMeta, now: i64) {
|
||||
if let Err(e) = conn.execute(
|
||||
r#"
|
||||
INSERT INTO playlist_meta
|
||||
(playlist_id, title, owner, owner_id, artwork_url,
|
||||
track_count, source, covers, own_image, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(playlist_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
owner = excluded.owner,
|
||||
owner_id = excluded.owner_id,
|
||||
artwork_url = excluded.artwork_url,
|
||||
track_count = excluded.track_count,
|
||||
source = excluded.source,
|
||||
covers = excluded.covers,
|
||||
own_image = excluded.own_image,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
m.playlist_id,
|
||||
m.title,
|
||||
m.owner,
|
||||
m.owner_id,
|
||||
m.artwork_url,
|
||||
m.track_count,
|
||||
m.source,
|
||||
serde_json::to_string(m.covers).unwrap_or_else(|_| "[]".to_string()),
|
||||
m.own_image as i32,
|
||||
now
|
||||
],
|
||||
) {
|
||||
// ERROR, not warn: this is the half of the pair whose absence makes the
|
||||
// rail render its empty state while the events pile up next to it — a
|
||||
// failure that is invisible in the UI by construction.
|
||||
log::error!("[qbz] playlist_play_history upsert meta failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn record_event_on(conn: &Connection, playlist_id: &str, now: i64) {
|
||||
if let Err(e) = conn.execute(
|
||||
"INSERT INTO playlist_play_events (playlist_id, occurred_at) VALUES (?, ?)",
|
||||
params![playlist_id, now],
|
||||
) {
|
||||
log::warn!("[qbz] playlist_play_history insert event failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Remember what a playlist IS, so a later play event can render a card.
|
||||
/// Called by the views that start playback from a playlist. No-op on an empty
|
||||
/// id.
|
||||
pub fn record_playlist_meta(m: PlaylistPlayMeta) {
|
||||
if m.playlist_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = now_secs();
|
||||
with_db(|conn| {
|
||||
upsert_meta_on(conn, &m, now);
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
|
||||
/// Record one play event. Called at the track-start edge when the track's
|
||||
/// playback context is a playlist. No-op on an empty id.
|
||||
pub fn record_playlist_play(playlist_id: &str) {
|
||||
if playlist_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = now_secs();
|
||||
with_db(|conn| {
|
||||
record_event_on(conn, playlist_id, now);
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
|
||||
/// `order_desc` is the ORDER BY body — the only difference between "recently
|
||||
/// played" and "most played", which is why both come out of one query.
|
||||
fn query_on(conn: &Connection, order: &str, limit: Option<u32>) -> Vec<PlaylistPlayRow> {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT m.playlist_id, m.title, m.owner, m.owner_id, m.artwork_url,
|
||||
m.track_count, m.source, m.covers, m.own_image, p.plays
|
||||
FROM playlist_meta m
|
||||
JOIN (
|
||||
SELECT playlist_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
|
||||
FROM playlist_play_events
|
||||
GROUP BY playlist_id
|
||||
) p ON p.playlist_id = m.playlist_id
|
||||
ORDER BY {order}
|
||||
{}
|
||||
"#,
|
||||
limit.map(|n| format!("LIMIT {n}")).unwrap_or_default()
|
||||
);
|
||||
let out = (|| -> Option<Vec<PlaylistPlayRow>> {
|
||||
let mut stmt = conn.prepare(&sql).ok()?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(PlaylistPlayRow {
|
||||
playlist_id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
owner: row.get(2)?,
|
||||
owner_id: row.get(3)?,
|
||||
artwork_url: row.get(4)?,
|
||||
track_count: row.get::<_, i64>(5)? as u32,
|
||||
source: row.get(6)?,
|
||||
covers: serde_json::from_str(&row.get::<_, String>(7)?)
|
||||
.unwrap_or_default(),
|
||||
own_image: row.get::<_, i64>(8)? != 0,
|
||||
plays: row.get::<_, i64>(9)? as u32,
|
||||
})
|
||||
})
|
||||
.ok()?;
|
||||
Some(rows.flatten().collect())
|
||||
})();
|
||||
out.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The `limit` most recently played playlists (the rail).
|
||||
pub fn recent_playlists(limit: u32) -> Vec<PlaylistPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, "p.last_at DESC", Some(limit)))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every played playlist, most recent first (a "View all" page).
|
||||
pub fn all_recent_playlists() -> Vec<PlaylistPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, "p.last_at DESC", None))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ranked by play count. Not wired to a rail — the owner asked for one rail,
|
||||
/// and this falls out of the same table for free when a second one is wanted.
|
||||
#[allow(dead_code)]
|
||||
pub fn top_playlists(limit: u32) -> Vec<PlaylistPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, "p.plays DESC, p.last_at DESC", Some(limit))))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Drop every trace of the given playlists — the parity of
|
||||
/// `recently_qt::prune_albums`, for a playlist the user deleted or unfollowed.
|
||||
/// Returns how many meta rows went.
|
||||
pub fn prune_playlists(ids: &[String]) -> usize {
|
||||
if ids.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
with_db(|conn| {
|
||||
let mut gone = 0usize;
|
||||
for id in ids {
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM playlist_play_events WHERE playlist_id = ?",
|
||||
params![id],
|
||||
);
|
||||
gone += conn
|
||||
.execute("DELETE FROM playlist_meta WHERE playlist_id = ?", params![id])
|
||||
.unwrap_or(0);
|
||||
}
|
||||
Some(gone)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta<'a>(id: &'a str, title: &'a str) -> PlaylistPlayMeta<'a> {
|
||||
PlaylistPlayMeta {
|
||||
playlist_id: id,
|
||||
title,
|
||||
owner: "Someone",
|
||||
owner_id: "42",
|
||||
artwork_url: "http://art",
|
||||
track_count: 12,
|
||||
source: "qobuz",
|
||||
covers: &[],
|
||||
own_image: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mem() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init_schema(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_orders_by_last_play_not_by_count() {
|
||||
let c = mem();
|
||||
// A is played far more, B far more RECENTLY. "Recently played" must
|
||||
// put B first — that is the whole difference from "most played".
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
|
||||
upsert_meta_on(&c, &meta("B", "Playlist B"), 100);
|
||||
for i in 0..30 {
|
||||
record_event_on(&c, "A", 100 + i);
|
||||
}
|
||||
record_event_on(&c, "B", 9_000);
|
||||
|
||||
let recent = query_on(&c, "p.last_at DESC", Some(10));
|
||||
assert_eq!(recent.len(), 2);
|
||||
assert_eq!(recent[0].playlist_id, "B");
|
||||
assert_eq!(recent[1].playlist_id, "A");
|
||||
assert_eq!(recent[1].plays, 30);
|
||||
|
||||
let top = query_on(&c, "p.plays DESC, p.last_at DESC", Some(10));
|
||||
assert_eq!(top[0].playlist_id, "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_card_per_playlist_however_many_tracks() {
|
||||
let c = mem();
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
|
||||
for i in 0..40 {
|
||||
record_event_on(&c, "A", 100 + i);
|
||||
}
|
||||
let recent = query_on(&c, "p.last_at DESC", None);
|
||||
assert_eq!(recent.len(), 1);
|
||||
assert_eq!(recent[0].plays, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_without_a_play_does_not_show() {
|
||||
// The JOIN is what enforces it: opening a playlist upserts nothing on
|
||||
// its own, and even if it did, a card with no play is not "recently
|
||||
// played".
|
||||
let c = mem();
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
|
||||
assert!(query_on(&c, "p.last_at DESC", None).is_empty());
|
||||
}
|
||||
|
||||
/// THE REGRESSION TEST for the bug the migration above exists to fix: a
|
||||
/// database created by the FIRST build, whose `playlist_meta` has neither
|
||||
/// `covers` nor `own_image`. `CREATE TABLE IF NOT EXISTS` leaves it alone,
|
||||
/// so without the ALTERs the upsert fails on a missing column, the meta
|
||||
/// table stays empty, and the rail renders its empty state forever while
|
||||
/// the events pile up beside it.
|
||||
#[test]
|
||||
fn init_schema_migrates_a_pre_covers_database() {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
// The exact shape the first build shipped.
|
||||
c.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE playlist_play_events (
|
||||
playlist_id TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE playlist_meta (
|
||||
playlist_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
owner_id TEXT NOT NULL DEFAULT '',
|
||||
artwork_url TEXT NOT NULL DEFAULT '',
|
||||
track_count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A play recorded by that build, which must survive the migration.
|
||||
record_event_on(&c, "A", 100);
|
||||
|
||||
init_schema(&c).unwrap();
|
||||
|
||||
// The upsert now works, and the pre-existing event still counts.
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 200);
|
||||
let rows = query_on(&c, "p.last_at DESC", None);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].playlist_id, "A");
|
||||
assert_eq!(rows[0].plays, 1);
|
||||
assert!(rows[0].covers.is_empty());
|
||||
assert!(!rows[0].own_image);
|
||||
|
||||
// And running it AGAIN is a no-op rather than an error — every launch
|
||||
// calls it.
|
||||
init_schema(&c).unwrap();
|
||||
assert_eq!(query_on(&c, "p.last_at DESC", None).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_removes_events_and_meta() {
|
||||
let c = mem();
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
|
||||
upsert_meta_on(&c, &meta("B", "Playlist B"), 100);
|
||||
record_event_on(&c, "A", 100);
|
||||
record_event_on(&c, "B", 100);
|
||||
let _ = c.execute("DELETE FROM playlist_play_events WHERE playlist_id = 'A'", []);
|
||||
let _ = c.execute("DELETE FROM playlist_meta WHERE playlist_id = 'A'", []);
|
||||
let left = query_on(&c, "p.last_at DESC", None);
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!(left[0].playlist_id, "B");
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ impl PlexSettingsStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open Plex settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to enable WAL for Plex settings database: {}", e))?;
|
||||
|
||||
// Base (slice 2a) schema. New columns are added by idempotent ALTERs
|
||||
|
||||
@@ -196,7 +196,7 @@ impl RecoStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open reco database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to enable WAL for reco database: {}", e))?;
|
||||
|
||||
let store = Self { conn };
|
||||
@@ -335,11 +335,11 @@ impl RecoStore {
|
||||
params![
|
||||
event.event_type.as_str(),
|
||||
event.item_type.as_str(),
|
||||
event.track_id,
|
||||
event.track_id.map(|v| v as i64),
|
||||
event.album_id.as_deref(),
|
||||
event.artist_id,
|
||||
event.playlist_id,
|
||||
event.genre_id,
|
||||
event.artist_id.map(|v| v as i64),
|
||||
event.playlist_id.map(|v| v as i64),
|
||||
event.genre_id.map(|v| v as i64),
|
||||
now_ts(),
|
||||
],
|
||||
)
|
||||
@@ -405,7 +405,7 @@ impl RecoStore {
|
||||
.map_err(|e| format!("Failed to prepare recent tracks query: {}", e))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![limit], |row| row.get::<_, u64>(0))
|
||||
.query_map(params![limit], |row| row.get::<_, i64>(0).map(|v| v as u64))
|
||||
.map_err(|e| format!("Failed to query recent tracks: {}", e))?;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
@@ -439,7 +439,7 @@ impl RecoStore {
|
||||
.map_err(|e| format!("Failed to prepare windowed recent tracks query: {}", e))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![since_ts, limit], |row| row.get::<_, u64>(0))
|
||||
.query_map(params![since_ts, limit], |row| row.get::<_, i64>(0).map(|v| v as u64))
|
||||
.map_err(|e| format!("Failed to query windowed recent tracks: {}", e))?;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
@@ -467,7 +467,7 @@ impl RecoStore {
|
||||
.map_err(|e| format!("Failed to prepare favorite tracks query: {}", e))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![limit], |row| row.get::<_, u64>(0))
|
||||
.query_map(params![limit], |row| row.get::<_, i64>(0).map(|v| v as u64))
|
||||
.map_err(|e| format!("Failed to query favorite tracks: {}", e))?;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
@@ -542,7 +542,7 @@ impl RecoStore {
|
||||
let rows = stmt
|
||||
.query_map(params![limit], |row| {
|
||||
Ok(TopArtistSeed {
|
||||
artist_id: row.get::<_, u64>(0)?,
|
||||
artist_id: row.get::<_, i64>(0).map(|v| v as u64)?,
|
||||
play_count: row.get::<_, u32>(1)?,
|
||||
})
|
||||
})
|
||||
@@ -576,7 +576,7 @@ impl RecoStore {
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare known artists query: {}", e))?;
|
||||
let rows = stmt
|
||||
.query_map(params![play_threshold], |row| row.get::<_, u64>(0))
|
||||
.query_map(params![play_threshold], |row| row.get::<_, i64>(0).map(|v| v as u64))
|
||||
.map_err(|e| format!("Failed to query known artists: {}", e))?;
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for row in rows {
|
||||
@@ -604,7 +604,7 @@ impl RecoStore {
|
||||
.map_err(|e| format!("Failed to prepare top genres query: {}", e))?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit], |row| {
|
||||
Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
|
||||
Ok((row.get::<_, i64>(0).map(|v| v as u64)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|e| format!("Failed to query top genres: {}", e))?;
|
||||
let mut genres = Vec::new();
|
||||
@@ -703,7 +703,7 @@ impl RecoStore {
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare scored tracks query: {}", e))?;
|
||||
let rows = stmt
|
||||
.query_map(params![score_type, limit], |row| row.get::<_, u64>(0))
|
||||
.query_map(params![score_type, limit], |row| row.get::<_, i64>(0).map(|v| v as u64))
|
||||
.map_err(|e| format!("Failed to query scored tracks: {}", e))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
@@ -729,7 +729,7 @@ impl RecoStore {
|
||||
.map_err(|e| format!("Failed to prepare scored artists query: {}", e))?;
|
||||
let rows = stmt
|
||||
.query_map(params![score_type, limit], |row| {
|
||||
Ok((row.get::<_, u64>(0)?, row.get::<_, f64>(1)?))
|
||||
Ok((row.get::<_, i64>(0).map(|v| v as u64)?, row.get::<_, f64>(1)?))
|
||||
})
|
||||
.map_err(|e| format!("Failed to query scored artists: {}", e))?;
|
||||
let mut out = Vec::new();
|
||||
@@ -761,10 +761,10 @@ impl RecoStore {
|
||||
Ok(RecoEventRecord {
|
||||
event_type: row.get(0)?,
|
||||
item_type: row.get(1)?,
|
||||
track_id: row.get(2)?,
|
||||
track_id: row.get::<_, Option<i64>>(2)?.map(|v| v as u64),
|
||||
album_id: row.get(3)?,
|
||||
artist_id: row.get(4)?,
|
||||
genre_id: row.get(5)?,
|
||||
artist_id: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
|
||||
genre_id: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
@@ -808,9 +808,9 @@ impl RecoStore {
|
||||
stmt.execute(params![
|
||||
score_type,
|
||||
item_type,
|
||||
entry.track_id,
|
||||
entry.track_id.map(|v| v as i64),
|
||||
entry.album_id.as_deref(),
|
||||
entry.artist_id,
|
||||
entry.artist_id.map(|v| v as i64),
|
||||
entry.score,
|
||||
updated_at,
|
||||
])
|
||||
@@ -1071,7 +1071,7 @@ impl RecoStore {
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE reco_events SET genre_id = ? WHERE album_id = ? AND genre_id IS NULL",
|
||||
params![genre_id, album_id],
|
||||
params![genre_id as i64, album_id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update genre for album: {}", e))?;
|
||||
Ok(affected as u64)
|
||||
@@ -1147,7 +1147,7 @@ mod tests {
|
||||
r#"INSERT INTO reco_events
|
||||
(event_type, item_type, track_id, album_id, artist_id, playlist_id, genre_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)"#,
|
||||
params![event_type, item_type, track_id, album_id, artist_id, genre_id, created_at],
|
||||
params![event_type, item_type, track_id.map(|v| v as i64), album_id, artist_id.map(|v| v as i64), genre_id.map(|v| v as i64), created_at],
|
||||
)
|
||||
.expect("insert event at ts");
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ impl RemoteControlSettingsStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open remote control settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for remote control settings database: {}",
|
||||
@@ -305,7 +305,7 @@ impl AllowedOriginsStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open allowed origins database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to enable WAL for allowed origins database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
|
||||
@@ -33,7 +33,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScrobblerSettings {
|
||||
/// Master toggle. When false, the whole scrobblers section body is hidden.
|
||||
/// Default OFF — integrations are opt-in.
|
||||
@@ -56,6 +56,26 @@ pub struct ScrobblerSettings {
|
||||
pub listenbrainz_token: String,
|
||||
/// ListenBrainz username (`UserInfo.user_name`).
|
||||
pub listenbrainz_username: String,
|
||||
|
||||
/// Keep the independent scrobblers active without a Qobuz session.
|
||||
/// Default ON: Qobuz authentication is unrelated to Last.fm/LB auth.
|
||||
pub allow_logged_out_scrobbling: bool,
|
||||
}
|
||||
|
||||
impl Default for ScrobblerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
ui_collapsed: false,
|
||||
lastfm_enabled: false,
|
||||
lastfm_session_key: String::new(),
|
||||
lastfm_username: String::new(),
|
||||
listenbrainz_enabled: false,
|
||||
listenbrainz_token: String::new(),
|
||||
listenbrainz_username: String::new(),
|
||||
allow_logged_out_scrobbling: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrobblerSettings {
|
||||
@@ -78,6 +98,25 @@ impl ScrobblerSettings {
|
||||
pub fn listenbrainz_active(&self) -> bool {
|
||||
self.enabled && self.listenbrainz_enabled && self.listenbrainz_is_authed()
|
||||
}
|
||||
|
||||
/// Whether QBZ may READ the user's listening history from an external
|
||||
/// service (Recommendations). ONE rule, the same as sending: connected
|
||||
/// AND scrobbling on. A connected-but-disabled account used to be read
|
||||
/// silently through `*_is_authed()`; the scrobbling toggle now governs
|
||||
/// both directions, and its Settings text says so.
|
||||
pub fn history_read_allowed(&self, service: HistoryService) -> bool {
|
||||
match service {
|
||||
HistoryService::LastFm => self.lastfm_active(),
|
||||
HistoryService::ListenBrainz => self.listenbrainz_active(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The external services whose history Recommendations may read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HistoryService {
|
||||
LastFm,
|
||||
ListenBrainz,
|
||||
}
|
||||
|
||||
pub struct ScrobblerSettingsStore {
|
||||
@@ -93,7 +132,7 @@ impl ScrobblerSettingsStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open scrobbler settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to enable WAL for scrobbler settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
@@ -106,11 +145,20 @@ impl ScrobblerSettingsStore {
|
||||
lastfm_username TEXT NOT NULL DEFAULT '',
|
||||
listenbrainz_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
listenbrainz_token TEXT NOT NULL DEFAULT '',
|
||||
listenbrainz_username TEXT NOT NULL DEFAULT ''
|
||||
listenbrainz_username TEXT NOT NULL DEFAULT '',
|
||||
allow_logged_out_scrobbling INTEGER NOT NULL DEFAULT 1
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create scrobbler settings table: {}", e))?;
|
||||
|
||||
// Existing Slint/Qt profiles predate logged-out mode. Additive and
|
||||
// default-on, so their established scrobbler setup keeps working.
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE scrobbler_settings
|
||||
ADD COLUMN allow_logged_out_scrobbling INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
);
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO scrobbler_settings
|
||||
(id, enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
|
||||
@@ -135,7 +183,7 @@ impl ScrobblerSettingsStore {
|
||||
.query_row(
|
||||
"SELECT enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
|
||||
lastfm_username, listenbrainz_enabled, listenbrainz_token,
|
||||
listenbrainz_username
|
||||
listenbrainz_username, allow_logged_out_scrobbling
|
||||
FROM scrobbler_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
@@ -147,6 +195,7 @@ impl ScrobblerSettingsStore {
|
||||
let listenbrainz_enabled: i32 = row.get(5)?;
|
||||
let listenbrainz_token: String = row.get(6)?;
|
||||
let listenbrainz_username: String = row.get(7)?;
|
||||
let allow_logged_out_scrobbling: i32 = row.get(8)?;
|
||||
Ok(ScrobblerSettings {
|
||||
enabled: enabled != 0,
|
||||
ui_collapsed: ui_collapsed != 0,
|
||||
@@ -156,6 +205,7 @@ impl ScrobblerSettingsStore {
|
||||
listenbrainz_enabled: listenbrainz_enabled != 0,
|
||||
listenbrainz_token,
|
||||
listenbrainz_username,
|
||||
allow_logged_out_scrobbling: allow_logged_out_scrobbling != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -182,6 +232,17 @@ impl ScrobblerSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_allow_logged_out_scrobbling(&self, value: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE scrobbler_settings
|
||||
SET allow_logged_out_scrobbling = ?1 WHERE id = 1",
|
||||
params![if value { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set logged-out scrobbling: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Last.fm ---
|
||||
|
||||
pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> {
|
||||
@@ -315,6 +376,10 @@ impl ScrobblerSettingsState {
|
||||
self.with_store(|s| s.set_ui_collapsed(value))
|
||||
}
|
||||
|
||||
pub fn set_allow_logged_out_scrobbling(&self, value: bool) -> Result<(), String> {
|
||||
self.with_store(|s| s.set_allow_logged_out_scrobbling(value))
|
||||
}
|
||||
|
||||
pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> {
|
||||
self.with_store(|s| s.set_lastfm_enabled(value))
|
||||
}
|
||||
@@ -363,6 +428,7 @@ mod tests {
|
||||
assert!(!s.listenbrainz_enabled);
|
||||
assert!(s.listenbrainz_token.is_empty());
|
||||
assert!(s.listenbrainz_username.is_empty());
|
||||
assert!(s.allow_logged_out_scrobbling);
|
||||
assert!(!s.lastfm_is_authed());
|
||||
assert!(!s.listenbrainz_is_authed());
|
||||
assert!(!s.lastfm_active());
|
||||
@@ -375,6 +441,7 @@ mod tests {
|
||||
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
|
||||
let s = store.get_settings().expect("get settings");
|
||||
assert!(!s.enabled);
|
||||
assert!(s.allow_logged_out_scrobbling);
|
||||
assert!(!s.lastfm_is_authed());
|
||||
assert!(!s.listenbrainz_is_authed());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
@@ -387,6 +454,9 @@ mod tests {
|
||||
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
|
||||
store.set_enabled(true).expect("enabled");
|
||||
store.set_ui_collapsed(true).expect("collapsed");
|
||||
store
|
||||
.set_allow_logged_out_scrobbling(false)
|
||||
.expect("logged-out scrobbling");
|
||||
store.set_lastfm_enabled(true).expect("lfm enabled");
|
||||
store
|
||||
.set_lastfm_session(" sk-123 ", " alice ")
|
||||
@@ -400,6 +470,7 @@ mod tests {
|
||||
let s = reopened.get_settings().expect("get settings");
|
||||
assert!(s.enabled);
|
||||
assert!(s.ui_collapsed);
|
||||
assert!(!s.allow_logged_out_scrobbling);
|
||||
assert!(s.lastfm_enabled);
|
||||
// set_lastfm_session trims whitespace.
|
||||
assert_eq!(s.lastfm_session_key, "sk-123");
|
||||
@@ -412,6 +483,37 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_database_migrates_logged_out_scrobbling_default_on() {
|
||||
let dir = unique_test_dir("scrobbler-migrate-logged-out");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
{
|
||||
let conn = Connection::open(dir.join("scrobbler_settings.db")).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE scrobbler_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
ui_collapsed INTEGER NOT NULL DEFAULT 0,
|
||||
lastfm_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
lastfm_session_key TEXT NOT NULL DEFAULT '',
|
||||
lastfm_username TEXT NOT NULL DEFAULT '',
|
||||
listenbrainz_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
listenbrainz_token TEXT NOT NULL DEFAULT '',
|
||||
listenbrainz_username TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO scrobbler_settings VALUES (1, 1, 0, 0, '', '', 0, '', '');",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let store = ScrobblerSettingsStore::new_at(&dir).expect("migrate store");
|
||||
let settings = store.get_settings().expect("get migrated settings");
|
||||
assert!(settings.enabled);
|
||||
assert!(settings.allow_logged_out_scrobbling);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrobbler_disconnect_keeps_enable_flags() {
|
||||
let dir = unique_test_dir("scrobbler-disconnect");
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex};
|
||||
///
|
||||
/// The primary protection for offline files is the CMAF-at-rest cache format.
|
||||
/// This grace period is an additional compliance guard, not the main defense.
|
||||
const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
pub const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -55,7 +55,7 @@ impl SubscriptionStateStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open subscription state database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for subscription state database: {}",
|
||||
|
||||
@@ -78,7 +78,7 @@ impl TraySettingsStore {
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open tray settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
|
||||
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
|
||||
+145
-20
@@ -1,36 +1,35 @@
|
||||
//! Framework-agnostic application runtime facade.
|
||||
//!
|
||||
//! [`AppRuntime`] is the composition root that a non-Tauri UI shell (Slint,
|
||||
//! TUI, headless) builds on. It owns an `Arc<QbzCore<A>>`, the framework-
|
||||
//! [`AppRuntime`] is the composition root that a native UI shell (Qt, TUI,
|
||||
//! headless) builds on. It owns an `Arc<QbzCore<A>>`, the framework-
|
||||
//! agnostic runtime state machine, and the per-user session, all without any
|
||||
//! Tauri dependency.
|
||||
//!
|
||||
//! Scope (Slint POC readiness audit, sessions 21-22):
|
||||
//!
|
||||
//! - Task 1 — composition and accessors: [`AppRuntime::new`], [`AppRuntime::core`].
|
||||
//! - Task 2 — minimal session activation: [`AppRuntime::activate`] and
|
||||
//! friends. This is deliberately minimal. It opens only the session store
|
||||
//! and performs the portable session scaffolding (user paths, directories,
|
||||
//! last-user marker, runtime state). It does NOT touch Tauri's
|
||||
//! `session_lifecycle`, does not initialize the `src-tauri`-side per-user
|
||||
//! stores (`library`, `reco`, `lyrics`, ...), and does not run the
|
||||
//! flat-to-user migration. A shell opens further stores per view, as the
|
||||
//! views that need them come online.
|
||||
//!
|
||||
//! The Tauri app does not consume this module; `CoreBridge` and
|
||||
//! `session_lifecycle` keep their own paths. `AppRuntime` is purely additive.
|
||||
//! Session activation is deliberately minimal: it opens the session store and
|
||||
//! performs the portable scaffolding (user paths, directories, last-user
|
||||
//! marker, runtime state). The shell binds its additional per-user stores as
|
||||
//! each session comes online.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap};
|
||||
use qbz_core::{FrontendAdapter, QbzCore};
|
||||
use qbz_offline_cache::OfflineCacheState;
|
||||
use qbz_player::Player;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::runtime::RuntimeManager;
|
||||
use crate::session_store::SessionStore;
|
||||
use crate::subscription_lifecycle;
|
||||
use crate::user_data::UserDataPaths;
|
||||
|
||||
/// How often an activated session re-checks the subscription grace clock,
|
||||
/// so a long-running session without a re-login still crosses the 30-day
|
||||
/// line on time.
|
||||
const SUBSCRIPTION_TICK: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// The per-user stores opened for the currently active session.
|
||||
///
|
||||
/// Minimal by design: it holds only the session store. A shell opens further
|
||||
@@ -38,9 +37,13 @@ use crate::user_data::UserDataPaths;
|
||||
/// loading the full WebKit-era store set up front.
|
||||
struct ActiveSession {
|
||||
user_id: u64,
|
||||
data_dir: PathBuf,
|
||||
session_store: SessionStore,
|
||||
}
|
||||
|
||||
/// The offline cache a host registered, shared with the subscription ticker.
|
||||
type OfflineCacheSlot = Arc<Mutex<Option<Arc<OfflineCacheState>>>>;
|
||||
|
||||
/// Composition root for a non-Tauri UI shell.
|
||||
///
|
||||
/// Generic over the [`FrontendAdapter`] the shell supplies, so the same
|
||||
@@ -54,6 +57,12 @@ pub struct AppRuntime<A: FrontendAdapter + Send + Sync + 'static> {
|
||||
/// the FFT producer and toggle capture. `None` for shells that do not drive
|
||||
/// audio visualization (the default `new`/`with_audio_settings` path).
|
||||
visualizer_tap: Option<VisualizerTap>,
|
||||
/// The host's offline cache, if it has one (Qt does, qbzd does not).
|
||||
/// The subscription lifecycle purges through it once the grace window
|
||||
/// has passed; without one the verdict is still recorded.
|
||||
offline_cache: OfflineCacheSlot,
|
||||
/// The daily subscription re-check for the active session.
|
||||
subscription_ticker: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||
@@ -76,6 +85,8 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||
user_paths: UserDataPaths::new(),
|
||||
session: Mutex::new(None),
|
||||
visualizer_tap: None,
|
||||
offline_cache: Arc::new(Mutex::new(None)),
|
||||
subscription_ticker: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +114,9 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||
/// Build like [`AppRuntime::new`], but also wire a [`VisualizerTap`] into the
|
||||
/// player and retain it so the shell can start the frontend-agnostic FFT
|
||||
/// producer ([`qbz_audio::visualizer::spawn_visualizer_thread`]) and toggle
|
||||
/// capture via the tap's `set_enabled`. Used by the Slint shell for the
|
||||
/// ImmersiveView audio visualizers. The tap starts disabled, so it adds no
|
||||
/// runtime cost until the immersive view enables it.
|
||||
/// capture via the tap's `set_enabled`. Used by the Qt shell for the
|
||||
/// immersive-view audio visualizers. The tap starts disabled, so it adds
|
||||
/// no runtime cost until the immersive view enables it.
|
||||
pub fn with_visualizer(adapter: A) -> Self {
|
||||
let (device_name, audio_settings) = AudioSettingsStore::new()
|
||||
.ok()
|
||||
@@ -174,19 +185,106 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||
|
||||
self.runtime.set_session_activated(true, user_id).await;
|
||||
|
||||
{
|
||||
let mut guard = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|e| format!("session lock poisoned: {}", e))?;
|
||||
*guard = Some(ActiveSession {
|
||||
user_id,
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
session_store,
|
||||
});
|
||||
}
|
||||
|
||||
log::info!("[AppRuntime] Session activated for user");
|
||||
|
||||
// Subscription lifecycle (D4): record the login verdict from the
|
||||
// core's session — a member without the offline entitlement starts
|
||||
// the grace clock, a subscriber clears it — and purge the offline
|
||||
// cache when the window has passed. Shared by every host through
|
||||
// this one activation path; best-effort, never blocks entry.
|
||||
self.run_subscription_lifecycle().await;
|
||||
self.start_subscription_ticker(data_dir.to_path_buf());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register (or clear) the host's offline cache. When a session is
|
||||
/// already active and a cache arrives, the grace clock is enforced
|
||||
/// against it right away — Qt activates its cache after the session.
|
||||
pub fn set_offline_cache(self: &Arc<Self>, cache: Option<Arc<OfflineCacheState>>) {
|
||||
let registered = cache.is_some();
|
||||
if let Ok(mut slot) = self.offline_cache.lock() {
|
||||
*slot = cache;
|
||||
}
|
||||
if registered && self.is_session_active() {
|
||||
let runtime = Arc::clone(self);
|
||||
tokio::spawn(async move { runtime.run_subscription_lifecycle().await });
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_subscription_lifecycle(&self) {
|
||||
let Some(data_dir) = self.active_data_dir() else {
|
||||
return;
|
||||
};
|
||||
let session = {
|
||||
let client_lock = self.core.client();
|
||||
let guard = client_lock.read().await;
|
||||
match guard.as_ref() {
|
||||
Some(client) => client.session().await,
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
let offline = self.offline_cache.lock().ok().and_then(|slot| slot.clone());
|
||||
let now = subscription_lifecycle::now_unix_secs();
|
||||
match subscription_lifecycle::run(&data_dir, session.as_ref(), offline, now).await {
|
||||
Ok(v) => log::info!(
|
||||
"[AppRuntime] subscription verdict: offline_entitled={} member_only={} invalid_since={:?} purged={}",
|
||||
v.offline_entitled,
|
||||
v.member_only,
|
||||
v.invalid_since,
|
||||
v.purged
|
||||
),
|
||||
Err(e) => log::error!("[AppRuntime] subscription lifecycle failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_subscription_ticker(&self, data_dir: PathBuf) {
|
||||
let offline_cache = Arc::clone(&self.offline_cache);
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(SUBSCRIPTION_TICK);
|
||||
interval.tick().await; // the activation pass already ran
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let offline = offline_cache.lock().ok().and_then(|slot| slot.clone());
|
||||
let now = subscription_lifecycle::now_unix_secs();
|
||||
if let Err(e) = subscription_lifecycle::run(&data_dir, None, offline, now).await {
|
||||
log::error!("[AppRuntime] subscription tick failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Ok(mut ticker) = self.subscription_ticker.lock() {
|
||||
if let Some(previous) = ticker.replace(handle) {
|
||||
previous.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_subscription_ticker(&self) {
|
||||
if let Ok(mut ticker) = self.subscription_ticker.lock() {
|
||||
if let Some(handle) = ticker.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn active_data_dir(&self) -> Option<PathBuf> {
|
||||
self.session
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|s| s.data_dir.clone()))
|
||||
}
|
||||
|
||||
/// Activate the per-user session for `user_id`.
|
||||
///
|
||||
/// Resolves the real per-user directories through [`UserDataPaths`],
|
||||
@@ -260,6 +358,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||
/// `last_user_id` marker is intentionally kept on disk so a later
|
||||
/// offline session can still find the user's data.
|
||||
pub async fn deactivate(&self) -> Result<(), String> {
|
||||
self.stop_subscription_ticker();
|
||||
{
|
||||
let mut guard = self
|
||||
.session
|
||||
@@ -318,6 +417,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn test_runtime() -> AppRuntime<NoOpAdapter> {
|
||||
// QbzCore builds a reqwest client (MusicBrainz); since reqwest no
|
||||
// longer pulls a rustls provider (#663), tests must install the
|
||||
// process-level one explicitly, exactly like the real binaries do at
|
||||
// startup — otherwise Client construction panics with "No provider set".
|
||||
crate::ensure_crypto_provider();
|
||||
AppRuntime::with_audio_settings(NoOpAdapter, None, AudioSettings::default(), None)
|
||||
}
|
||||
|
||||
@@ -365,6 +469,27 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&cache_dir);
|
||||
}
|
||||
|
||||
/// User zero is the first-run, never-authenticated profile selected by
|
||||
/// `activate_offline`. It is a real local session, not an auth sentinel.
|
||||
#[tokio::test]
|
||||
async fn guest_profile_zero_is_a_valid_local_session() {
|
||||
let rt = test_runtime();
|
||||
let data_dir = unique_test_dir("guest-data");
|
||||
let cache_dir = unique_test_dir("guest-cache");
|
||||
|
||||
rt.activate_at(0, &data_dir, &cache_dir)
|
||||
.await
|
||||
.expect("guest activation succeeds without Qobuz auth");
|
||||
|
||||
assert!(rt.is_session_active());
|
||||
assert_eq!(rt.active_user_id(), Some(0));
|
||||
assert!(data_dir.join("session.db").exists());
|
||||
|
||||
rt.deactivate().await.expect("guest deactivation succeeds");
|
||||
let _ = std::fs::remove_dir_all(&data_dir);
|
||||
let _ = std::fs::remove_dir_all(&cache_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deactivate_clears_session_and_runtime() {
|
||||
let rt = test_runtime();
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Subscription lifecycle: the D4 grace clock and the offline-cache purge.
|
||||
//!
|
||||
//! Runs from the SHARED session activation (`AppRuntime::activate_at`) and
|
||||
//! from a daily ticker the runtime owns, so every host — the Qt shell, the
|
||||
//! daemon — gets the same verdict and the same enforcement without code of
|
||||
//! its own. Before this module the verdict was produced by the Qt shell
|
||||
//! alone, only when the login was REJECTED, and the purge had no caller at
|
||||
//! all: past the 30-day grace the offline cache was merely blocked from
|
||||
//! playing (`offline_playback_allowed`), never removed. The official Qobuz
|
||||
//! app removes it; so does this.
|
||||
//!
|
||||
//! Semantics (member mode, 2026-09-02):
|
||||
//!
|
||||
//! - A login whose entitlements carry `offline_streaming` is a VALID
|
||||
//! observation: the clock is cleared.
|
||||
//! - A login without it — a Qobuz member with no subscription — is an
|
||||
//! INVALID observation: the clock starts, or keeps running from its first
|
||||
//! observation. The member still gets in; only offline is on the clock.
|
||||
//! - Once the clock passes [`GRACE_PERIOD_SECS`], the cached audio is
|
||||
//! purged — when a cache is registered. A host without one (qbzd) keeps
|
||||
//! the verdict and the read gate, and a cache registered later is purged
|
||||
//! on registration.
|
||||
//! - Nothing here decides playback. `offline_playback_allowed` stays the
|
||||
//! read gate the views consult.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_models::UserSession;
|
||||
use qbz_offline_cache::OfflineCacheState;
|
||||
|
||||
use crate::settings::subscription::SubscriptionStateStore;
|
||||
pub use crate::settings::subscription::GRACE_PERIOD_SECS;
|
||||
|
||||
/// What the lifecycle concluded for the active session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionVerdict {
|
||||
/// The session carries the `offline_streaming` entitlement.
|
||||
pub offline_entitled: bool,
|
||||
/// No streaming entitlement at all: a Qobuz member without subscription.
|
||||
pub member_only: bool,
|
||||
/// First time the account was seen without the offline entitlement,
|
||||
/// unix seconds; `None` while the clock is clear.
|
||||
pub invalid_since: Option<i64>,
|
||||
/// When the grace window ends (`invalid_since + GRACE_PERIOD_SECS`).
|
||||
pub grace_deadline: Option<i64>,
|
||||
/// The cache was purged by THIS run.
|
||||
pub purged: bool,
|
||||
}
|
||||
|
||||
/// D4 producer: record the login verdict for this session.
|
||||
pub fn observe(
|
||||
store: &SubscriptionStateStore,
|
||||
session: &UserSession,
|
||||
now: i64,
|
||||
) -> Result<(), String> {
|
||||
if session.entitlements.offline_streaming {
|
||||
store.mark_valid(now)
|
||||
} else {
|
||||
store.mark_invalid(now)
|
||||
}
|
||||
}
|
||||
|
||||
/// D4 enforcer: purge the offline cache once the grace window has passed.
|
||||
///
|
||||
/// Returns `Ok(true)` when a purge happened. With no cache registered the
|
||||
/// decision is logged and left standing (not marked purged), so the next
|
||||
/// registration or tick purges. Takes the directory rather than an open
|
||||
/// store because the store's SQLite connection cannot be held across the
|
||||
/// purge's await point on a multi-threaded runtime.
|
||||
pub async fn enforce(
|
||||
data_dir: &Path,
|
||||
now: i64,
|
||||
offline: Option<&OfflineCacheState>,
|
||||
) -> Result<bool, String> {
|
||||
let due = SubscriptionStateStore::new_at(data_dir)?.should_purge_offline_cache(now)?;
|
||||
if !due {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(cache) = offline else {
|
||||
log::warn!(
|
||||
"[subscription] offline cache purge is due (no offline entitlement for {} days) but no cache is registered in this host",
|
||||
GRACE_PERIOD_SECS / 86_400
|
||||
);
|
||||
return Ok(false);
|
||||
};
|
||||
log::warn!(
|
||||
"[subscription] no offline entitlement for {} days: purging the offline cache",
|
||||
GRACE_PERIOD_SECS / 86_400
|
||||
);
|
||||
qbz_offline_cache::purge_all_cached_files(cache, &cache.library_db).await?;
|
||||
SubscriptionStateStore::new_at(data_dir)?.mark_offline_cache_purged(now)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The verdict as the host may want to show it (diagnostics, `/api/status`).
|
||||
pub fn verdict(
|
||||
store: &SubscriptionStateStore,
|
||||
session: Option<&UserSession>,
|
||||
purged: bool,
|
||||
) -> Result<SubscriptionVerdict, String> {
|
||||
let state = store.get_state()?;
|
||||
let entitlements = session.map(|s| &s.entitlements);
|
||||
Ok(SubscriptionVerdict {
|
||||
offline_entitled: entitlements.map(|e| e.offline_streaming).unwrap_or(false),
|
||||
member_only: entitlements.map(|e| e.is_member_only()).unwrap_or(false),
|
||||
invalid_since: state.invalid_since,
|
||||
grace_deadline: state.invalid_since.map(|t| t + GRACE_PERIOD_SECS),
|
||||
purged,
|
||||
})
|
||||
}
|
||||
|
||||
/// One full pass for the per-user directory `data_dir`: observe the session
|
||||
/// (when there is one), enforce, report. Best-effort by contract — the
|
||||
/// caller logs the error and activation proceeds; a broken clock must never
|
||||
/// keep a user out.
|
||||
pub async fn run(
|
||||
data_dir: &Path,
|
||||
session: Option<&UserSession>,
|
||||
offline: Option<Arc<OfflineCacheState>>,
|
||||
now: i64,
|
||||
) -> Result<SubscriptionVerdict, String> {
|
||||
if let Some(session) = session {
|
||||
observe(&SubscriptionStateStore::new_at(data_dir)?, session, now)?;
|
||||
}
|
||||
let purged = enforce(data_dir, now, offline.as_deref()).await?;
|
||||
verdict(&SubscriptionStateStore::new_at(data_dir)?, session, purged)
|
||||
}
|
||||
|
||||
pub fn now_unix_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use qbz_models::Entitlements;
|
||||
|
||||
fn session(offline: bool) -> UserSession {
|
||||
UserSession {
|
||||
user_id: 7,
|
||||
entitlements: Entitlements {
|
||||
lossless_streaming: offline,
|
||||
offline_streaming: offline,
|
||||
..Entitlements::default()
|
||||
},
|
||||
..UserSession::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriber_login_clears_the_clock() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SubscriptionStateStore::new_at(dir.path()).unwrap();
|
||||
store.mark_invalid(100).unwrap();
|
||||
observe(&store, &session(true), 200).unwrap();
|
||||
let v = verdict(&store, Some(&session(true)), false).unwrap();
|
||||
assert_eq!(v.invalid_since, None);
|
||||
assert_eq!(v.grace_deadline, None);
|
||||
assert!(v.offline_entitled);
|
||||
assert!(!v.member_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_login_starts_the_clock_once_and_keeps_its_origin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SubscriptionStateStore::new_at(dir.path()).unwrap();
|
||||
observe(&store, &session(false), 100).unwrap();
|
||||
observe(&store, &session(false), 500).unwrap();
|
||||
let v = verdict(&store, Some(&session(false)), false).unwrap();
|
||||
assert_eq!(v.invalid_since, Some(100));
|
||||
assert_eq!(v.grace_deadline, Some(100 + GRACE_PERIOD_SECS));
|
||||
assert!(v.member_only);
|
||||
assert!(!v.offline_entitled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_waits_for_the_grace_window_and_needs_a_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SubscriptionStateStore::new_at(dir.path()).unwrap();
|
||||
observe(&store, &session(false), 100).unwrap();
|
||||
// Inside the window: nothing.
|
||||
assert!(!enforce(dir.path(), 100 + GRACE_PERIOD_SECS - 1, None)
|
||||
.await
|
||||
.unwrap());
|
||||
// Past the window but no cache registered: still nothing, and the
|
||||
// decision is NOT marked as purged, so a later host can act.
|
||||
assert!(!enforce(dir.path(), 100 + GRACE_PERIOD_SECS, None)
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(store
|
||||
.should_purge_offline_cache(100 + GRACE_PERIOD_SECS)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_without_a_session_only_enforces() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SubscriptionStateStore::new_at(dir.path()).unwrap();
|
||||
store.mark_invalid(100).unwrap();
|
||||
drop(store);
|
||||
// An offline entry (no Qobuz session) must not clear or restart the
|
||||
// clock; it only reports it.
|
||||
let v = run(dir.path(), None, None, 200).await.unwrap();
|
||||
assert_eq!(v.invalid_since, Some(100));
|
||||
assert!(!v.purged);
|
||||
assert!(!v.offline_entitled);
|
||||
}
|
||||
}
|
||||
@@ -135,14 +135,6 @@ impl UserDataPaths {
|
||||
contents.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// Clear the last user_id file, called on explicit logout.
|
||||
pub fn clear_last_user_id() {
|
||||
if let Ok(path) = Self::last_user_id_path() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
log::info!("Cleared last_user_id file");
|
||||
}
|
||||
}
|
||||
|
||||
fn last_user_id_path() -> Result<PathBuf, String> {
|
||||
let dir = Self::global_data_dir()?;
|
||||
Ok(dir.join("last_user_id"))
|
||||
|
||||
@@ -34,7 +34,7 @@ rustfft = "6.4"
|
||||
spectrum-analyzer = "1.5"
|
||||
|
||||
# Database for loudness cache
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
|
||||
# System directories
|
||||
dirs = "6"
|
||||
@@ -50,6 +50,7 @@ alsa = "0.11"
|
||||
# Kept in lockstep with alsa (0.11 -> alsa-sys 0.4); a single alsa-sys is
|
||||
# required because of the `links = "alsa"` constraint shared with cpal.
|
||||
alsa-sys = "0.4"
|
||||
libc = "0.2"
|
||||
# zbus for org.freedesktop.ReserveDevice1 D-Bus client (device reservation).
|
||||
# Pinned to v4 to share the transitive copy already pulled in by
|
||||
# secret-service/souvlaki and avoid duplicate zbus compiles.
|
||||
@@ -64,3 +65,15 @@ jack = "0.13"
|
||||
# CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode
|
||||
coreaudio-rs = "0.14.1"
|
||||
objc2-core-audio = "0.3.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# WASAPI exclusive mode (phase B). HEnquist's crate, the one CamillaDSP
|
||||
# uses. `windows` is pinned to the 0.62 line cpal already resolves, so
|
||||
# this adds no second windows tree to the graph.
|
||||
wasapi = "0.24"
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Media_Audio",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Throwaway proof for the WASAPI exclusive path (phase B, task 2 step 8).
|
||||
//!
|
||||
//! Opens a render endpoint in EXCLUSIVE mode and plays a 1 kHz sine, then
|
||||
//! reports what the stream negotiated and how many underruns it took.
|
||||
//!
|
||||
//! Deliberately quiet: -20 dBFS. A stability proof does not need full scale,
|
||||
//! and this runs on somebody's actual monitors.
|
||||
//!
|
||||
//! cargo run -p qbz-audio --example wasapi_play list endpoints
|
||||
//! cargo run -p qbz-audio --example wasapi_play <id> [rate] [s] play
|
||||
//!
|
||||
//! What it proves that a unit test cannot: the DAC's own display shows the
|
||||
//! rate we asked for. If the display says 48 kHz while this says 96, the
|
||||
//! stream is being resampled somewhere and it is NOT bit-perfect.
|
||||
|
||||
fn main() {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
eprintln!("wasapi_play is Windows-only");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use qbz_audio::wasapi_direct::{WasapiDirectStream, WasapiTiming};
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
if args.is_empty() {
|
||||
list_endpoints();
|
||||
eprintln!();
|
||||
eprintln!("pass an endpoint id to play, e.g.");
|
||||
eprintln!(" cargo run -p qbz-audio --example wasapi_play \"{{0.0.0...}}\" 96000 10");
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = args[0].clone();
|
||||
let rate: u32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(96_000);
|
||||
let secs: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(10);
|
||||
|
||||
let stream = match WasapiDirectStream::new(&endpoint, rate, 2, WasapiTiming::Events) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("open failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let info = stream.open_info();
|
||||
println!("endpoint : {}", info.endpoint_name);
|
||||
println!("rate : {} Hz", info.rate);
|
||||
println!(
|
||||
"format : {:?} ({}/{} bits)",
|
||||
info.rung,
|
||||
info.rung.valid_bits(),
|
||||
info.rung.container_bits()
|
||||
);
|
||||
println!(
|
||||
"period : {} hns buffer: {} frames",
|
||||
info.period_hns, info.buffer_frames
|
||||
);
|
||||
println!("realigned: {} timing: {:?}", info.realigned, info.timing);
|
||||
println!("bit-perfect mode: {:?}", stream.bit_perfect_mode());
|
||||
println!();
|
||||
println!("BEEP 1 of 3 - {secs}s of 1 kHz at -20 dBFS");
|
||||
println!("CHECK THE DAC: it must read {rate}. Then expect TWO more one-second");
|
||||
println!("beeps - one after the drain, one after the stop. Three beeps = PASS.");
|
||||
|
||||
// Feed in period-sized chunks so the queue's back-pressure paces us
|
||||
// instead of a sleep. 1 kHz divides every rate in the ladder evenly
|
||||
// enough that the phase never accumulates audible drift over 10 s.
|
||||
let frames_per_chunk = info.buffer_frames.max(1) as usize;
|
||||
let total_frames = rate as u64 * secs;
|
||||
let amp = 0.1f32; // -20 dBFS
|
||||
let step = 2.0 * std::f32::consts::PI * 1000.0 / rate as f32;
|
||||
|
||||
let mut phase = 0.0f32;
|
||||
let mut written: u64 = 0;
|
||||
while written < total_frames {
|
||||
let n = frames_per_chunk.min((total_frames - written) as usize);
|
||||
let mut buf = Vec::with_capacity(n * 2);
|
||||
for _ in 0..n {
|
||||
let v = phase.sin() * amp;
|
||||
phase += step;
|
||||
if phase > std::f32::consts::TAU {
|
||||
phase -= std::f32::consts::TAU;
|
||||
}
|
||||
buf.push(v); // L
|
||||
buf.push(v); // R
|
||||
}
|
||||
if let Err(e) = stream.write_f32(&buf) {
|
||||
eprintln!("write failed after {written} frames: {e}");
|
||||
break;
|
||||
}
|
||||
written += n as u64;
|
||||
}
|
||||
|
||||
// The verdict must be judged on CONTINUOUS playback only. Everything
|
||||
// after this point deliberately leaves the queue empty between beeps,
|
||||
// and a genuine gap with the stream running IS an underrun -- counting
|
||||
// those would make this instrument report a failure it created itself.
|
||||
let underruns_playing = stream.underruns();
|
||||
let timeouts_playing = stream.event_timeouts();
|
||||
|
||||
// Drain, then WRITE AGAIN. This is the discriminating test for the
|
||||
// gapless contract: drain must block until the queue has played out
|
||||
// AND must leave the stream usable for the next source. An early
|
||||
// return, or a render thread that exits on drain, both show up here
|
||||
// and nowhere else.
|
||||
let t0 = std::time::Instant::now();
|
||||
let drained = stream.drain();
|
||||
let drain_ms = t0.elapsed().as_millis();
|
||||
println!();
|
||||
println!("drain : {drained:?} in {drain_ms} ms");
|
||||
|
||||
// A FULL SECOND, not one buffer. The point of these two checks is that
|
||||
// a person can HEAR the second and third beeps; a 144-frame burst at
|
||||
// 44.1 kHz is 3 ms, which is inaudible, so the test could pass or fail
|
||||
// and sound identical either way.
|
||||
let mut beep = |stream: &WasapiDirectStream, phase: &mut f32| -> Result<(), String> {
|
||||
let mut left = rate as usize;
|
||||
while left > 0 {
|
||||
let n = frames_per_chunk.min(left);
|
||||
let mut buf = Vec::with_capacity(n * 2);
|
||||
for _ in 0..n {
|
||||
let v = phase.sin() * amp;
|
||||
*phase += step;
|
||||
if *phase > std::f32::consts::TAU {
|
||||
*phase -= std::f32::consts::TAU;
|
||||
}
|
||||
buf.push(v);
|
||||
buf.push(v);
|
||||
}
|
||||
stream.write_f32(&buf)?;
|
||||
left -= n;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
println!("BEEP 2 of 3 - one second, after the drain");
|
||||
match beep(&stream, &mut phase) {
|
||||
Ok(()) => println!("write after drain: OK - the stream survived the drain"),
|
||||
Err(e) => println!("write after drain: FAILED ({e}) - the stream died on drain"),
|
||||
}
|
||||
let _ = stream.drain();
|
||||
|
||||
// STOP, then WRITE AGAIN. This is the OTHER half of the gapless
|
||||
// contract and the one that was missing: the player calls
|
||||
// engine.stop() on EVERY track change and then reuses the same stream
|
||||
// object for the next track, exactly as ALSA's stop() does
|
||||
// ("prepare for next playback"). A render thread that exits on Stop
|
||||
// passes every drain test and still cannot play a second track --
|
||||
// which is what shipped, and what the app log caught as
|
||||
// "Write failed: render thread is gone".
|
||||
let t1 = std::time::Instant::now();
|
||||
let stopped = stream.stop();
|
||||
println!(
|
||||
"stop : {stopped:?} in {} ms",
|
||||
t1.elapsed().as_millis()
|
||||
);
|
||||
|
||||
println!("BEEP 3 of 3 - one second, after the stop");
|
||||
let survived_stop = match beep(&stream, &mut phase) {
|
||||
Ok(()) => {
|
||||
println!("write after stop : OK - the stream survived the stop");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
println!("write after stop : FAILED ({e}) - the stream died on stop");
|
||||
false
|
||||
}
|
||||
};
|
||||
let _ = stream.drain();
|
||||
|
||||
println!();
|
||||
println!("frames written : {written}");
|
||||
println!(
|
||||
"underruns : {} during playback ({} total, gaps between beeps included)",
|
||||
underruns_playing,
|
||||
stream.underruns()
|
||||
);
|
||||
println!(
|
||||
"event timeouts : {} during playback ({} total)",
|
||||
timeouts_playing,
|
||||
stream.event_timeouts()
|
||||
);
|
||||
println!(
|
||||
"VERDICT : {}",
|
||||
if !survived_stop {
|
||||
"FAIL - the stream did not survive stop(); a track change kills it"
|
||||
} else if underruns_playing == 0 && timeouts_playing == 0 {
|
||||
"PASS - survived drain and stop, no underruns, no timeouts"
|
||||
} else {
|
||||
"underruns occurred; try WasapiTiming::Polling"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn list_endpoints() {
|
||||
use wasapi::{initialize_mta, DeviceEnumerator, Direction};
|
||||
let _ = initialize_mta().ok();
|
||||
let Ok(en) = DeviceEnumerator::new() else {
|
||||
eprintln!("DeviceEnumerator failed");
|
||||
return;
|
||||
};
|
||||
let Ok(coll) = en.get_device_collection(&Direction::Render) else {
|
||||
eprintln!("device collection failed");
|
||||
return;
|
||||
};
|
||||
let n = coll.get_nbr_devices().unwrap_or(0);
|
||||
println!("render endpoints:");
|
||||
for i in 0..n {
|
||||
if let Ok(d) = coll.get_device_at_index(i) {
|
||||
println!(
|
||||
" [{i}] {}",
|
||||
d.get_friendlyname().unwrap_or_else(|_| "<unnamed>".into())
|
||||
);
|
||||
println!(" {}", d.get_id().unwrap_or_else(|_| "<no id>".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,17 +69,77 @@ fn suspend_default_sink_for_exclusive() {
|
||||
/// fix). No-op if QBZ did not suspend one — so it is safe to call on every
|
||||
/// stop/teardown. Call it once the exclusive device has actually been released
|
||||
/// so the rest of the system can use the sink again.
|
||||
pub fn resume_suspended_sink() {
|
||||
pub fn resume_suspended_sink() -> Result<(), String> {
|
||||
let target = match SUSPENDED_SINK.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(sink) = target {
|
||||
let _ = std::process::Command::new("pactl")
|
||||
.args(["suspend-sink", &sink, "0"])
|
||||
.output();
|
||||
log::info!("[ALSA Backend] Resumed PipeWire sink '{}'", sink);
|
||||
let Some(sink) = target else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// The PCM and reservation have already been dropped by every caller, but
|
||||
// PipeWire may need a short moment to recreate the parked sink. Retry the
|
||||
// exact recorded name, then the default alias as a compatibility fallback.
|
||||
// Keep SUSPENDED_SINK populated until one command actually succeeds: the
|
||||
// old take-before-command implementation permanently lost the recovery
|
||||
// target on one transient `pactl` failure while logging success anyway.
|
||||
let mut last_error = String::new();
|
||||
for delay_ms in [0u64, 25, 75, 150] {
|
||||
if delay_ms > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
}
|
||||
let mut targets = vec![sink.as_str()];
|
||||
if sink != "@DEFAULT_SINK@" {
|
||||
targets.push("@DEFAULT_SINK@");
|
||||
}
|
||||
for target in targets {
|
||||
match std::process::Command::new("pactl")
|
||||
.args(["suspend-sink", target, "0"])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
if let Ok(mut guard) = SUSPENDED_SINK.lock() {
|
||||
if guard.as_deref() == Some(sink.as_str()) {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"[ALSA Backend] Resumed PipeWire sink '{}' via '{}'",
|
||||
sink,
|
||||
target
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(output) => {
|
||||
last_error = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = error.to_string();
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
let message = format!(
|
||||
"cannot resume PipeWire sink '{}': pactl is unavailable ({error})",
|
||||
sink
|
||||
);
|
||||
log::warn!("[ALSA Backend] {message}");
|
||||
return Err(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"cannot resume PipeWire sink '{}' after releasing ALSA: {}",
|
||||
sink,
|
||||
if last_error.is_empty() {
|
||||
"pactl failed without a diagnostic"
|
||||
} else {
|
||||
last_error.as_str()
|
||||
}
|
||||
);
|
||||
log::warn!("[ALSA Backend] {message}");
|
||||
Err(message)
|
||||
}
|
||||
|
||||
/// Common audio sample rates to check for device support
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! This module bypasses rodio/CPAL completely for direct hardware access.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use alsa::pcm::{Access, Format, HwParams, PCM};
|
||||
use alsa::pcm::{Access, Format, Frames, HwParams, PCM};
|
||||
#[cfg(target_os = "linux")]
|
||||
use alsa::{Direction, ValueOr};
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -113,6 +113,18 @@ pub struct AlsaDirectStream {
|
||||
device_id: String,
|
||||
}
|
||||
|
||||
/// A writable ALSA mixer element and its current normalized level.
|
||||
///
|
||||
/// The probe is deliberately read-only. Callers use the sampled level to seed
|
||||
/// QBZ's volume state *before* enabling hardware volume, so changing the
|
||||
/// setting cannot copy the direct path's fixed 100% UI value into the
|
||||
/// physical mixer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HardwareVolumeInfo {
|
||||
pub control_name: String,
|
||||
pub volume: f32,
|
||||
}
|
||||
|
||||
/// Defensive settle delay between reservation acquisition and PCM open.
|
||||
///
|
||||
/// Only applied when the reservation actually transitioned ownership (i.e.
|
||||
@@ -144,13 +156,8 @@ impl AlsaDirectStream {
|
||||
// immediately before a real `PCM::new()` and held for as long as that
|
||||
// PCM is open.
|
||||
// TODO(Task 5): replace second arg with user-facing DAC name from settings.
|
||||
let reservation =
|
||||
crate::DeviceReservation::acquire(device_id, device_id).map_err(|e| {
|
||||
format!(
|
||||
"Cannot acquire exclusive device '{}': {}",
|
||||
device_id, e
|
||||
)
|
||||
})?;
|
||||
let reservation = crate::DeviceReservation::acquire(device_id, device_id)
|
||||
.map_err(|e| format!("Cannot acquire exclusive device '{}': {}", device_id, e))?;
|
||||
|
||||
// Defensive margin only matters when the reservation actually displaced
|
||||
// a holder (or could have). On the degraded D-Bus path the bus name is
|
||||
@@ -213,13 +220,13 @@ impl AlsaDirectStream {
|
||||
// Set buffer size (larger buffer for high-res audio)
|
||||
let buffer_size = if sample_rate >= 192000 {
|
||||
// 500ms buffer for 192kHz+ (like MPD config)
|
||||
(sample_rate / 2) as i64
|
||||
(sample_rate / 2) as Frames
|
||||
} else if sample_rate >= 96000 {
|
||||
// 250ms buffer for 96kHz
|
||||
(sample_rate / 4) as i64
|
||||
(sample_rate / 4) as Frames
|
||||
} else {
|
||||
// 125ms buffer for lower rates
|
||||
(sample_rate / 8) as i64
|
||||
(sample_rate / 8) as Frames
|
||||
};
|
||||
|
||||
hwp.set_buffer_size_near(buffer_size)
|
||||
@@ -300,11 +307,11 @@ impl AlsaDirectStream {
|
||||
hwp.set_rate(carrier_rate, ValueOr::Nearest)
|
||||
.map_err(|e| format!("Failed to set DoP carrier rate {}: {}", carrier_rate, e))?;
|
||||
let buffer_size = if carrier_rate >= 192000 {
|
||||
(carrier_rate / 2) as i64
|
||||
(carrier_rate / 2) as Frames
|
||||
} else if carrier_rate >= 96000 {
|
||||
(carrier_rate / 4) as i64
|
||||
(carrier_rate / 4) as Frames
|
||||
} else {
|
||||
(carrier_rate / 8) as i64
|
||||
(carrier_rate / 8) as Frames
|
||||
};
|
||||
hwp.set_buffer_size_near(buffer_size)
|
||||
.map_err(|e| format!("Failed to set buffer size: {}", e))?;
|
||||
@@ -380,15 +387,13 @@ impl AlsaDirectStream {
|
||||
}
|
||||
}
|
||||
let Some((format, le)) = selected else {
|
||||
return Err(
|
||||
"Device has no native DSD format (kernel quirk missing?)".to_string()
|
||||
);
|
||||
return Err("Device has no native DSD format (kernel quirk missing?)".to_string());
|
||||
};
|
||||
hwp.set_channels(channels as u32)
|
||||
.map_err(|e| format!("Failed to set channels: {}", e))?;
|
||||
hwp.set_rate(rate, ValueOr::Nearest)
|
||||
.map_err(|e| format!("Failed to set native DSD rate {}: {}", rate, e))?;
|
||||
let buffer_size = (rate / 4) as i64; // 250 ms
|
||||
let buffer_size = (rate / 4) as Frames; // 250 ms
|
||||
hwp.set_buffer_size_near(buffer_size)
|
||||
.map_err(|e| format!("Failed to set buffer size: {}", e))?;
|
||||
hwp.set_period_size_near(buffer_size / 10, ValueOr::Nearest)
|
||||
@@ -441,7 +446,11 @@ impl AlsaDirectStream {
|
||||
match io.writei(samples) {
|
||||
Ok(written) => {
|
||||
if written != frames {
|
||||
log::warn!("[ALSA Direct] Partial DoP write: {} / {} frames", written, frames);
|
||||
log::warn!(
|
||||
"[ALSA Direct] Partial DoP write: {} / {} frames",
|
||||
written,
|
||||
frames
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -852,7 +861,10 @@ impl AlsaDirectStream {
|
||||
// libc::EBADFD — the PCM was not in a running-ish state.
|
||||
const EBADFD: i32 = 77;
|
||||
if e.errno() as i32 != EBADFD {
|
||||
log::warn!("[ALSA Direct] drop on stop failed (continuing to prepare): {}", e);
|
||||
log::warn!(
|
||||
"[ALSA Direct] drop on stop failed (continuing to prepare): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
pcm.prepare()
|
||||
@@ -869,61 +881,37 @@ impl AlsaDirectStream {
|
||||
self.channels
|
||||
}
|
||||
|
||||
/// Frames accepted by ALSA but not yet presented by the device.
|
||||
///
|
||||
/// This is observational only: it neither changes PCM parameters nor
|
||||
/// waits for the device. The direct writer uses it to point the passive
|
||||
/// visualizer tap at the audible playhead instead of the queue head.
|
||||
pub fn playback_delay_frames(&self) -> Result<u64, String> {
|
||||
let pcm = self.pcm.lock().unwrap();
|
||||
pcm.delay()
|
||||
.map(|frames| frames.max(0) as u64)
|
||||
.map_err(|e| format!("Failed to query ALSA playback delay: {}", e))
|
||||
}
|
||||
|
||||
/// Get device ID
|
||||
pub fn device_id(&self) -> &str {
|
||||
&self.device_id
|
||||
}
|
||||
|
||||
/// Try to set hardware volume via ALSA mixer
|
||||
/// Try to set hardware volume via ALSA mixer.
|
||||
///
|
||||
/// Returns error if:
|
||||
/// - DAC doesn't have mixer controls (common for USB DACs)
|
||||
/// - Mixer API fails
|
||||
///
|
||||
/// NOTE: Failure doesn't break playback, just means volume can't be controlled.
|
||||
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
|
||||
use alsa::mixer::SelemChannelId::*;
|
||||
use alsa::mixer::{Mixer, SelemId};
|
||||
|
||||
// Open mixer for device
|
||||
let mixer = Mixer::new(&self.device_id, false)
|
||||
.map_err(|e| format!("Failed to open mixer for {}: {}", self.device_id, e))?;
|
||||
|
||||
// Try to find a volume control element
|
||||
// Common names: "Master", "PCM", "Speaker", "Headphone"
|
||||
let control_names = ["Master", "PCM", "Speaker", "Headphone", "Digital"];
|
||||
|
||||
for name in &control_names {
|
||||
let selem_id = SelemId::new(name, 0);
|
||||
|
||||
if let Some(selem) = mixer.find_selem(&selem_id) {
|
||||
// Check if this element has playback volume control
|
||||
if selem.has_playback_volume() {
|
||||
let (min, max) = selem.get_playback_volume_range();
|
||||
let target = min + ((max - min) as f32 * volume) as i64;
|
||||
|
||||
log::info!(
|
||||
"[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})",
|
||||
name,
|
||||
volume * 100.0,
|
||||
target,
|
||||
max
|
||||
);
|
||||
|
||||
// Set volume on all channels
|
||||
for channel in &[FrontLeft, FrontRight, FrontCenter, RearLeft, RearRight] {
|
||||
let _ = selem.set_playback_volume(*channel, target);
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"No volume control found for {}. DAC may not support hardware mixer.",
|
||||
self.device_id
|
||||
))
|
||||
pub fn set_hardware_volume(
|
||||
&self,
|
||||
control: &crate::alsa_hardware_volume::AlsaMixerControlId,
|
||||
volume: f32,
|
||||
) -> Result<(), String> {
|
||||
crate::alsa_hardware_volume::set_hardware_volume(&self.device_id, control, volume)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Check if device is a bit-perfect hardware device
|
||||
@@ -935,6 +923,47 @@ impl AlsaDirectStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only capability probe used before the UI enables hardware volume.
|
||||
pub fn probe_hardware_volume(device_id: &str) -> Result<HardwareVolumeInfo, String> {
|
||||
use crate::alsa_hardware_volume::{
|
||||
resolve_hardware_volume, HardwareVolumeDecision, HardwareVolumeUnsupportedReason,
|
||||
};
|
||||
let decision = resolve_hardware_volume(device_id, &Default::default());
|
||||
match decision {
|
||||
HardwareVolumeDecision::Selected { selection } => Ok(HardwareVolumeInfo {
|
||||
control_name: selection.control.name,
|
||||
volume: selection.candidate.current_volume.unwrap_or(1.0),
|
||||
}),
|
||||
HardwareVolumeDecision::NeedsChoice { candidates, .. } => Err(format!(
|
||||
"Ambiguous playback-volume controls for {device_id}: {}",
|
||||
candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.label.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)),
|
||||
HardwareVolumeDecision::Unsupported { reason, .. } => match reason {
|
||||
HardwareVolumeUnsupportedReason::Probe(error) => Err(error.message),
|
||||
other => Err(format!(
|
||||
"No compatible ALSA hardware volume for {device_id}: {other:?}"
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors the exact route predicate used by the player: ALSA plus a direct
|
||||
/// device id and either the `hw` or `plughw` engine. ALSA's default/sysdefault
|
||||
/// and `Pcm` routes use CPAL/Rodio and retain software volume.
|
||||
pub fn uses_alsa_direct_route(audio: &crate::settings::AudioSettings) -> bool {
|
||||
audio.backend_type == Some(crate::backend::AudioBackendType::Alsa)
|
||||
&& audio.alsa_plugin.unwrap_or(crate::backend::AlsaPlugin::Hw)
|
||||
!= crate::backend::AlsaPlugin::Pcm
|
||||
&& audio
|
||||
.output_device
|
||||
.as_deref()
|
||||
.is_some_and(AlsaDirectStream::is_hw_device)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl AlsaDirectStream {
|
||||
pub fn new(_device_id: &str, _sample_rate: u32, _channels: u16) -> Result<Self, String> {
|
||||
@@ -965,8 +994,118 @@ impl AlsaDirectStream {
|
||||
2
|
||||
}
|
||||
|
||||
pub fn playback_delay_frames(&self) -> Result<u64, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Check if device is a bit-perfect hardware device (always false on non-Linux)
|
||||
pub fn is_hw_device(_device_id: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::uses_alsa_direct_route;
|
||||
use crate::alsa_hardware_volume::mixer_ctl_name;
|
||||
use crate::backend::{AlsaPlugin, AudioBackendType};
|
||||
use crate::settings::AudioSettings;
|
||||
|
||||
#[test]
|
||||
fn mixer_ctl_name_maps_card_forms() {
|
||||
// HiFiBerry Digi S/PDIF (#331/#659) and other plugin aliases.
|
||||
assert_eq!(
|
||||
mixer_ctl_name("iec958:CARD=sndrpihifiberry,DEV=0"),
|
||||
"hw:CARD=sndrpihifiberry"
|
||||
);
|
||||
assert_eq!(mixer_ctl_name("hw:CARD=C20,DEV=0"), "hw:CARD=C20");
|
||||
assert_eq!(mixer_ctl_name("front:CARD=PCH,DEV=0"), "hw:CARD=PCH");
|
||||
assert_eq!(mixer_ctl_name("hdmi:CARD=NVidia,DEV=1"), "hw:CARD=NVidia");
|
||||
assert_eq!(
|
||||
mixer_ctl_name("sysdefault:CARD=DacMagic,DEV=0"),
|
||||
"hw:CARD=DacMagic"
|
||||
);
|
||||
// CARD= in a non-first position.
|
||||
assert_eq!(mixer_ctl_name("hw:DEV=0,CARD=DacMagic"), "hw:CARD=DacMagic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_ctl_name_numeric_and_fallback() {
|
||||
assert_eq!(mixer_ctl_name("hw:1,0"), "hw:1");
|
||||
assert_eq!(mixer_ctl_name("plughw:2"), "hw:2");
|
||||
// No CARD= and no numeric arg → unchanged.
|
||||
assert_eq!(mixer_ctl_name("default"), "default");
|
||||
assert_eq!(mixer_ctl_name("pulse"), "pulse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_route_requires_alsa_direct_device_and_non_pcm_plugin() {
|
||||
let mut audio = AudioSettings::default();
|
||||
assert!(!uses_alsa_direct_route(&audio));
|
||||
|
||||
audio.backend_type = Some(AudioBackendType::Alsa);
|
||||
audio.output_device = Some("front:CARD=USB,DEV=0".to_string());
|
||||
audio.alsa_plugin = Some(AlsaPlugin::Hw);
|
||||
assert_eq!(
|
||||
uses_alsa_direct_route(&audio),
|
||||
cfg!(target_os = "linux")
|
||||
);
|
||||
|
||||
audio.alsa_plugin = Some(AlsaPlugin::PlugHw);
|
||||
assert_eq!(
|
||||
uses_alsa_direct_route(&audio),
|
||||
cfg!(target_os = "linux")
|
||||
);
|
||||
|
||||
audio.alsa_plugin = Some(AlsaPlugin::Pcm);
|
||||
assert!(!uses_alsa_direct_route(&audio));
|
||||
|
||||
audio.alsa_plugin = Some(AlsaPlugin::Hw);
|
||||
audio.output_device = Some("sysdefault:CARD=USB".to_string());
|
||||
assert!(!uses_alsa_direct_route(&audio));
|
||||
|
||||
audio.output_device = None;
|
||||
assert!(!uses_alsa_direct_route(&audio));
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards to the inherent methods, which is all this is: no behaviour is
|
||||
/// added, moved or wrapped. See `backend::DirectSink`.
|
||||
impl crate::backend::DirectSink for AlsaDirectStream {
|
||||
fn write_f32(&self, samples: &[f32]) -> Result<(), String> {
|
||||
AlsaDirectStream::write_f32(self, samples)
|
||||
}
|
||||
fn drain(&self) -> Result<(), String> {
|
||||
AlsaDirectStream::drain(self)
|
||||
}
|
||||
fn stop(&self) -> Result<(), String> {
|
||||
AlsaDirectStream::stop(self)
|
||||
}
|
||||
fn sample_rate(&self) -> u32 {
|
||||
AlsaDirectStream::sample_rate(self)
|
||||
}
|
||||
fn channels(&self) -> u16 {
|
||||
AlsaDirectStream::channels(self)
|
||||
}
|
||||
fn playback_delay_frames(&self) -> Result<u64, String> {
|
||||
AlsaDirectStream::playback_delay_frames(self)
|
||||
}
|
||||
fn log_label(&self) -> &'static str {
|
||||
"ALSA Direct Engine"
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_device_id(&self) -> Option<&str> {
|
||||
Some(self.device_id())
|
||||
}
|
||||
/// Linux only, because the inherent method is: the non-Linux stub of this
|
||||
/// type has no mixer at all, and there the trait default refusal is the
|
||||
/// honest answer.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_hardware_volume(
|
||||
&self,
|
||||
control: &crate::alsa_hardware_volume::AlsaMixerControlId,
|
||||
volume: f32,
|
||||
) -> Result<(), String> {
|
||||
AlsaDirectStream::set_hardware_volume(self, control, volume)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,23 +14,30 @@ use rodio::Source;
|
||||
|
||||
/// Messages sent from the audio pipeline to the loudness analyzer thread.
|
||||
pub enum AnalyzerMessage {
|
||||
/// A batch of interleaved f32 samples.
|
||||
Samples(Vec<f32>),
|
||||
/// A new track has started — reset analyzer state.
|
||||
NewTrack {
|
||||
track_id: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
target_lufs: f32,
|
||||
/// Shared gain atomic — analyzer writes, DynamicAmplify reads.
|
||||
gain_atomic: Arc<AtomicU32>,
|
||||
},
|
||||
/// A batch of interleaved f32 samples with its absolute track frame.
|
||||
Samples { start_frame: u64, samples: Vec<f32> },
|
||||
/// The source reached its first audible frame. Emitting this from the tap,
|
||||
/// rather than when a gapless source is queued, preserves the true track
|
||||
/// boundary inside one output stream for both analyzers.
|
||||
NewTrack(AnalyzerWaveformTrack),
|
||||
/// Seek occurred — reset accumulated samples but keep current gain.
|
||||
Reset,
|
||||
/// Shut down the analyzer thread.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AnalyzerWaveformTrack {
|
||||
pub track_id: u64,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub duration_secs: u64,
|
||||
pub start_frame: u64,
|
||||
pub target_lufs: Option<f32>,
|
||||
/// Shared gain atomic — loudness analyzer writes, DynamicAmplify reads.
|
||||
pub gain_atomic: Option<Arc<AtomicU32>>,
|
||||
}
|
||||
|
||||
const BATCH_SIZE: usize = 4096;
|
||||
|
||||
pub struct AnalyzerTap<S>
|
||||
@@ -41,6 +48,11 @@ where
|
||||
sender: SyncSender<AnalyzerMessage>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
buffer: Vec<f32>,
|
||||
channels: u16,
|
||||
sample_cursor: u64,
|
||||
batch_start_frame: u64,
|
||||
waveform_track: Option<AnalyzerWaveformTrack>,
|
||||
waveform_boundary_sent: bool,
|
||||
}
|
||||
|
||||
impl<S> AnalyzerTap<S>
|
||||
@@ -48,22 +60,87 @@ where
|
||||
S: Source<Item = f32>,
|
||||
{
|
||||
pub fn new(source: S, sender: SyncSender<AnalyzerMessage>, enabled: Arc<AtomicBool>) -> Self {
|
||||
let channels = source.channels().get();
|
||||
Self {
|
||||
inner: source,
|
||||
sender,
|
||||
enabled,
|
||||
buffer: Vec::with_capacity(BATCH_SIZE),
|
||||
channels,
|
||||
sample_cursor: 0,
|
||||
batch_start_frame: 0,
|
||||
waveform_track: None,
|
||||
waveform_boundary_sent: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_waveform(
|
||||
source: S,
|
||||
sender: SyncSender<AnalyzerMessage>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
waveform_track: Option<AnalyzerWaveformTrack>,
|
||||
start_frame: u64,
|
||||
) -> Self {
|
||||
let channels = source.channels().get();
|
||||
Self {
|
||||
inner: source,
|
||||
sender,
|
||||
enabled,
|
||||
buffer: Vec::with_capacity(BATCH_SIZE),
|
||||
channels,
|
||||
sample_cursor: start_frame.saturating_mul(channels as u64),
|
||||
batch_start_frame: start_frame,
|
||||
waveform_boundary_sent: waveform_track.is_none(),
|
||||
waveform_track,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn announce_waveform_track(&mut self) {
|
||||
if self.waveform_boundary_sent
|
||||
|| (!self.enabled.load(Ordering::Relaxed)
|
||||
&& !crate::seek_waveform::seek_waveform_enabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(track) = self.waveform_track.clone() else {
|
||||
self.waveform_boundary_sent = true;
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.sender
|
||||
.try_send(AnalyzerMessage::NewTrack(track))
|
||||
.is_ok()
|
||||
{
|
||||
self.waveform_boundary_sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn flush_if_full(&mut self) {
|
||||
if self.buffer.len() >= BATCH_SIZE {
|
||||
let batch = std::mem::replace(&mut self.buffer, Vec::with_capacity(BATCH_SIZE));
|
||||
// Non-blocking send — drop batch if channel is full
|
||||
let _ = self.sender.try_send(AnalyzerMessage::Samples(batch));
|
||||
// Keep every message frame-aligned. 4096 is not divisible by every
|
||||
// legal channel count (notably 3), and splitting an interleaved frame
|
||||
// would shift both RMS analyzers at the next batch boundary.
|
||||
if self.buffer.len() >= BATCH_SIZE && self.buffer.len() % self.channels.max(1) as usize == 0
|
||||
{
|
||||
self.flush_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn flush_buffer(&mut self) {
|
||||
if self.buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
let batch = std::mem::replace(&mut self.buffer, Vec::with_capacity(BATCH_SIZE));
|
||||
// Non-blocking send — drop batch if channel is full. The absolute
|
||||
// frame on the next successful batch preserves the timeline gap.
|
||||
let _ = self.sender.try_send(AnalyzerMessage::Samples {
|
||||
start_frame: self.batch_start_frame,
|
||||
samples: batch,
|
||||
});
|
||||
self.batch_start_frame = self.sample_cursor / self.channels.max(1) as u64;
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Iterator for AnalyzerTap<S>
|
||||
@@ -74,11 +151,23 @@ where
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let sample = self.inner.next()?;
|
||||
let Some(sample) = self.inner.next() else {
|
||||
self.flush_buffer();
|
||||
return None;
|
||||
};
|
||||
|
||||
if self.enabled.load(Ordering::Relaxed) {
|
||||
self.announce_waveform_track();
|
||||
let capture =
|
||||
self.enabled.load(Ordering::Relaxed) || crate::seek_waveform::seek_waveform_enabled();
|
||||
if capture {
|
||||
if self.buffer.is_empty() {
|
||||
self.batch_start_frame = self.sample_cursor / self.channels.max(1) as u64;
|
||||
}
|
||||
self.buffer.push(sample);
|
||||
self.sample_cursor = self.sample_cursor.saturating_add(1);
|
||||
self.flush_if_full();
|
||||
} else {
|
||||
self.sample_cursor = self.sample_cursor.saturating_add(1);
|
||||
}
|
||||
|
||||
Some(sample)
|
||||
@@ -114,3 +203,88 @@ where
|
||||
self.inner.total_duration()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
use std::num::NonZero;
|
||||
use std::sync::mpsc;
|
||||
|
||||
fn track(track_id: u64) -> AnalyzerWaveformTrack {
|
||||
AnalyzerWaveformTrack {
|
||||
track_id,
|
||||
sample_rate: 48_000,
|
||||
channels: 2,
|
||||
duration_secs: 1,
|
||||
start_frame: 0,
|
||||
target_lufs: None,
|
||||
gain_atomic: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn source(samples: Vec<f32>) -> SamplesBuffer {
|
||||
SamplesBuffer::new(
|
||||
NonZero::new(2u16).unwrap(),
|
||||
NonZero::new(48_000u32).unwrap(),
|
||||
samples,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_is_emitted_only_when_gapless_source_becomes_audible() {
|
||||
let (tx, rx) = mpsc::sync_channel(8);
|
||||
let enabled = Arc::new(AtomicBool::new(true));
|
||||
let mut first = AnalyzerTap::new_with_waveform(
|
||||
source(vec![0.1, 0.1]),
|
||||
tx.clone(),
|
||||
enabled.clone(),
|
||||
Some(track(41)),
|
||||
0,
|
||||
);
|
||||
assert!(matches!(first.next(), Some(_)));
|
||||
assert!(
|
||||
matches!(rx.recv().unwrap(), AnalyzerMessage::NewTrack(info) if info.track_id == 41)
|
||||
);
|
||||
let _: Vec<_> = first.collect();
|
||||
assert!(matches!(
|
||||
rx.recv().unwrap(),
|
||||
AnalyzerMessage::Samples { start_frame: 0, .. }
|
||||
));
|
||||
|
||||
let mut queued =
|
||||
AnalyzerTap::new_with_waveform(source(vec![0.2, 0.2]), tx, enabled, Some(track(42)), 0);
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(matches!(queued.next(), Some(_)));
|
||||
assert!(
|
||||
matches!(rx.recv().unwrap(), AnalyzerMessage::NewTrack(info) if info.track_id == 42)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_flushes_partial_batch_with_absolute_start_frame() {
|
||||
let (tx, rx) = mpsc::sync_channel(8);
|
||||
let enabled = Arc::new(AtomicBool::new(true));
|
||||
let tap = AnalyzerTap::new_with_waveform(
|
||||
source(vec![0.3, 0.3, 0.4, 0.4]),
|
||||
tx,
|
||||
enabled,
|
||||
Some(track(51)),
|
||||
125,
|
||||
);
|
||||
let _: Vec<_> = tap.collect();
|
||||
assert!(
|
||||
matches!(rx.recv().unwrap(), AnalyzerMessage::NewTrack(info) if info.track_id == 51)
|
||||
);
|
||||
match rx.recv().unwrap() {
|
||||
AnalyzerMessage::Samples {
|
||||
start_frame,
|
||||
samples,
|
||||
} => {
|
||||
assert_eq!(start_frame, 125);
|
||||
assert_eq!(samples, vec![0.3, 0.3, 0.4, 0.4]);
|
||||
}
|
||||
_ => panic!("expected samples"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,16 @@ pub enum AudioBackendType {
|
||||
/// bit-perfect ALSA-exclusive / DAC-passthrough paths.
|
||||
Jack,
|
||||
|
||||
/// WASAPI EXCLUSIVE (Windows) - the bit-perfect path.
|
||||
/// - Takes the endpoint exclusively; other apps go silent
|
||||
/// - No resampling: the DAC is commanded to the track's own rate
|
||||
/// - Selecting a device is required, by ENDPOINT ID (Windows gives several
|
||||
/// endpoints the same friendly name)
|
||||
/// The trait's `create_output_stream` for this type is the SHARED CPAL one:
|
||||
/// the exclusive stream bypasses the trait entirely, exactly as ALSA Direct
|
||||
/// and JACK do.
|
||||
WasapiExclusive,
|
||||
|
||||
/// System default backend (non-Linux platforms)
|
||||
/// - Uses CPAL default host (CoreAudio on macOS, WASAPI on Windows)
|
||||
/// - Automatic device selection via OS audio system
|
||||
@@ -308,6 +318,14 @@ impl BackendManager {
|
||||
backends.push(AudioBackendType::SystemDefault);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Offered unconditionally: whether a given DEVICE accepts exclusive
|
||||
// mode is answered per device when the stream opens, not here, and
|
||||
// hiding the backend would hide the answer too.
|
||||
backends.push(AudioBackendType::WasapiExclusive);
|
||||
}
|
||||
|
||||
backends
|
||||
}
|
||||
|
||||
@@ -333,6 +351,14 @@ impl BackendManager {
|
||||
Ok(Box::new(CpalDefaultBackend::new()?))
|
||||
}
|
||||
}
|
||||
AudioBackendType::WasapiExclusive => {
|
||||
// The SHARED stream, deliberately. `create_stream_for_backend`
|
||||
// tries the exclusive stream first and only reaches here when
|
||||
// that is impossible - no device chosen, or the endpoint
|
||||
// refusing every format - so this is the graceful fallback
|
||||
// rather than the intended path.
|
||||
Ok(Box::new(CpalDefaultBackend::new()?))
|
||||
}
|
||||
AudioBackendType::SystemDefault => {
|
||||
// "System": play through the OS default output via CPAL's default
|
||||
// host — CoreAudio/WASAPI off-Linux, the ALSA "default" PCM on Linux
|
||||
@@ -390,7 +416,10 @@ impl BackendManager {
|
||||
// (e.g. a non-default socket name, or Flatpak where the socket path
|
||||
// differs but the pulse shim is bridged).
|
||||
if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
if std::path::Path::new(&runtime_dir).join("pipewire-0").exists() {
|
||||
if std::path::Path::new(&runtime_dir)
|
||||
.join("pipewire-0")
|
||||
.exists()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -453,6 +482,20 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
.map(|desc| desc.name().to_string())
|
||||
.unwrap_or_else(|_| "Default Output".to_string());
|
||||
|
||||
// WINDOWS ONLY, additive. Endpoint FRIENDLY NAMES are not unique
|
||||
// there: measured on a real box, three active render endpoints were
|
||||
// all called "Altavoces" - the motherboard Realtek, a Bluetooth
|
||||
// handsfree and a Cambridge Audio USB DAC. Matching the default by
|
||||
// name would mark all three.
|
||||
#[cfg(windows)]
|
||||
let default_endpoint_id = default_device.id().ok().map(|i| i.1);
|
||||
|
||||
// Idempotent, and this is the natural place: the first time anything
|
||||
// asks what devices exist is the first time it matters that the answer
|
||||
// can go stale.
|
||||
#[cfg(windows)]
|
||||
crate::wasapi_backend::start_hotplug_watch();
|
||||
|
||||
let mut devices = Vec::new();
|
||||
for device in self
|
||||
.host
|
||||
@@ -463,23 +506,92 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
.description()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.unwrap_or_else(|_| "Unknown Device".to_string());
|
||||
let is_default = name == default_name;
|
||||
|
||||
// On macOS, probe device capabilities via CoreAudio
|
||||
#[cfg(target_os = "macos")]
|
||||
let (supported_rates, max_rate, bus_type, is_hw) = { Self::probe_macos_device(&name) };
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// The placeholder for every platform that has no probe. On
|
||||
// Windows `supported_rates`/`max_rate` are SHADOWED further down
|
||||
// by the exclusive-mode sweep, which is why they carry an
|
||||
// underscore prefix there and nowhere else.
|
||||
#[cfg(all(not(target_os = "macos"), not(windows)))]
|
||||
let (supported_rates, max_rate, bus_type, is_hw): (
|
||||
Option<Vec<u32>>,
|
||||
Option<u32>,
|
||||
Option<String>,
|
||||
bool,
|
||||
) = (None, None, None, false);
|
||||
#[cfg(windows)]
|
||||
let (bus_type, is_hw): (Option<String>, bool) = (None, false);
|
||||
|
||||
// WINDOWS ONLY, additive: id, display and description all come
|
||||
// from fields cpal already fills but this backend ignored.
|
||||
//
|
||||
// `name` is the endpoint ("Altavoces") and repeats across
|
||||
// devices; `driver()` is the ADAPTER ("Cambridge Audio USB Audio
|
||||
// 2.0") and is what tells them apart; `id()` is the stable WASAPI
|
||||
// endpoint GUID. Using the name as the id meant device_filter
|
||||
// folded every same-named endpoint into one entry, so a USB DAC
|
||||
// sharing the name "Altavoces" with the onboard card could not be
|
||||
// selected at all - and an unselectable DAC blocks bit-perfect.
|
||||
#[cfg(windows)]
|
||||
let (id, display, description, is_default) = {
|
||||
let driver = device
|
||||
.description()
|
||||
.ok()
|
||||
.and_then(|d| d.driver().map(str::to_string))
|
||||
.filter(|d| !d.is_empty() && d.as_str() != name.as_str());
|
||||
let endpoint_id = device.id().ok().map(|i| i.1);
|
||||
let display = match &driver {
|
||||
Some(d) => format!("{name} ({d})"),
|
||||
None => name.clone(),
|
||||
};
|
||||
let is_default = match (&endpoint_id, &default_endpoint_id) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => name == default_name,
|
||||
};
|
||||
(
|
||||
endpoint_id.unwrap_or_else(|| name.clone()),
|
||||
display,
|
||||
driver,
|
||||
is_default,
|
||||
)
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let (id, display, description, is_default) =
|
||||
(name.clone(), name.clone(), None, name == default_name);
|
||||
|
||||
// WINDOWS: the rates the endpoint accepts in EXCLUSIVE mode, which
|
||||
// is the only list that describes what reaches the converter
|
||||
// untouched. Shadows the `(None, None, ...)` placeholder above.
|
||||
//
|
||||
// It cannot be inferred, which is the whole reason it is measured:
|
||||
// the owner's DAC accepts 44100/48000/88200/96000/192000 and
|
||||
// REFUSES 176400, so the 44.1 family stops at 88.2 while the 48
|
||||
// family reaches 192. Cached per endpoint, so this costs once.
|
||||
//
|
||||
// `supported_rates` answers `None` when the endpoint could not
|
||||
// be asked at all -- busy, gone, or COM refused -- and that must
|
||||
// not reach a caller as "supports nothing", which would narrow a
|
||||
// quality cap on the strength of a failed question. An endpoint
|
||||
// that answers and lists nothing is reported the same way for the
|
||||
// same reason: there is nothing to offer either way, and neither
|
||||
// is a fact worth caching upstream.
|
||||
#[cfg(windows)]
|
||||
let (supported_rates, max_rate) = {
|
||||
match crate::wasapi_backend::supported_rates(&id) {
|
||||
Some(rates) if !rates.is_empty() => {
|
||||
let top = rates.iter().copied().max();
|
||||
(Some(rates), top)
|
||||
}
|
||||
_ => (None, None),
|
||||
}
|
||||
};
|
||||
|
||||
devices.push(AudioDevice {
|
||||
id: name.clone(),
|
||||
name,
|
||||
description: None,
|
||||
id,
|
||||
name: display,
|
||||
description,
|
||||
is_default,
|
||||
max_sample_rate: max_rate,
|
||||
supported_sample_rates: supported_rates,
|
||||
@@ -560,6 +672,14 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
.output_devices()
|
||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||
.find(|d| {
|
||||
// WINDOWS ONLY, additive: enumerate_devices publishes the
|
||||
// stable endpoint GUID as the id there, because friendly
|
||||
// names repeat. The name comparison stays as the fallback
|
||||
// so a setting saved before this change still resolves.
|
||||
#[cfg(windows)]
|
||||
if d.id().map(|i| i.1 == *device_id).unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
d.description()
|
||||
.map(|desc| desc.name() == device_id.as_str())
|
||||
.unwrap_or(false)
|
||||
@@ -930,3 +1050,56 @@ impl CpalDefaultBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two-method surface a bit-perfect direct stream owes the player's writer
|
||||
/// thread, plus the three it owes the engine around it.
|
||||
///
|
||||
/// It exists so ONE writer thread serves both `AlsaDirectStream` and
|
||||
/// `WasapiDirectStream` instead of two copies drifting apart. The set is
|
||||
/// exactly what `playback_engine.rs` already called on the ALSA stream - no
|
||||
/// method was invented for the trait, and neither implementation gains or
|
||||
/// loses behaviour by having it.
|
||||
pub trait DirectSink: Send + Sync + 'static {
|
||||
/// Interleaved f32, blocking when the device's queue is full. That block
|
||||
/// IS the back-pressure that paces the writer thread.
|
||||
fn write_f32(&self, samples: &[f32]) -> Result<(), String>;
|
||||
/// Play out what is queued, then return. Bounded internally.
|
||||
fn drain(&self) -> Result<(), String>;
|
||||
fn stop(&self) -> Result<(), String>;
|
||||
fn sample_rate(&self) -> u32;
|
||||
fn channels(&self) -> u16;
|
||||
|
||||
/// Frames submitted to the sink but not yet heard, when the backend can
|
||||
/// measure them. The visualizer uses this only as latency compensation;
|
||||
/// an unsupported query keeps playback intact and falls back to the
|
||||
/// bounded direct-writer pacing.
|
||||
fn playback_delay_frames(&self) -> Result<u64, String> {
|
||||
Err("this direct sink cannot measure queued playback frames".to_string())
|
||||
}
|
||||
|
||||
/// Log prefix for this sink. On the trait rather than a parameter so a
|
||||
/// call site cannot label an ALSA stream "WASAPI" or the reverse - which
|
||||
/// is exactly what a hard-coded label at four call sites invites.
|
||||
fn log_label(&self) -> &'static str;
|
||||
|
||||
/// ALSA PCM identity for control-plane operations. Other direct sinks do
|
||||
/// not expose an ALSA route and keep the default `None`.
|
||||
fn alsa_device_id(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Device-side volume, where the sink has one.
|
||||
///
|
||||
/// The default REFUSES rather than silently succeeding: a sink without a
|
||||
/// hardware mixer must not pretend it moved the volume. Every call site is
|
||||
/// already gated on the engine's `hardware_volume` flag, which is false for
|
||||
/// any such sink - permanently so for WASAPI exclusive, where the phase B
|
||||
/// contract rules out ISimpleAudioVolume.
|
||||
fn set_hardware_volume(
|
||||
&self,
|
||||
_control: &crate::alsa_hardware_volume::AlsaMixerControlId,
|
||||
_volume: f32,
|
||||
) -> Result<(), String> {
|
||||
Err("this sink has no hardware volume".to_string())
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,11 @@ const FALLBACK_RATES: [u32; 6] = [44100, 48000, 88200, 96000, 176400, 192000];
|
||||
/// clean per-node format capability here, so we report the common set honestly
|
||||
/// as nominal (matches the prior Tauri behavior; do not present it as measured).
|
||||
fn nominal_formats() -> Vec<String> {
|
||||
vec!["S16LE".to_string(), "S24LE".to_string(), "F32LE".to_string()]
|
||||
vec![
|
||||
"S16LE".to_string(),
|
||||
"S24LE".to_string(),
|
||||
"F32LE".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Pure assembly (testable): combine detected rates + description into the DTO,
|
||||
@@ -64,9 +68,8 @@ fn assemble(
|
||||
/// Read-only — reads `/proc/asound` and runs `pw-dump`; never opens a stream.
|
||||
pub fn query_dac_capabilities(node_name: &str) -> DacCapabilities {
|
||||
// Pretty description from robust (pw-dump-backed, Slice 0) enumeration.
|
||||
let description = crate::backend::BackendManager::create_backend(
|
||||
crate::backend::AudioBackendType::PipeWire,
|
||||
)
|
||||
let description =
|
||||
crate::backend::BackendManager::create_backend(crate::backend::AudioBackendType::PipeWire)
|
||||
.ok()
|
||||
.and_then(|b| b.enumerate_devices().ok())
|
||||
.and_then(|devs| {
|
||||
@@ -92,7 +95,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn uses_detected_rates_when_present() {
|
||||
let caps = assemble("alsa_output.usb-x", Some(vec![44100, 96000, 192000]), Some("My DAC".into()));
|
||||
let caps = assemble(
|
||||
"alsa_output.usb-x",
|
||||
Some(vec![44100, 96000, 192000]),
|
||||
Some("My DAC".into()),
|
||||
);
|
||||
assert_eq!(caps.sample_rates, vec![44100, 96000, 192000]);
|
||||
assert_eq!(caps.description.as_deref(), Some("My DAC"));
|
||||
assert_eq!(caps.channels, Some(2));
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
//! bit-perfect / sample-rate-passthrough path is untouched.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
// Every target but Windows still runs `pw-dump`.
|
||||
#[cfg(not(windows))]
|
||||
use std::process::Command;
|
||||
|
||||
/// The DAC's live, negotiated hardware state — what the card is REALLY clocked
|
||||
@@ -94,6 +96,18 @@ pub fn parse_alsa_card_for_node(json: &str, node_name: &str) -> Option<u32> {
|
||||
}
|
||||
|
||||
/// Resolve the ALSA card number for a sink node by running `pw-dump`.
|
||||
///
|
||||
/// W14, Windows only: `pw-dump` does not exist there, so the spawn already
|
||||
/// failed into this same None. Scoped to Windows rather than `not(linux)` --
|
||||
/// a macOS or BSD box can have a real `pw-dump`, and this function is not the
|
||||
/// place to decide it may not speak.
|
||||
#[cfg(windows)]
|
||||
fn alsa_card_for_node(_node_name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve the ALSA card number for a sink node by running `pw-dump`.
|
||||
#[cfg(not(windows))]
|
||||
fn alsa_card_for_node(node_name: &str) -> Option<u32> {
|
||||
let output = Command::new("pw-dump").output().ok()?;
|
||||
if !output.status.success() {
|
||||
|
||||
@@ -40,7 +40,11 @@ pub fn is_discard_sink(display: &str) -> bool {
|
||||
/// description collapses the wrappers and keeps the genuinely distinct
|
||||
/// outputs.
|
||||
fn dedup_key(display: &str) -> String {
|
||||
display.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase()
|
||||
display
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Preference within a display group (lower = kept). When several PCM ids
|
||||
@@ -147,8 +151,14 @@ mod tests {
|
||||
));
|
||||
// null appears FIRST in the raw list but must be emitted last.
|
||||
let out = run(&[
|
||||
("null", "Discard all samples (playback) or generate zero samples (capture)"),
|
||||
("default", "Default ALSA Output (currently PipeWire Media Server)"),
|
||||
(
|
||||
"null",
|
||||
"Discard all samples (playback) or generate zero samples (capture)",
|
||||
),
|
||||
(
|
||||
"default",
|
||||
"Default ALSA Output (currently PipeWire Media Server)",
|
||||
),
|
||||
("front:CARD=PCH,DEV=0", "HDA Intel PCH, ALC3254 Analog"),
|
||||
]);
|
||||
let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect();
|
||||
@@ -173,12 +183,21 @@ mod tests {
|
||||
#[test]
|
||||
fn keeps_genuinely_distinct_outputs() {
|
||||
let out = run(&[
|
||||
("default", "Default ALSA Output (currently PipeWire Media Server)"),
|
||||
(
|
||||
"default",
|
||||
"Default ALSA Output (currently PipeWire Media Server)",
|
||||
),
|
||||
("front:CARD=PCH,DEV=0", "HDA Intel PCH, ALC3254 Analog"),
|
||||
("iec958:CARD=PCH,DEV=1", "HDA Intel PCH, ALC3254 Digital"),
|
||||
("hdmi:CARD=PCH,DEV=3", "HDA Intel PCH, HDMI 0"),
|
||||
("front:CARD=C20,DEV=0", "Cambridge Audio USB Audio 2.0, USB Audio"),
|
||||
("surround40:CARD=C20,DEV=0", "Cambridge Audio USB Audio 2.0, USB Audio"),
|
||||
(
|
||||
"front:CARD=C20,DEV=0",
|
||||
"Cambridge Audio USB Audio 2.0, USB Audio",
|
||||
),
|
||||
(
|
||||
"surround40:CARD=C20,DEV=0",
|
||||
"Cambridge Audio USB Audio 2.0, USB Audio",
|
||||
),
|
||||
]);
|
||||
let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect();
|
||||
assert_eq!(
|
||||
@@ -197,7 +216,10 @@ mod tests {
|
||||
fn passes_pipewire_node_names_through() {
|
||||
let out = run(&[
|
||||
("alsa_output.usb-Cambridge", "alsa_output.usb-Cambridge"),
|
||||
("alsa_output.pci-0000_00_1f.3", "alsa_output.pci-0000_00_1f.3"),
|
||||
(
|
||||
"alsa_output.pci-0000_00_1f.3",
|
||||
"alsa_output.pci-0000_00_1f.3",
|
||||
),
|
||||
]);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
@@ -205,11 +227,17 @@ mod tests {
|
||||
#[test]
|
||||
fn first_seen_order_is_preserved() {
|
||||
let out = run(&[
|
||||
("hw:CARD=C20,DEV=0", "Cambridge Audio USB Audio 2.0, USB Audio"),
|
||||
(
|
||||
"hw:CARD=C20,DEV=0",
|
||||
"Cambridge Audio USB Audio 2.0, USB Audio",
|
||||
),
|
||||
("default", "Default ALSA Output"),
|
||||
// Better-ranked id for Cambridge appears later; it wins the group
|
||||
// but the group keeps its first-seen position (before Default).
|
||||
("front:CARD=C20,DEV=0", "Cambridge Audio USB Audio 2.0, USB Audio"),
|
||||
(
|
||||
"front:CARD=C20,DEV=0",
|
||||
"Cambridge Audio USB Audio 2.0, USB Audio",
|
||||
),
|
||||
]);
|
||||
let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect();
|
||||
assert_eq!(ids, vec!["front:CARD=C20,DEV=0", "default"]);
|
||||
|
||||
@@ -311,9 +311,8 @@ fn try_acquire_name(
|
||||
bus_name: &str,
|
||||
replace: bool,
|
||||
) -> Result<RequestNameReply, ReservationError> {
|
||||
let proxy = DBusProxy::new(conn).map_err(|e| {
|
||||
ReservationError::DbusError(format!("DBusProxy::new failed: {}", e))
|
||||
})?;
|
||||
let proxy = DBusProxy::new(conn)
|
||||
.map_err(|e| ReservationError::DbusError(format!("DBusProxy::new failed: {}", e)))?;
|
||||
let well_known: WellKnownName<'_> = bus_name.try_into().map_err(|e| {
|
||||
ReservationError::DbusError(format!("invalid bus name '{}': {}", bus_name, e))
|
||||
})?;
|
||||
@@ -352,9 +351,8 @@ fn resolve_contention(
|
||||
// it twice for back-to-back property reads has been observed to cost an
|
||||
// extra GetAll round trip on some bus daemons; one proxy is both faster
|
||||
// and clearer.
|
||||
let holder_proxy = open_holder_proxy(conn, bus_name, object_path).map_err(|e| {
|
||||
ReservationError::DbusError(format!("Proxy::new for holder failed: {}", e))
|
||||
})?;
|
||||
let holder_proxy = open_holder_proxy(conn, bus_name, object_path)
|
||||
.map_err(|e| ReservationError::DbusError(format!("Proxy::new for holder failed: {}", e)))?;
|
||||
|
||||
// Default to 0 if the holder is uncooperative or doesn't expose Priority.
|
||||
// Rationale: PulseAudio/PipeWire are the most common holders and run at
|
||||
@@ -681,10 +679,7 @@ mod tests {
|
||||
] {
|
||||
match parse_card_index(s) {
|
||||
Ok(_) | Err(ReservationError::InvalidDevice(_)) => {}
|
||||
Err(other) => panic!(
|
||||
"unexpected error variant for {:?}: {:?}",
|
||||
s, other
|
||||
),
|
||||
Err(other) => panic!("unexpected error variant for {:?}: {:?}", s, other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! the check step can show the right `apt`/`dnf`/`pacman` remediation. None of
|
||||
//! this opens a stream — purely diagnostic.
|
||||
|
||||
// Every target but Windows still probes; see `audio_stack_health`.
|
||||
#[cfg(not(windows))]
|
||||
use std::process::Command;
|
||||
|
||||
/// Result of the audio-stack probes. All best-effort; a failed probe reads as
|
||||
@@ -32,7 +34,8 @@ impl AudioStackHealth {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `sh -c "<probe>"` exits 0.
|
||||
/// True if `sh -c "<probe>"` exits 0. Windows never probes; see below.
|
||||
#[cfg(not(windows))]
|
||||
fn sh_ok(probe: &str) -> bool {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
@@ -44,6 +47,31 @@ fn sh_ok(probe: &str) -> bool {
|
||||
|
||||
/// Run the audio-stack probes. Linux-only meaningfully; elsewhere everything
|
||||
/// reads false except where trivially true.
|
||||
///
|
||||
/// W14: on WINDOWS ONLY the answer is all-false, returned WITHOUT running the
|
||||
/// probes. `systemctl`, `pactl` and `pw-dump` do not exist there (a spawn of a
|
||||
/// missing image fails before a process is made, which is harmless), but `sh`
|
||||
/// DOES exist wherever Git for Windows is installed, so the five `sh -c`
|
||||
/// probes really run and flash five console windows past a GUI-subsystem
|
||||
/// binary that owns no console.
|
||||
///
|
||||
/// Scoped to Windows, not to `not(linux)`: macOS and the BSDs can have a real
|
||||
/// `pactl`/`pw-dump` from Homebrew or ports, and answering false for them
|
||||
/// would be a lie this diff has no business telling.
|
||||
#[cfg(windows)]
|
||||
pub fn audio_stack_health() -> AudioStackHealth {
|
||||
AudioStackHealth {
|
||||
wireplumber_active: false,
|
||||
has_pw_dump: false,
|
||||
cpal_sees_pipewire: false,
|
||||
has_pactl: false,
|
||||
any_devices: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the audio-stack probes. Linux-only meaningfully; elsewhere everything
|
||||
/// reads false except where trivially true.
|
||||
#[cfg(not(windows))]
|
||||
pub fn audio_stack_health() -> AudioStackHealth {
|
||||
// systemd path first; fall back to a process check so non-systemd inits
|
||||
// (Gentoo/OpenRC, Void/runit) don't read as "WirePlumber down".
|
||||
@@ -258,9 +286,15 @@ fn parse_distro(os_release: &str) -> Distro {
|
||||
// os-release values may be bare, double-quoted, or single-quoted
|
||||
// (Gentoo uses single quotes), so strip both quote styles.
|
||||
if let Some(v) = line.strip_prefix("ID=") {
|
||||
id = v.trim().trim_matches(|c| c == '"' || c == '\'').to_lowercase();
|
||||
id = v
|
||||
.trim()
|
||||
.trim_matches(|c| c == '"' || c == '\'')
|
||||
.to_lowercase();
|
||||
} else if let Some(v) = line.strip_prefix("ID_LIKE=") {
|
||||
id_like = v.trim().trim_matches(|c| c == '"' || c == '\'').to_lowercase();
|
||||
id_like = v
|
||||
.trim()
|
||||
.trim_matches(|c| c == '"' || c == '\'')
|
||||
.to_lowercase();
|
||||
}
|
||||
}
|
||||
let hay = format!("{} {}", id, id_like);
|
||||
@@ -315,7 +349,10 @@ mod tests {
|
||||
fn classifies_fedora_arch_suse_gentoo_void_other() {
|
||||
assert_eq!(parse_distro("ID=fedora\n"), Distro::Fedora);
|
||||
assert_eq!(parse_distro("ID=arch\n"), Distro::Arch);
|
||||
assert_eq!(parse_distro("ID=opensuse-tumbleweed\nID_LIKE=\"suse opensuse\"\n"), Distro::OpenSuse);
|
||||
assert_eq!(
|
||||
parse_distro("ID=opensuse-tumbleweed\nID_LIKE=\"suse opensuse\"\n"),
|
||||
Distro::OpenSuse
|
||||
);
|
||||
assert_eq!(parse_distro("ID=gentoo\n"), Distro::Gentoo);
|
||||
// Gentoo's real os-release single-quotes the value.
|
||||
assert_eq!(parse_distro("ID='gentoo'\n"), Distro::Gentoo);
|
||||
|
||||
@@ -80,8 +80,10 @@ impl JackStream {
|
||||
/// Open the JACK client, register stable stereo ports, activate, and
|
||||
/// auto-connect to the system's physical playback.
|
||||
pub fn new(channels: u16) -> Result<Self, String> {
|
||||
let (client, _status) = Client::new("qbz", ClientOptions::NO_START_SERVER)
|
||||
.map_err(|e| format!("JACK client open failed (is a JACK/pipewire-jack server running?): {e}"))?;
|
||||
let (client, _status) =
|
||||
Client::new("qbz", ClientOptions::NO_START_SERVER).map_err(|e| {
|
||||
format!("JACK client open failed (is a JACK/pipewire-jack server running?): {e}")
|
||||
})?;
|
||||
|
||||
let sample_rate = client.sample_rate() as u32;
|
||||
|
||||
@@ -123,7 +125,8 @@ impl JackStream {
|
||||
}
|
||||
log::info!(
|
||||
"[JACK] auto-connected qbz:out_FL/FR -> {} / {}",
|
||||
playback[0], playback[1]
|
||||
playback[0],
|
||||
playback[1]
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
|
||||
+40
-21
@@ -26,33 +26,39 @@
|
||||
//! └───────────────┘
|
||||
//! ```
|
||||
|
||||
pub mod backend;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pipewire_backend;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod alsa_backend;
|
||||
pub mod alsa_direct;
|
||||
pub mod alsa_hardware_volume;
|
||||
pub mod wasapi_direct;
|
||||
/// Endpoint capabilities: the exclusive-mode rate sweep and the hotplug watch.
|
||||
#[cfg(windows)]
|
||||
pub mod wasapi_backend;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod alsa_error_handler;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pulse_backend;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod jack_backend;
|
||||
pub mod alsa_direct;
|
||||
pub mod analysis;
|
||||
pub mod analyzer_tap;
|
||||
pub mod backend;
|
||||
pub mod coreaudio_direct;
|
||||
pub mod dac_capabilities;
|
||||
pub mod dac_probe;
|
||||
pub mod health;
|
||||
pub mod analysis;
|
||||
pub mod analyzer_tap;
|
||||
pub mod device_filter;
|
||||
pub mod device_reservation;
|
||||
pub mod diagnostic;
|
||||
pub mod dynamic_amplify;
|
||||
pub mod health;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod jack_backend;
|
||||
pub mod loudness;
|
||||
pub mod loudness_analyzer;
|
||||
pub mod loudness_cache;
|
||||
pub mod network_throttle;
|
||||
pub mod output_sinks;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pipewire_backend;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pulse_backend;
|
||||
pub mod seek_waveform;
|
||||
pub mod settings;
|
||||
pub mod visualizer;
|
||||
|
||||
@@ -63,10 +69,9 @@ pub use alsa_backend::{
|
||||
resolve_stable_to_current_hw,
|
||||
};
|
||||
pub use alsa_direct::AlsaDirectStream;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use jack_backend::JackStream;
|
||||
pub use wasapi_direct::{Rung, LADDER};
|
||||
pub use analysis::SpectralAnalyzer;
|
||||
pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap};
|
||||
pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap, AnalyzerWaveformTrack};
|
||||
pub use backend::{
|
||||
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
|
||||
BackendManager, BackendResult, BitPerfectMode,
|
||||
@@ -74,32 +79,46 @@ pub use backend::{
|
||||
pub use coreaudio_direct::CoreAudioExclusiveGuard;
|
||||
pub use dac_capabilities::{query_dac_capabilities, DacCapabilities};
|
||||
pub use dac_probe::{negotiated_active_rate, negotiated_stream_rate, NegotiatedRate};
|
||||
pub use device_reservation::{DeviceReservation, ReservationError};
|
||||
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
|
||||
pub use dynamic_amplify::DynamicAmplify;
|
||||
pub use health::{
|
||||
audio_stack_health, detect_distro, detect_init, detect_sandbox, AudioStackHealth, Distro,
|
||||
InitSystem, Sandbox,
|
||||
};
|
||||
pub use device_reservation::{DeviceReservation, ReservationError};
|
||||
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
|
||||
pub use dynamic_amplify::DynamicAmplify;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use jack_backend::JackStream;
|
||||
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};
|
||||
pub use loudness_analyzer::LoudnessAnalyzer;
|
||||
pub use loudness_cache::LoudnessCache;
|
||||
pub use output_sinks::{list_output_sinks, OutputSinkInfo};
|
||||
pub use seek_waveform::{
|
||||
register_seek_waveform_key, seek_waveform_content_key, seek_waveform_snapshot,
|
||||
set_seek_waveform_enabled, SeekWaveformSnapshot, SEEK_WAVEFORM_BINS,
|
||||
};
|
||||
pub use settings::AudioSettings;
|
||||
pub use visualizer::{RingBuffer, TappedSource, VisualizerTap};
|
||||
|
||||
/// Stub: returns the ID unchanged on non-Linux (no ALSA normalization needed).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn normalize_device_id_to_stable(id: &str) -> String { id.to_string() }
|
||||
pub fn normalize_device_id_to_stable(id: &str) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// Stub: no ALSA device resolution on non-Linux.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn resolve_stable_to_current_hw(_stable: &str) -> Option<String> { None }
|
||||
pub fn resolve_stable_to_current_hw(_stable: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Stub: no ALSA sample rate probing on non-Linux.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn device_supports_sample_rate(_device_id: &str, _sample_rate: u32) -> Option<bool> { None }
|
||||
pub fn device_supports_sample_rate(_device_id: &str, _sample_rate: u32) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Stub: no ALSA rate enumeration on non-Linux.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn get_device_supported_rates(_device_id: &str) -> Option<Vec<u32>> { None }
|
||||
pub fn get_device_supported_rates(_device_id: &str) -> Option<Vec<u32>> {
|
||||
None
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use ebur128::{EbuR128, Mode};
|
||||
use super::analyzer_tap::AnalyzerMessage;
|
||||
use super::loudness::db_to_linear;
|
||||
use super::loudness_cache::LoudnessCache;
|
||||
use super::seek_waveform::{SeekWaveformAccumulator, SeekWaveformCache};
|
||||
|
||||
/// Maximum gain boost in dB (conservative clipping prevention)
|
||||
const MAX_GAIN_DB: f32 = 6.0;
|
||||
@@ -44,6 +45,11 @@ impl LoudnessAnalyzer {
|
||||
|
||||
fn run(rx: Receiver<AnalyzerMessage>, cache: Arc<LoudnessCache>) {
|
||||
let mut state: Option<AnalyzerState> = None;
|
||||
// Default-off really is idle: do not touch the waveform database
|
||||
// until the preference is enabled for an audible track.
|
||||
let mut waveform_cache = None;
|
||||
let mut waveform: Option<SeekWaveformAccumulator> = None;
|
||||
let mut current_track = None;
|
||||
|
||||
loop {
|
||||
let msg = match rx.recv() {
|
||||
@@ -55,50 +61,76 @@ impl LoudnessAnalyzer {
|
||||
};
|
||||
|
||||
match msg {
|
||||
AnalyzerMessage::NewTrack {
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
target_lufs,
|
||||
gain_atomic,
|
||||
} => {
|
||||
AnalyzerMessage::NewTrack(track) => {
|
||||
state = match (track.target_lufs, track.gain_atomic.clone()) {
|
||||
(Some(target_lufs), Some(gain_atomic)) => {
|
||||
log::info!(
|
||||
"[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
track.track_id,
|
||||
track.sample_rate,
|
||||
track.channels,
|
||||
target_lufs
|
||||
);
|
||||
|
||||
// Check cache first
|
||||
if let Some(cached) = cache.get(track_id) {
|
||||
let mut analyzer = AnalyzerState::new(
|
||||
track.track_id,
|
||||
track.sample_rate,
|
||||
track.channels,
|
||||
target_lufs,
|
||||
);
|
||||
if let Some(cached) = cache.get(track.track_id) {
|
||||
let gain = compute_gain_capped(cached.gain_db);
|
||||
gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
|
||||
analyzer.initial_done = true;
|
||||
log::info!(
|
||||
"[LoudnessAnalyzer] Cache hit for track {}: {:.2} dB (source: {}), gain {:.4}",
|
||||
track_id, cached.gain_db, cached.source, gain
|
||||
track.track_id, cached.gain_db, cached.source, gain
|
||||
);
|
||||
|
||||
// Set gain immediately via the atomic
|
||||
gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
|
||||
|
||||
// Create state marked as cached — still accept samples for refinement
|
||||
let mut s =
|
||||
AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
|
||||
s.gain_atomic = Some(gain_atomic);
|
||||
s.initial_done = true;
|
||||
state = Some(s);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No cache — start fresh analysis
|
||||
let mut s = AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
|
||||
s.gain_atomic = Some(gain_atomic);
|
||||
state = Some(s);
|
||||
analyzer.gain_atomic = Some(gain_atomic);
|
||||
Some(analyzer)
|
||||
}
|
||||
AnalyzerMessage::Samples(samples) => {
|
||||
_ => None,
|
||||
};
|
||||
waveform = if super::seek_waveform::seek_waveform_enabled() {
|
||||
ensure_waveform_cache(&mut waveform_cache);
|
||||
Some(SeekWaveformAccumulator::begin(
|
||||
track.track_id,
|
||||
track.sample_rate,
|
||||
track.channels,
|
||||
track.duration_secs,
|
||||
waveform_cache.as_ref(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
current_track = Some(track);
|
||||
}
|
||||
AnalyzerMessage::Samples {
|
||||
start_frame,
|
||||
samples,
|
||||
} => {
|
||||
if let Some(ref mut s) = state {
|
||||
s.feed_samples(&samples, &cache);
|
||||
}
|
||||
if super::seek_waveform::seek_waveform_enabled() {
|
||||
ensure_waveform_cache(&mut waveform_cache);
|
||||
if waveform.is_none() {
|
||||
waveform = current_track.as_ref().map(|track| {
|
||||
SeekWaveformAccumulator::begin(
|
||||
track.track_id,
|
||||
track.sample_rate,
|
||||
track.channels,
|
||||
track.duration_secs,
|
||||
waveform_cache.as_ref(),
|
||||
)
|
||||
});
|
||||
}
|
||||
if let Some(ref mut accumulator) = waveform {
|
||||
accumulator.feed(start_frame, &samples, waveform_cache.as_ref());
|
||||
}
|
||||
} else {
|
||||
waveform = None;
|
||||
}
|
||||
}
|
||||
AnalyzerMessage::Reset => {
|
||||
if let Some(ref mut s) = state {
|
||||
@@ -115,6 +147,14 @@ impl LoudnessAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_waveform_cache(cache: &mut Option<SeekWaveformCache>) {
|
||||
if cache.is_none() {
|
||||
*cache = SeekWaveformCache::open()
|
||||
.map_err(|error| log::warn!("[SeekWaveform] {error}"))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
struct AnalyzerState {
|
||||
track_id: u64,
|
||||
target_lufs: f32,
|
||||
|
||||
Loaded 100 of 2869 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user