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
+463366
-309433
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,33 +1,34 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: Report something that is not working as expected
|
description: Tell us about something that is not working as expected
|
||||||
title: "[Bug] "
|
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## ⚠️ Bug reports need logs
|
Thanks for reporting this. Please describe what happened and, if
|
||||||
Without logs I am debugging blind, and the report will almost certainly
|
possible, how to reproduce it. Logs help with most reports, but they
|
||||||
stall waiting on `needs: info` instead of getting fixed.
|
are not required when the issue is self-explanatory or unavailable.
|
||||||
|
|
||||||
**Please attach your logs in the Logs section below** — it is the single
|
If you investigated the cause, please include what you found. Patches
|
||||||
most useful thing you can do to get this fixed fast
|
are welcome, but never required.
|
||||||
(**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.
|
|
||||||
|
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: preflight
|
id: area
|
||||||
attributes:
|
attributes:
|
||||||
label: Before submitting
|
label: Area
|
||||||
description: A bug report without these is much harder to act on.
|
description: Which part of QBZ is affected?
|
||||||
options:
|
options:
|
||||||
- label: I have attached my logs below (Terminal + Console), or explained why I cannot get them.
|
- Startup or crash
|
||||||
required: true
|
- Playback or audio
|
||||||
- label: I searched the existing issues and this is not a duplicate.
|
- Qobuz login or catalog
|
||||||
required: true
|
- Local or offline library
|
||||||
- label: I included concrete steps to reproduce.
|
- Qobuz Connect or casting
|
||||||
required: true
|
- Interface or navigation
|
||||||
|
- qbzd or headless use
|
||||||
|
- Installation, update or packaging
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: platform
|
id: platform
|
||||||
@@ -36,41 +37,29 @@ body:
|
|||||||
options:
|
options:
|
||||||
- Linux
|
- Linux
|
||||||
- macOS
|
- macOS
|
||||||
|
- Windows
|
||||||
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Description
|
label: What happened?
|
||||||
description: A clear and concise description of the problem.
|
description: Tell us what you observed and what you expected instead.
|
||||||
|
placeholder: Describe the problem in your own words.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps
|
id: steps
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: How can we reproduce it?
|
||||||
value: |
|
description: If it is intermittent, describe what you were doing before it appeared.
|
||||||
1.
|
placeholder: |
|
||||||
2.
|
1. Open...
|
||||||
3.
|
2. Select...
|
||||||
validations:
|
3. Observe...
|
||||||
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.
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -78,7 +67,8 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: QBZ version
|
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:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -88,94 +78,51 @@ body:
|
|||||||
label: Installation method
|
label: Installation method
|
||||||
options:
|
options:
|
||||||
- AppImage
|
- AppImage
|
||||||
- .deb
|
- APT repository or .deb
|
||||||
- .rpm
|
- RPM repository or .rpm
|
||||||
- AUR
|
- AUR
|
||||||
- Gentoo overlay
|
- Gentoo overlay
|
||||||
- Tarball
|
- Nix or NixOS
|
||||||
- Flatpak
|
- Flatpak
|
||||||
- DMG (macOS)
|
- Snap
|
||||||
|
- Homebrew Cask
|
||||||
|
- Signed macOS DMG
|
||||||
|
- Upstream macOS DMG
|
||||||
|
- Windows MSI or ZIP
|
||||||
|
- Standalone qbzd tarball
|
||||||
- Built from source
|
- Built from source
|
||||||
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: textarea
|
||||||
id: distro
|
id: environment
|
||||||
attributes:
|
attributes:
|
||||||
label: Distribution / macOS version
|
label: Environment details
|
||||||
description: Linux distro or macOS version.
|
description: Add only what seems relevant to this report.
|
||||||
placeholder: e.g. Arch Linux, Ubuntu 24.04, Fedora 43, macOS 15.3 Sequoia
|
placeholder: |
|
||||||
|
OS or distribution:
|
||||||
- type: input
|
Desktop environment / display server:
|
||||||
id: de
|
Audio backend / DAC:
|
||||||
attributes:
|
GPU:
|
||||||
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
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Logs
|
label: Logs
|
||||||
description: |
|
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**
|
If they are relevant and available, open Settings > Developer Mode >
|
||||||
2. Click **Upload** on both the **Terminal** and **Console** tabs
|
View Logs, upload both Terminal and Console, and paste both links here.
|
||||||
3. Paste both URLs here
|
For startup failures, terminal output is enough.
|
||||||
|
placeholder: |
|
||||||
Where to find the logs (click the image to enlarge):
|
Terminal log URL:
|
||||||
|
Console log URL:
|
||||||
[](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:
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: screenshots
|
id: attachments
|
||||||
attributes:
|
attributes:
|
||||||
label: Screenshots
|
label: Screenshots or additional context
|
||||||
description: If applicable, add screenshots to help illustrate the issue.
|
description: Drag in images or files, or add anything else that may help.
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Anything else that may help reproduce or understand the issue.
|
|
||||||
@@ -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
|
name: Feature request
|
||||||
description: Suggest an idea or improvement
|
description: Suggest an idea or improvement
|
||||||
title: "[Feature] "
|
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement"]
|
||||||
body:
|
body:
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
@@ -12,6 +11,7 @@ body:
|
|||||||
- All platforms
|
- All platforms
|
||||||
- Linux
|
- Linux
|
||||||
- macOS
|
- macOS
|
||||||
|
- Windows
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
name: Feedback
|
name: Feedback
|
||||||
description: General feedback, UX thoughts, or non-bug discussion
|
description: General feedback, UX thoughts, or non-bug discussion
|
||||||
title: "[Feedback] "
|
|
||||||
labels: ["feedback"]
|
labels: ["feedback"]
|
||||||
body:
|
body:
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
@@ -11,6 +10,7 @@ body:
|
|||||||
- ""
|
- ""
|
||||||
- Linux
|
- Linux
|
||||||
- macOS
|
- macOS
|
||||||
|
- Windows
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,40 @@
|
|||||||
|
|
||||||
<!-- What does this PR do? -->
|
<!-- 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
|
## Checklist
|
||||||
|
|
||||||
- [ ] My change targets the `crates/` workspace. The Svelte `src/` and Tauri
|
- [ ] This PR targets `pre-release`, not `main`.
|
||||||
`src-tauri/` trees were **removed in 2.0.2** (preserved only at git tag
|
- [ ] I reviewed the complete diff and described how I verified the change.
|
||||||
`legacy-tauri-svelte` for reference); PRs against those paths can't be
|
- [ ] My change targets the live Qt/Rust tree. The Slint crates and the deleted
|
||||||
merged. If your fix targets the old tree, open an issue instead and we'll
|
Svelte/Tauri trees are historical references and cannot accept fixes.
|
||||||
help you port it.
|
- [ ] 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,
|
issue_number: context.payload.issue.number,
|
||||||
labels: ['platform:linux']
|
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
|
name: Redirect PRs to pre-release
|
||||||
|
|
||||||
on:
|
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]
|
branches: [main]
|
||||||
types: [opened]
|
types: [opened]
|
||||||
|
|
||||||
|
|||||||
+244
-132
@@ -1,24 +1,28 @@
|
|||||||
name: release-aur
|
name: release-aur
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Trigger after release-linux completes (it uploads amd64 tarball)
|
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["release-linux"]
|
workflows: ["release-linux"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to publish (e.g. 1.0.0)"
|
description: "Version to prepare (e.g. 2.1.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
pkgrel:
|
pkgrel:
|
||||||
description: "Package release number (bump for same-version rebuilds)"
|
description: "Package release number"
|
||||||
required: false
|
required: false
|
||||||
default: "1"
|
default: "1"
|
||||||
type: string
|
type: string
|
||||||
|
publish:
|
||||||
|
description: "Push all four generated packages to AUR"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: aur-publish
|
group: aur-prepare-publish
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -26,160 +30,268 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-aur:
|
update-aur:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
# Only run on manual dispatch, or successful stable tag releases (no -rc)
|
# workflow_run prepares verified metadata only. Publishing is deliberately
|
||||||
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')) }}
|
# 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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout app repo
|
||||||
uses: actions/checkout@v7
|
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
|
- name: Resolve and verify release
|
||||||
id: version
|
id: release
|
||||||
env:
|
env:
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
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_VERSION: ${{ inputs.version }}
|
||||||
|
DISPATCH_PUBLISH: ${{ inputs.publish }}
|
||||||
|
DISPATCH_PKGREL: ${{ inputs.pkgrel }}
|
||||||
run: |
|
run: |
|
||||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
set -euo pipefail
|
||||||
# Extract version from the triggering workflow's head branch (tag)
|
|
||||||
REF="$WORKFLOW_HEAD_BRANCH"
|
if [[ "$EVENT_NAME" == workflow_run ]]; then
|
||||||
if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
tag="$RUN_HEAD_BRANCH"
|
||||||
echo "Invalid workflow_run head branch for release: $REF"
|
version="${tag#v}"
|
||||||
exit 1
|
pkgrel=1
|
||||||
fi
|
should_publish=false
|
||||||
VERSION="${REF#v}"
|
|
||||||
else
|
else
|
||||||
VERSION="$DISPATCH_VERSION"
|
version="$DISPATCH_VERSION"
|
||||||
|
tag="v${version}"
|
||||||
|
pkgrel="${DISPATCH_PKGREL:-1}"
|
||||||
|
should_publish="${DISPATCH_PUBLISH:-false}"
|
||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Detected version: $VERSION"
|
|
||||||
|
|
||||||
- name: Wait for all release assets
|
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||||
env:
|
echo "::error::Invalid release version: $version"
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
exit 1
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
fi
|
||||||
run: |
|
if [[ ! "$pkgrel" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
echo "Waiting for release assets to be available..."
|
echo "::error::Invalid pkgrel: $pkgrel"
|
||||||
echo "Note: amd64 should be ready (release-linux completed), waiting for aarch64..."
|
exit 1
|
||||||
MAX_WAIT=1800
|
fi
|
||||||
INTERVAL=30
|
if [[ "$should_publish" == true && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
ELAPSED=0
|
echo "::error::AUR publishing is limited to stable x.y.z releases"
|
||||||
|
|
||||||
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!"
|
|
||||||
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"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Download release tarballs and get checksums
|
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.release.outputs.version }}
|
||||||
|
run: |
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
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 sources and calculate checksums
|
||||||
id: checksums
|
id: checksums
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
# Download x86_64 tarball
|
set -euo pipefail
|
||||||
AMD64_URL="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz"
|
mkdir -p /tmp/aur-sources
|
||||||
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"
|
|
||||||
|
|
||||||
# Download aarch64 tarball
|
download_hash() {
|
||||||
AARCH64_URL="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz"
|
local output_name="$1" url="$2" file="$3" sum
|
||||||
echo "Downloading: $AARCH64_URL"
|
curl -fsSL "$url" -o "/tmp/aur-sources/$file"
|
||||||
curl -fsSL -o /tmp/qbz_aarch64.tar.gz "$AARCH64_URL"
|
sum="$(sha256sum "/tmp/aur-sources/$file" | cut -d' ' -f1)"
|
||||||
SHA256_AARCH64=$(sha256sum /tmp/qbz_aarch64.tar.gz | cut -d' ' -f1)
|
echo "${output_name}=${sum}" >> "$GITHUB_OUTPUT"
|
||||||
echo "sha256_aarch64=$SHA256_AARCH64" >> "$GITHUB_OUTPUT"
|
echo "${file}: ${sum}"
|
||||||
echo "aarch64 Checksum: $SHA256_AARCH64"
|
}
|
||||||
|
|
||||||
- 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:
|
env:
|
||||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
set -euo pipefail
|
||||||
echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
|
if [[ -z "$AUR_SSH_PRIVATE_KEY" ]]; then
|
||||||
chmod 600 ~/.ssh/aur
|
echo "::error::AUR_SSH_PRIVATE_KEY is required for publishing"
|
||||||
echo "Host aur.archlinux.org" >> ~/.ssh/config
|
exit 1
|
||||||
echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config
|
fi
|
||||||
echo " User aur" >> ~/.ssh/config
|
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
|
ssh-keyscan -t ed25519 aur.archlinux.org >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Clone AUR repo
|
- name: Clone AUR package repos
|
||||||
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"
|
|
||||||
else
|
|
||||||
echo "pkgrel=${{ inputs.pkgrel || '1' }}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Update PKGBUILD
|
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
|
||||||
PKGREL: ${{ steps.pkgrel.outputs.pkgrel }}
|
|
||||||
SHA256_AMD64: ${{ steps.checksums.outputs.sha256_amd64 }}
|
|
||||||
SHA256_AARCH64: ${{ steps.checksums.outputs.sha256_aarch64 }}
|
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/aur-qbz-bin
|
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
|
||||||
|
url="https://aur.archlinux.org/${package}.git"
|
||||||
|
fi
|
||||||
|
|
||||||
# Copy PKGBUILD from main repo (source of truth)
|
if ! git clone "$url" "$destination"; then
|
||||||
cp ${{ github.workspace }}/packaging/aur/PKGBUILD .
|
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
|
||||||
|
|
||||||
# Update version and pkgrel
|
- name: Stamp PKGBUILDs
|
||||||
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
|
|
||||||
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
|
|
||||||
useradd -m builder
|
|
||||||
chown -R builder:builder /pkg
|
|
||||||
sudo -u builder makepkg --printsrcinfo > .SRCINFO
|
|
||||||
chown -R \$HOST_UID:\$HOST_GID /pkg
|
|
||||||
"
|
|
||||||
cat .SRCINFO
|
|
||||||
|
|
||||||
- name: Commit and push to AUR
|
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
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: |
|
run: |
|
||||||
cd /tmp/aur-qbz-bin
|
set -euo pipefail
|
||||||
git config user.name "GitHub Actions"
|
for package in qbz-bin qbz qbzd-bin qbzd; do
|
||||||
git config user.email "actions@github.com"
|
cp "packaging/aur/${package}/"* "/tmp/aur-${package}/"
|
||||||
git add PKGBUILD .SRCINFO
|
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "/tmp/aur-${package}/PKGBUILD"
|
||||||
git commit -m "Update to v${VERSION}"
|
sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" "/tmp/aur-${package}/PKGBUILD"
|
||||||
git push
|
done
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Generate and validate .SRCINFO files
|
||||||
|
run: |
|
||||||
|
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
|
||||||
|
runuser -u builder -- bash -c \
|
||||||
|
"cd /pkg && makepkg --printsrcinfo > .SRCINFO"
|
||||||
|
chown -R "${HOST_UID}:${HOST_GID}" /pkg
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Commit and push all AUR packages
|
||||||
|
if: steps.release.outputs.should_publish == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
|
run: |
|
||||||
|
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
|
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch: the bundle is a
|
||||||
# packaging/flatpak/com.blitzfc.qbz.yml (network manifest) with
|
# REPACK of the bare binary from the shared build-qt-linux.yml on the KDE
|
||||||
# flatpak-builder and attaches the .flatpak bundle to the Release.
|
# runtime (org.kde.Platform 6.9 provides Qt). No cargo inside flatpak-builder,
|
||||||
# Flathub proper is a separate repo/manifest updated at tag time via
|
# no swap ceremony. Same shape as the Flathub manifest, which consumes the
|
||||||
# scripts/update-flathub.sh (offline sources).
|
# published release tarball instead.
|
||||||
#
|
|
||||||
# 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).
|
|
||||||
|
|
||||||
name: release-flatpak
|
name: release-flatpak
|
||||||
|
|
||||||
@@ -16,7 +12,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
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
|
required: false
|
||||||
type: string
|
type: string
|
||||||
push:
|
push:
|
||||||
@@ -28,15 +24,46 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
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:
|
build-flatpak:
|
||||||
|
needs: [build, build-qbzd]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 300
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Resolve release version
|
- name: Resolve release version
|
||||||
@@ -65,6 +92,17 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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).
|
# Keep the metainfo's top <release> entry honest (version + date).
|
||||||
- name: Sync metainfo release entry
|
- name: Sync metainfo release entry
|
||||||
if: steps.release.outputs.version != ''
|
if: steps.release.outputs.version != ''
|
||||||
@@ -86,35 +124,40 @@ jobs:
|
|||||||
p.write_text(text, encoding="utf-8")
|
p.write_text(text, encoding="utf-8")
|
||||||
PY
|
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: |
|
run: |
|
||||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
set -euo pipefail
|
||||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
P=packaging/flatpak/payload
|
||||||
/usr/local/lib/node_modules || true
|
chmod +x "$P/qbz" "$P/qbzd"
|
||||||
sudo docker system prune -af || true
|
cp packaging/flatpak/com.blitzfc.qbz.desktop "$P/qbz.desktop"
|
||||||
if sudo fallocate -l 18G /swap_extra; then
|
cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "$P/"
|
||||||
sudo chmod 600 /swap_extra
|
cp LICENSE "$P/LICENSE"
|
||||||
sudo mkswap /swap_extra
|
cp -r licenses "$P/licenses"
|
||||||
sudo swapon /swap_extra
|
mkdir -p "$P/icons"; cp packaging/icons/{32x32,48x48,64x64,128x128,256x256,512x512}.png "$P/icons/"
|
||||||
else
|
ls -la "$P"
|
||||||
echo "::warning::could not allocate extra swap; build may OOM"
|
|
||||||
fi
|
|
||||||
free -h
|
|
||||||
|
|
||||||
- name: Install flatpak tooling
|
- name: Install flatpak tooling
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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
|
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||||
|
|
||||||
- name: Install runtimes
|
- name: Install runtimes
|
||||||
run: |
|
run: |
|
||||||
flatpak install --user -y --noninteractive flathub \
|
flatpak install --user -y --noninteractive flathub \
|
||||||
org.gnome.Platform//49 \
|
org.kde.Platform//6.9 \
|
||||||
org.gnome.Sdk//49
|
org.kde.Sdk//6.9
|
||||||
flatpak install --user -y --noninteractive flathub \
|
|
||||||
org.freedesktop.Sdk.Extension.rust-stable//25.08 \
|
|
||||||
org.freedesktop.Sdk.Extension.llvm20//25.08
|
|
||||||
|
|
||||||
- name: Build Flatpak
|
- name: Build Flatpak
|
||||||
id: build_flatpak
|
id: build_flatpak
|
||||||
@@ -132,11 +175,31 @@ jobs:
|
|||||||
echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT"
|
echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
ls -la dist
|
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
|
- name: Upload Flatpak bundle
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: qbz-flatpak
|
name: qbz-flatpak
|
||||||
path: dist/${{ steps.build_flatpak.outputs.bundle_name }}
|
path: dist/${{ steps.build_flatpak.outputs.bundle_name }}
|
||||||
|
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||||
|
|
||||||
- name: Publish GitHub Release Asset
|
- name: Publish GitHub Release Asset
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
name: release-gentoo
|
name: release-gentoo
|
||||||
|
|
||||||
# The overlay repo (vicrodh/qbz-overlay) must NEVER be edited by hand: any
|
# qbz-overlay is automation-owned because every ebuild or metadata change must
|
||||||
# ebuild change without a Manifest regeneration bricks emerge with a digest
|
# regenerate its Manifest. workflow_dispatch is the supported way to publish
|
||||||
# mismatch (issue #557 — the 2.0.0 RESTRICT edit was pushed manually and
|
# a template-only fix for an already released version.
|
||||||
# skipped the Manifest). Template changes between releases are published by
|
|
||||||
# re-running THIS workflow via workflow_dispatch with the released version.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
@@ -13,9 +11,14 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to publish (e.g. 1.2.0)"
|
description: "Version to prepare (e.g. 2.1.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
publish:
|
||||||
|
description: "Push all four packages and Manifests to qbz-overlay"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: gentoo-overlay
|
group: gentoo-overlay
|
||||||
@@ -26,160 +29,206 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-overlay:
|
update-overlay:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
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')) }}
|
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:
|
steps:
|
||||||
- name: Checkout app repo
|
- name: Checkout app repo
|
||||||
uses: actions/checkout@v7
|
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
|
- name: Resolve and verify release
|
||||||
id: version
|
id: release
|
||||||
env:
|
env:
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
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_VERSION: ${{ inputs.version }}
|
||||||
|
DISPATCH_PUBLISH: ${{ inputs.publish }}
|
||||||
run: |
|
run: |
|
||||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
set -euo pipefail
|
||||||
REF="$WORKFLOW_HEAD_BRANCH"
|
if [[ "$EVENT_NAME" == workflow_run ]]; then
|
||||||
if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
tag="$RUN_HEAD_BRANCH"
|
||||||
echo "Invalid tag: $REF"
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
VERSION="${REF#v}"
|
|
||||||
else
|
|
||||||
VERSION="$DISPATCH_VERSION"
|
|
||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Version: $VERSION"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Clone overlay repo
|
- name: Clone overlay repo
|
||||||
env:
|
env:
|
||||||
OVERLAY_PAT: ${{ secrets.APT_REPO_TOKEN }}
|
OVERLAY_PAT: ${{ secrets.APT_REPO_TOKEN }}
|
||||||
|
SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
|
||||||
run: |
|
run: |
|
||||||
git clone "https://x-access-token:${OVERLAY_PAT}@github.com/vicrodh/qbz-overlay.git" /tmp/qbz-overlay
|
set -euo pipefail
|
||||||
cd /tmp/qbz-overlay
|
if [[ "$SHOULD_PUBLISH" == true ]]; then
|
||||||
git config user.name "GitHub Actions"
|
if [[ -z "$OVERLAY_PAT" ]]; then
|
||||||
git config user.email "actions@github.com"
|
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:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
run: |
|
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)
|
- name: Download distfiles
|
||||||
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
|
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
run: |
|
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)
|
for filename in "${!urls[@]}"; do
|
||||||
rm -f qbz-*.ebuild
|
ok=false
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
# Copy template from app repo and update version
|
if curl -fsSL "${urls[$filename]}" -o "/tmp/qbz-distfiles/${filename}"; then
|
||||||
cp "${{ github.workspace }}/packaging/gentoo/qbz.ebuild.template" "qbz-${VERSION}.ebuild"
|
ok=true
|
||||||
|
|
||||||
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
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Attempt ${attempt} failed for ${dist_name}, retrying in 30s..."
|
echo "Attempt ${attempt}/60 failed for ${filename}"
|
||||||
sleep 30
|
sleep 30
|
||||||
done
|
done
|
||||||
if [[ "$ok" == 1 ]]; then
|
if [[ "$ok" != true ]]; then
|
||||||
MANIFEST+="$(manifest_line DIST "$dist_name" "/tmp/${dist_name}")"$'\n'
|
echo "::error::Required distfile unavailable: ${filename}"
|
||||||
elif [[ "$arch" == "amd64" ]]; then
|
|
||||||
echo "ERROR: required asset ${dist_name} not found at ${url}"
|
|
||||||
exit 1
|
exit 1
|
||||||
else
|
|
||||||
echo "WARNING: ${dist_name} not found at ${url}, skipping"
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Hash the ebuild itself
|
- name: Generate all Manifests
|
||||||
MANIFEST+="$(manifest_line EBUILD "qbz-bin-${VERSION}.ebuild" "qbz-bin-${VERSION}.ebuild")"$'\n'
|
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
|
write_manifest() {
|
||||||
MANIFEST+="$(manifest_line MISC "metadata.xml" "metadata.xml")"$'\n'
|
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
|
write_manifest qbz-bin \
|
||||||
echo "=== qbz-bin Manifest ==="
|
"qbz_${VERSION}_amd64.tar.gz" \
|
||||||
cat Manifest
|
"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 ---
|
- name: Validate generated overlay metadata
|
||||||
cd /tmp/qbz-overlay/media-sound/qbz
|
run: |
|
||||||
MANIFEST=""
|
set -euo pipefail
|
||||||
|
for package in qbz-bin qbz qbzd-bin qbzd; do
|
||||||
# Download source tarball
|
directory="/tmp/qbz-overlay/media-sound/${package}"
|
||||||
dist_name="qbz-${VERSION}.tar.gz"
|
bash -n "${directory}"/*.ebuild
|
||||||
url="https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz"
|
xmllint --noout "${directory}/metadata.xml"
|
||||||
echo "Downloading source tarball..."
|
test "$(wc -l < "${directory}/Manifest")" -ge 4
|
||||||
if curl -sLf "$url" -o "/tmp/${dist_name}"; then
|
done
|
||||||
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: Commit and push
|
- name: Commit and push
|
||||||
|
if: steps.release.outputs.should_publish == 'true'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/qbz-overlay
|
set -euo pipefail
|
||||||
git add -A
|
git -C /tmp/qbz-overlay add -A
|
||||||
if git diff --cached --quiet; then
|
if git -C /tmp/qbz-overlay diff --cached --quiet; then
|
||||||
echo "No changes to commit"
|
echo "No overlay changes to publish"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
git commit -m "Update to v${VERSION}"
|
git -C /tmp/qbz-overlay commit -m "Update to v${VERSION}"
|
||||||
git push
|
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
|
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Pattern is still
|
||||||
# build-once-package-many shape as release-linux.yml (read that file's
|
# build once, package many — but the build is the shared build-qt-linux.yml
|
||||||
# header); differences: native arm64 runner, no x86 target-features, its
|
# (workflow_call) and it yields TWO payloads:
|
||||||
# own rust-cache key (this workflow is the only writer), and it ATTACHES
|
# * the bare binary -> qbz_${VERSION}_arm64.tar.gz (AUR / Gentoo / Flathub
|
||||||
# to the Release that release-linux.yml creates.
|
# 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,
|
# CONTRACT (do not break): the release must keep publishing
|
||||||
# QBZ_${VERSION}_arm64.deb, QBZ-${VERSION}-1.aarch64.rpm,
|
# qbz_${VERSION}_arm64.tar.gz (dir qbz_${VERSION}_arm64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop,
|
||||||
# QBZ_${VERSION}_aarch64.AppImage — AUR/Gentoo/APT/website names.
|
# 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"
|
name: "Linux ARM64 (aarch64) Build"
|
||||||
|
|
||||||
@@ -16,7 +30,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
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
|
required: false
|
||||||
type: string
|
type: string
|
||||||
push:
|
push:
|
||||||
@@ -27,18 +41,23 @@ concurrency:
|
|||||||
group: release-linux-aarch64-${{ github.ref }}
|
group: release-linux-aarch64-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
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:
|
jobs:
|
||||||
build:
|
# Release bookkeeping first so a bad tag fails in seconds, not after a build.
|
||||||
|
version:
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
timeout-minutes: 300
|
|
||||||
continue-on-error: true
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.release.outputs.version }}
|
version: ${{ steps.release.outputs.version }}
|
||||||
prerelease: ${{ steps.release.outputs.prerelease }}
|
prerelease: ${{ steps.release.outputs.prerelease }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v7
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Resolve release version
|
- name: Resolve release version
|
||||||
@@ -51,6 +70,7 @@ jobs:
|
|||||||
VERSION="${{ inputs.tag }}"
|
VERSION="${{ inputs.tag }}"
|
||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
else
|
else
|
||||||
|
# Manual dispatch without a tag: test build at the Cargo.toml version.
|
||||||
VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
|
VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
|
||||||
fi
|
fi
|
||||||
PRERELEASE=false
|
PRERELEASE=false
|
||||||
@@ -59,103 +79,72 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "prerelease=$PRERELEASE" >> "$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
|
- name: Verify tag is on main
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Free disk space and add swap
|
# The binary embeds the workspace version (About, diagnostics); a
|
||||||
run: |
|
# mismatched tag would ship a binary that reports the wrong version.
|
||||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
- name: Assert Cargo.toml version matches tag
|
||||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
|
||||||
/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)
|
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: "0"
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
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"
|
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --manifest-path crates/Cargo.toml -p qbz
|
CARGO_VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
|
||||||
cp crates/target/release/qbz qbz-unstripped
|
if [[ "$CARGO_VERSION" != "$VERSION" ]]; then
|
||||||
strip -o qbz crates/target/release/qbz
|
echo "::error::crates/Cargo.toml workspace version ($CARGO_VERSION) != tag version ($VERSION). Bump it before tagging (see RELEASE_CHECKLIST)."
|
||||||
ls -la qbz qbz-unstripped
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload binary
|
# The media-widget icon invariant (DesktopEntry == installed .desktop
|
||||||
uses: actions/upload-artifact@v7
|
# basename, Icon= installed) — regressed more than once, so it is a
|
||||||
with:
|
# gate at the recipe level here and on the built packages below.
|
||||||
name: qbz-linux-aarch64
|
- name: Gate — desktop entry / icon invariant (recipes)
|
||||||
path: qbz
|
run: scripts/packaging/desktop_entry_check.sh
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Upload unstripped binary (symbolication)
|
# No workflow may build the frozen Slint binary: same bin name, same
|
||||||
uses: actions/upload-artifact@v7
|
# target dir, last build wins the file — a smoke against "the wrong
|
||||||
with:
|
# qbz" is silent.
|
||||||
name: qbz-linux-aarch64-unstripped
|
- name: Gate — no workflow builds `-p qbz`
|
||||||
path: qbz-unstripped
|
run: |
|
||||||
retention-days: 90
|
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; }
|
||||||
|
|
||||||
# T15: aarch64 mirror of release-linux.yml's build-qbzd job — see that
|
build:
|
||||||
# file's header comment for the glibc-floor rationale. Uses the native
|
needs: version
|
||||||
# ubuntu-22.04-arm runner label rather than cross-compiling.
|
uses: ./.github/workflows/build-qt-linux.yml
|
||||||
#
|
with:
|
||||||
# Fallback note (NOT implemented — flagged for pre-flight per the T15
|
arch: aarch64
|
||||||
# brief): if ubuntu-22.04-arm is ever unavailable to this repo, cross-build
|
source_ref: ${{ inputs.tag || github.ref }}
|
||||||
# instead via the existing Cross.toml pattern (crates/Cross.toml,
|
|
||||||
# scripts/build-aarch64-linux.sh's CROSS mode) targeting a 2.35-floor
|
# qbzd is minutes-class and Slint-free. Runs on ubuntu-22.04 so the shipped
|
||||||
# image; the objdump gate below stays the acceptance test either way.
|
# binary's glibc floor stays <= Pi OS bookworm's 2.36 (the honest-Pi-story
|
||||||
|
# gate); the objdump step verifies that empirically.
|
||||||
build-qbzd:
|
build-qbzd:
|
||||||
runs-on: ubuntu-22.04-arm
|
runs-on: ubuntu-22.04-arm
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- 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
|
- name: system deps
|
||||||
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev
|
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
|
- name: slint-free dep-graph gate
|
||||||
@@ -174,37 +163,45 @@ jobs:
|
|||||||
echo "glibc floor: $floor"
|
echo "glibc floor: $floor"
|
||||||
highest=$(printf '%s\n' "$floor" "GLIBC_2.35" | sort -V | tail -1)
|
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; }
|
[ "$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
|
- 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:
|
package:
|
||||||
needs: [build, build-qbzd]
|
needs: [version, build, build-qbzd]
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.build.outputs.version }}
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Download binary
|
- name: Download bare binary
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
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
|
- name: Download qbzd binary
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
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
|
- name: Install nfpm
|
||||||
run: |
|
run: |
|
||||||
@@ -214,37 +211,55 @@ jobs:
|
|||||||
sudo apt-get install -y nfpm
|
sudo apt-get install -y nfpm
|
||||||
nfpm --version
|
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)
|
- name: Build deb + rpm (nfpm)
|
||||||
env:
|
env:
|
||||||
QBZ_VERSION: ${{ env.VERSION }}
|
QBZ_VERSION: ${{ env.VERSION }}
|
||||||
QBZ_ARCH: arm64
|
QBZ_ARCH: ${{ env.DEB_ARCH }}
|
||||||
QBZ_BINARY: ./qbz
|
|
||||||
run: |
|
run: |
|
||||||
chmod +x qbz
|
|
||||||
chmod +x qbzd
|
|
||||||
mkdir -p dist
|
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 \
|
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 \
|
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
|
- name: Build AppImage
|
||||||
run: |
|
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 \
|
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
|
chmod +x appimagetool
|
||||||
ARCH=aarch64 ./appimagetool --appimage-extract-and-run "$APPDIR" \
|
ARCH="${ARCH}" ./appimagetool --appimage-extract-and-run AppDir \
|
||||||
"dist/QBZ_${VERSION}_aarch64.AppImage"
|
"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)
|
- name: Sign AppImage (Tauri-updater bridge)
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
@@ -254,14 +269,16 @@ jobs:
|
|||||||
npx --yes @tauri-apps/cli@^2 signer sign \
|
npx --yes @tauri-apps/cli@^2 signer sign \
|
||||||
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
|
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
|
||||||
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
|
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
|
||||||
"dist/QBZ_${VERSION}_aarch64.AppImage"
|
"dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
|
||||||
ls -la dist
|
ls -la dist
|
||||||
|
|
||||||
|
# The BARE binary. Its consumers (AUR, Gentoo, Flathub) provide Qt.
|
||||||
- name: Build AUR/Gentoo tarball
|
- name: Build AUR/Gentoo tarball
|
||||||
run: |
|
run: |
|
||||||
# CONTRACT: same name + internal layout as the Tauri-era tarball.
|
# CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and
|
||||||
TARBALL_DIR="qbz_${VERSION}_aarch64"
|
# the website hard-code this name and internal layout.
|
||||||
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"
|
cp qbz "tarball-staging/${TARBALL_DIR}/qbz"
|
||||||
chmod +x "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"
|
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/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/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/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"
|
cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop"
|
||||||
# Additive (T14) — see release-linux.yml's matching block for the
|
cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml"
|
||||||
# layout-contract rationale.
|
cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE"
|
||||||
|
cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses"
|
||||||
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
|
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
|
||||||
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
|
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
|
||||||
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service"
|
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service"
|
||||||
mkdir -p "tarball-staging/${TARBALL_DIR}/completions"
|
mkdir -p "tarball-staging/${TARBALL_DIR}/completions"
|
||||||
./qbzd completions bash > "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
|
cp qbzd.bash "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
|
||||||
./qbzd completions zsh > "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
|
cp qbzd.zsh "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
|
||||||
./qbzd completions fish > "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
|
cp qbzd.fish "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
|
||||||
tar -czvf "dist/qbz_${VERSION}_aarch64.tar.gz" -C tarball-staging "${TARBALL_DIR}"
|
tar -czvf "dist/qbz_${VERSION}_${DEB_ARCH}.tar.gz" -C tarball-staging "${TARBALL_DIR}"
|
||||||
ls -la dist
|
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
|
- name: qbzd-tarball
|
||||||
run: |
|
run: |
|
||||||
QBZD_DIR="qbzd-${VERSION}-linux-aarch64"
|
QBZD_DIR="qbzd-${VERSION}-linux-${DEB_ARCH}"
|
||||||
mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions"
|
mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions"
|
||||||
cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
|
cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
|
||||||
chmod +x "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"
|
cp crates/qbzd/service/qbzd.service "qbzd-tarball-staging/${QBZD_DIR}/qbzd.service"
|
||||||
./qbzd completions bash > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
|
cp qbzd.bash "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
|
||||||
./qbzd completions zsh > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
|
cp qbzd.zsh "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
|
||||||
./qbzd completions fish > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
|
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"
|
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
|
ls -la dist
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: qbz-linux-aarch64-packages
|
name: qbz-linux-packages-${{ env.ARCH }}
|
||||||
path: dist/*
|
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
|
- name: Publish GitHub Release Assets
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: dist/*
|
files: dist/*
|
||||||
prerelease: ${{ needs.build.outputs.prerelease }}
|
prerelease: ${{ needs.version.outputs.prerelease }}
|
||||||
make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }}
|
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
|
- name: Trigger APT repo update
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: peter-evans/repository-dispatch@v4
|
uses: peter-evans/repository-dispatch@v4
|
||||||
@@ -328,3 +340,14 @@ jobs:
|
|||||||
repository: vicrodh/qbz-apt
|
repository: vicrodh/qbz-apt
|
||||||
event-type: new-release
|
event-type: new-release
|
||||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
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 }}"}'
|
||||||
+163
-174
@@ -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:
|
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Pattern is still
|
||||||
# build once, package many. The build job produces the binary with the
|
# build once, package many — but the build is the shared build-qt-linux.yml
|
||||||
# proven public-runner recipe from build-slint.yml (16 GB RAM + 18 GB extra
|
# (workflow_call) and it yields TWO payloads:
|
||||||
# swap covers the ~30 GB qbz_ui rustc peak, MIN memory tier, mold, pinned
|
# * the bare binary -> qbz_${VERSION}_amd64.tar.gz (AUR / Gentoo / Flathub
|
||||||
# nightly); the package job turns it into deb/rpm (nfpm), AppImage, and the
|
# repack: ecosystems that provide Qt >= 6.8 themselves)
|
||||||
# AUR/Gentoo/APT tarball, then creates the GitHub Release.
|
# * 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
|
# 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
|
# 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
|
# qbzd_${VERSION}_amd64.deb / qbzd-${VERSION}-1.x86_64.rpm / qbzd-${VERSION}-linux-amd64.tar.gz
|
||||||
# these names.
|
# 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)
|
# Triggers: version tags (cut from main — enforced by the ancestry guard)
|
||||||
# + manual dispatch (guard skipped, for testing on any branch).
|
# + manual dispatch (guard skipped, for testing on any branch).
|
||||||
@@ -22,7 +31,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
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
|
required: false
|
||||||
type: string
|
type: string
|
||||||
push:
|
push:
|
||||||
@@ -33,17 +42,23 @@ concurrency:
|
|||||||
group: release-linux-${{ github.ref }}
|
group: release-linux-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
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:
|
jobs:
|
||||||
build:
|
# Release bookkeeping first so a bad tag fails in seconds, not after a build.
|
||||||
|
version:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 300
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.release.outputs.version }}
|
version: ${{ steps.release.outputs.version }}
|
||||||
prerelease: ${{ steps.release.outputs.prerelease }}
|
prerelease: ${{ steps.release.outputs.prerelease }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v7
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Resolve release version
|
- name: Resolve release version
|
||||||
@@ -69,8 +84,7 @@ jobs:
|
|||||||
|
|
||||||
# Release tags must be cut from main. A tag on any other commit fires
|
# 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
|
# this workflow too (tags are not branch-scoped in Actions), so verify
|
||||||
# ancestry instead of trusting convention. Manual dispatch skips this
|
# ancestry instead of trusting convention. Manual dispatch skips this.
|
||||||
# (that is the testing path).
|
|
||||||
- name: Verify tag is on main
|
- name: Verify tag is on main
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
@@ -80,11 +94,10 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fail fast if the version bump was forgotten: the binary embeds the
|
# The binary embeds the workspace version (About, diagnostics); a
|
||||||
# workspace version (About dialog, diagnostics), so a mismatched tag
|
# mismatched tag would ship a binary that reports the wrong version.
|
||||||
# would ship a binary that reports the wrong version.
|
|
||||||
- name: Assert Cargo.toml version matches tag
|
- name: Assert Cargo.toml version matches tag
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.release.outputs.version }}
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
@@ -94,100 +107,45 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The qbz_ui rustc needs ~30 GB (RSS+swap). Public runner: 16 GB RAM
|
# The media-widget icon invariant (DesktopEntry == installed .desktop
|
||||||
# + ~4 GB stock swap; free disk by dropping preinstalled toolchains,
|
# basename, Icon= installed) — regressed more than once, so it is a
|
||||||
# then add 18 GB swap -> ~38 GB total. Same recipe as build-slint.yml.
|
# gate at the recipe level here and on the built packages below.
|
||||||
- name: Free disk space and add swap
|
- 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: |
|
run: |
|
||||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
|
set -euo pipefail
|
||||||
/opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
|
hits=$(grep -rnE 'cargo (build|check|test|run|install).* -p qbz([^-a-z]|$)' .github/workflows/ || true)
|
||||||
/usr/local/lib/node_modules || true
|
[ -z "$hits" ] || { echo "::error::a workflow still builds the Slint crate:"; echo "$hits"; exit 1; }
|
||||||
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
|
build:
|
||||||
run: |
|
needs: version
|
||||||
sudo apt-get update
|
uses: ./.github/workflows/build-qt-linux.yml
|
||||||
sudo apt-get install -y \
|
with:
|
||||||
mold nasm \
|
arch: x86_64
|
||||||
libasound2-dev libjack-jackd2-dev \
|
source_ref: ${{ inputs.tag || github.ref }}
|
||||||
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
|
# qbzd is minutes-class and Slint-free. Runs on ubuntu-22.04 so the shipped
|
||||||
# behavior, -Z threads support). Bump both files together.
|
# binary's glibc floor stays <= Pi OS bookworm's 2.36 (the honest-Pi-story
|
||||||
- name: Setup Rust
|
# gate); the objdump step verifies that empirically.
|
||||||
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
|
|
||||||
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.
|
|
||||||
build-qbzd:
|
build-qbzd:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- 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
|
- name: system deps
|
||||||
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev
|
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
|
- name: slint-free dep-graph gate
|
||||||
@@ -206,40 +164,48 @@ jobs:
|
|||||||
echo "glibc floor: $floor"
|
echo "glibc floor: $floor"
|
||||||
highest=$(printf '%s\n' "$floor" "GLIBC_2.35" | sort -V | tail -1)
|
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; }
|
[ "$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
|
- 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:
|
package:
|
||||||
needs: [build, build-qbzd]
|
needs: [version, build, build-qbzd]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.build.outputs.version }}
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
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
|
uses: actions/download-artifact@v8
|
||||||
with:
|
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
|
- name: Download qbzd binary
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
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
|
- name: Install nfpm
|
||||||
run: |
|
run: |
|
||||||
@@ -249,46 +215,55 @@ jobs:
|
|||||||
sudo apt-get install -y nfpm
|
sudo apt-get install -y nfpm
|
||||||
nfpm --version
|
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)
|
- name: Build deb + rpm (nfpm)
|
||||||
env:
|
env:
|
||||||
QBZ_VERSION: ${{ env.VERSION }}
|
QBZ_VERSION: ${{ env.VERSION }}
|
||||||
QBZ_ARCH: amd64
|
QBZ_ARCH: ${{ env.DEB_ARCH }}
|
||||||
QBZ_BINARY: ./qbz
|
|
||||||
run: |
|
run: |
|
||||||
chmod +x qbz
|
|
||||||
chmod +x qbzd
|
|
||||||
mkdir -p dist
|
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 \
|
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 \
|
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
|
- name: Build AppImage
|
||||||
run: |
|
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 \
|
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
|
chmod +x appimagetool
|
||||||
ARCH=x86_64 ./appimagetool --appimage-extract-and-run "$APPDIR" \
|
ARCH="${ARCH}" ./appimagetool --appimage-extract-and-run AppDir \
|
||||||
"dist/QBZ_${VERSION}_amd64.AppImage"
|
"dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
|
||||||
|
ls -la dist
|
||||||
|
|
||||||
# Tauri-updater bridge: v1.2.x AppImage installs poll latest.json and
|
# Tauri-updater bridge: v1.2.x AppImage installs poll latest.json and
|
||||||
# verify artifacts with the Tauri minisign pubkey baked into the old
|
# verify artifacts with the Tauri minisign pubkey baked into the old
|
||||||
# binaries. Signing the Slint AppImage with the SAME private key lets
|
# binaries. Signing with the SAME private key lets them auto-update
|
||||||
# them auto-update straight into v2 (the updater just swaps the
|
# straight into this AppImage. release-updater-manifest.yml collects
|
||||||
# AppImage file). release-updater-manifest.yml collects the .sig into
|
# the .sig. The app itself has no auto-updater.
|
||||||
# latest.json. The Slint app itself has no auto-updater — this exists
|
|
||||||
# only to migrate old installs.
|
|
||||||
- name: Sign AppImage (Tauri-updater bridge)
|
- name: Sign AppImage (Tauri-updater bridge)
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
@@ -298,15 +273,16 @@ jobs:
|
|||||||
npx --yes @tauri-apps/cli@^2 signer sign \
|
npx --yes @tauri-apps/cli@^2 signer sign \
|
||||||
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
|
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
|
||||||
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
|
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
|
||||||
"dist/QBZ_${VERSION}_amd64.AppImage"
|
"dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
|
||||||
ls -la dist
|
ls -la dist
|
||||||
|
|
||||||
|
# The BARE binary. Its consumers (AUR, Gentoo, Flathub) provide Qt.
|
||||||
- name: Build AUR/Gentoo tarball
|
- name: Build AUR/Gentoo tarball
|
||||||
run: |
|
run: |
|
||||||
# CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and
|
# CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and
|
||||||
# the website hard-code this name and internal layout.
|
# the website hard-code this name and internal layout.
|
||||||
TARBALL_DIR="qbz_${VERSION}_amd64"
|
TARBALL_DIR="qbz_${VERSION}_${DEB_ARCH}"
|
||||||
mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256}/apps
|
mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256,512x512}/apps
|
||||||
cp qbz "tarball-staging/${TARBALL_DIR}/qbz"
|
cp qbz "tarball-staging/${TARBALL_DIR}/qbz"
|
||||||
chmod +x "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"
|
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/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/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/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"
|
cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop"
|
||||||
# Additive (T14): qbzd + its user unit + shell completions. Every
|
cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml"
|
||||||
# entry above is untouched — the layout contract (header lines
|
cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE"
|
||||||
# 10-14) forbids renaming/moving existing entries. AUR/Gentoo/flake
|
cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses"
|
||||||
# install-line updates to pick these up are P1 (T22, per D13); the
|
|
||||||
# files are inert there until then.
|
|
||||||
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
|
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
|
||||||
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
|
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
|
||||||
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service"
|
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service"
|
||||||
mkdir -p "tarball-staging/${TARBALL_DIR}/completions"
|
mkdir -p "tarball-staging/${TARBALL_DIR}/completions"
|
||||||
./qbzd completions bash > "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
|
cp qbzd.bash "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
|
||||||
./qbzd completions zsh > "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
|
cp qbzd.zsh "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
|
||||||
./qbzd completions fish > "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
|
cp qbzd.fish "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
|
||||||
tar -czvf "dist/qbz_${VERSION}_amd64.tar.gz" -C tarball-staging "${TARBALL_DIR}"
|
tar -czvf "dist/qbz_${VERSION}_${DEB_ARCH}.tar.gz" -C tarball-staging "${TARBALL_DIR}"
|
||||||
ls -la dist
|
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
|
- name: qbzd-tarball
|
||||||
run: |
|
run: |
|
||||||
QBZD_DIR="qbzd-${VERSION}-linux-amd64"
|
QBZD_DIR="qbzd-${VERSION}-linux-${DEB_ARCH}"
|
||||||
mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions"
|
mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions"
|
||||||
cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
|
cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
|
||||||
chmod +x "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"
|
cp crates/qbzd/service/qbzd.service "qbzd-tarball-staging/${QBZD_DIR}/qbzd.service"
|
||||||
./qbzd completions bash > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
|
cp qbzd.bash "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
|
||||||
./qbzd completions zsh > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
|
cp qbzd.zsh "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
|
||||||
./qbzd completions fish > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
|
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"
|
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
|
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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: qbz-linux-packages
|
name: qbz-linux-packages-${{ env.ARCH }}
|
||||||
path: dist/*
|
path: dist/*
|
||||||
|
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||||
|
|
||||||
- name: Publish GitHub Release Assets
|
- name: Publish GitHub Release Assets
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: dist/*
|
files: dist/*
|
||||||
prerelease: ${{ needs.build.outputs.prerelease }}
|
prerelease: ${{ needs.version.outputs.prerelease }}
|
||||||
make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }}
|
make_latest: ${{ needs.version.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||||
|
|
||||||
- name: Trigger APT repo update
|
- name: Trigger APT repo update
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -372,3 +350,14 @@ jobs:
|
|||||||
repository: vicrodh/qbz-apt
|
repository: vicrodh/qbz-apt
|
||||||
event-type: new-release
|
event-type: new-release
|
||||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
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
|
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Recipe measured on
|
||||||
# qbz binary per target, assembles a minimal QBZ.app bundle (Info.plist +
|
# the Mac mini M2 (qbz-nix-docs/cicd/2026-08-25-qt-workflows-contracts/ V5b):
|
||||||
# icns + binary), ad-hoc signs it (same trust level as the unsigned Tauri
|
# * Qt from aqt (official, UNIVERSAL frameworks, min macOS 12) — NOT Homebrew:
|
||||||
# builds), and packs a dmg with hdiutil. No Tauri updater: the .app.tar.gz
|
# Homebrew splits Qt across formulae and macdeployqt cannot resolve
|
||||||
# + .sig updater assets are gone, dmg is the distribution.
|
# @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
|
# macOS pins 6.9 (not the Linux 6.8 LTS): the traffic-lights fix uses
|
||||||
# ~30 GB on Linux. macOS swaps dynamically to SSD so the build may survive
|
# Window paddings + SafeArea (Qt 6.9), and Qt is always bundled here so a
|
||||||
# (slowly) — if it proves unreliable, point runs-on at a self-hosted runner
|
# higher floor costs no reach. `vars.QT_VERSION_MACOS` overrides.
|
||||||
# on the M2 Mini instead. continue-on-error keeps a mac failure from
|
|
||||||
# blocking the Linux release.
|
|
||||||
|
|
||||||
name: release-macos
|
name: release-macos
|
||||||
|
|
||||||
@@ -18,7 +28,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
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
|
required: false
|
||||||
type: string
|
type: string
|
||||||
push:
|
push:
|
||||||
@@ -29,27 +39,39 @@ concurrency:
|
|||||||
group: release-macos-${{ github.ref }}
|
group: release-macos-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
QT_VERSION: ${{ vars.QT_VERSION_MACOS || '6.9.3' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-macos:
|
build-macos:
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- target: aarch64-apple-darwin
|
- target: aarch64-apple-darwin
|
||||||
|
runner: macos-latest
|
||||||
artifact: qbz-macos-aarch64
|
artifact: qbz-macos-aarch64
|
||||||
arch: 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
|
- 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
|
artifact: qbz-macos-x86_64
|
||||||
# Historical dmg naming: intel assets ship as "x64".
|
# Historical dmg naming: intel assets ship as "x64".
|
||||||
arch: x64
|
arch: x64
|
||||||
runs-on: macos-latest
|
cxxflags: ""
|
||||||
timeout-minutes: 360
|
runs-on: ${{ matrix.runner }}
|
||||||
continue-on-error: true
|
timeout-minutes: 120
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Resolve release version
|
- name: Resolve release version
|
||||||
@@ -81,7 +103,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Assert Cargo.toml version matches tag
|
- name: Assert Cargo.toml version matches tag
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.release.outputs.version }}
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
@@ -91,35 +113,48 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Same pinned nightly as the Linux builders (-Z threads support,
|
# Without qtshadertools: the committed .qsb ship (see build-qt-linux.yml).
|
||||||
# consistent measured behavior). Bump all release workflows together.
|
- name: Install Qt ${{ env.QT_VERSION }} (universal)
|
||||||
- name: Setup Rust
|
uses: jurplel/install-qt-action@v4
|
||||||
uses: dtolnay/rust-toolchain@master
|
with:
|
||||||
|
version: ${{ env.QT_VERSION }}
|
||||||
|
host: mac
|
||||||
|
target: desktop
|
||||||
|
arch: clang_64
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust (stable)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: nightly-2026-06-23
|
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: crates -> target
|
workspaces: crates -> target
|
||||||
shared-key: slint-macos-${{ matrix.target }}
|
shared-key: qt-macos-${{ matrix.target }}-${{ env.QT_VERSION }}
|
||||||
cache-on-failure: true
|
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
|
# NEVER `-p qbz` (the frozen Slint binary shares the bin name).
|
||||||
# swap for the qbz_ui rustc peak. Binary is stripped by the workspace
|
- name: Build (release)
|
||||||
# profile (strip = "symbols"), which rustc applies correctly to Mach-O.
|
|
||||||
- name: Build (release, MIN memory tier)
|
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: "0"
|
CARGO_INCREMENTAL: "0"
|
||||||
CARGO_BUILD_JOBS: "1"
|
CMAKE_PREFIX_PATH: ${{ env.QT_ROOT_DIR }}
|
||||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256"
|
QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake
|
||||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: "2"
|
CXXFLAGS: ${{ matrix.cxxflags }}
|
||||||
RUSTFLAGS: "-Z threads=1"
|
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --manifest-path crates/Cargo.toml \
|
python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml \
|
||||||
-p qbz --target ${{ matrix.target }}
|
-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
|
- name: Assemble QBZ.app
|
||||||
env:
|
env:
|
||||||
@@ -131,6 +166,32 @@ jobs:
|
|||||||
cp "crates/target/${{ matrix.target }}/release/qbz" "$APP/Contents/MacOS/qbz"
|
cp "crates/target/${{ matrix.target }}/release/qbz" "$APP/Contents/MacOS/qbz"
|
||||||
chmod +x "$APP/Contents/MacOS/qbz"
|
chmod +x "$APP/Contents/MacOS/qbz"
|
||||||
cp packaging/icons/icon.icns "$APP/Contents/Resources/icon.icns"
|
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
|
cat > "$APP/Contents/Info.plist" <<PLIST
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!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>CFBundleDisplayName</key><string>QBZ</string>
|
||||||
<key>CFBundleExecutable</key><string>qbz</string>
|
<key>CFBundleExecutable</key><string>qbz</string>
|
||||||
<key>CFBundleIconFile</key><string>icon</string>
|
<key>CFBundleIconFile</key><string>icon</string>
|
||||||
|
${ICON_NAME_KEY}
|
||||||
<key>CFBundleShortVersionString</key><string>${VERSION}</string>
|
<key>CFBundleShortVersionString</key><string>${VERSION}</string>
|
||||||
<key>CFBundleVersion</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>NSHighResolutionCapable</key><true/>
|
||||||
<key>LSApplicationCategoryType</key><string>public.app-category.music</string>
|
<key>LSApplicationCategoryType</key><string>public.app-category.music</string>
|
||||||
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
|
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
|
||||||
@@ -159,10 +221,75 @@ jobs:
|
|||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
# Ad-hoc signature: required for arm64 Macs to launch at all;
|
|
||||||
# matches the trust level of the previous unsigned Tauri builds.
|
# Bundle the Qt frameworks, the cocoa platform plugin and the QML
|
||||||
codesign --force --deep -s - "$APP"
|
# modules the app imports (Effects, labs.qmlmodels, Controls, ...).
|
||||||
codesign --verify --verbose "$APP"
|
# 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
|
- name: Build dmg
|
||||||
env:
|
env:
|
||||||
@@ -174,10 +301,9 @@ jobs:
|
|||||||
ls -la dist
|
ls -la dist
|
||||||
|
|
||||||
# Tauri-updater bridge: v1.2.x installs poll latest.json and verify
|
# Tauri-updater bridge: v1.2.x installs poll latest.json and verify
|
||||||
# with the Tauri minisign pubkey baked into the old binaries. The
|
# with the Tauri minisign pubkey baked into the old binaries. Keep
|
||||||
# updater expects QBZ_${ARCH}.app.tar.gz — keep producing it, signed
|
# producing QBZ_${ARCH}.app.tar.gz signed with the SAME key so old
|
||||||
# with the SAME key, so old installs update straight into the Slint
|
# installs update straight into this .app.
|
||||||
# .app. release-updater-manifest.yml collects the .sig files.
|
|
||||||
- name: Package .app.tar.gz + sign (Tauri-updater bridge)
|
- name: Package .app.tar.gz + sign (Tauri-updater bridge)
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
@@ -196,13 +322,13 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.artifact }}
|
name: ${{ matrix.artifact }}
|
||||||
|
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
|
||||||
path: |
|
path: |
|
||||||
dist/*.dmg
|
dist/*.dmg
|
||||||
dist/*.app.tar.gz
|
dist/*.app.tar.gz
|
||||||
dist/*.app.tar.gz.sig
|
dist/*.app.tar.gz.sig
|
||||||
|
|
||||||
# Attaches to the Release created by release-linux.yml; same
|
# Attaches to the Release created by release-linux.yml.
|
||||||
# prerelease logic (the old workflow hardcoded prerelease: true).
|
|
||||||
- name: Publish GitHub Release Assets
|
- name: Publish GitHub Release Assets
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
|
|||||||
+128
-180
@@ -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
|
# All-hosted Snap pipeline — BOTH arches. Each track = build (the shared
|
||||||
# runs on the dev box, so the lack of systemd/snapd there does not matter. Two
|
# build-qt-linux.yml, which yields the self-contained AppDir) -> dump-pack ->
|
||||||
# independent tracks, each = build-binary -> dump-pack -> publish:
|
# publish. No compilation in snapcraft; the pack runs in seconds on a runner
|
||||||
# amd64: build-binary (ubuntu-24.04, MIN-tier + 18 GB swap) -> snap
|
# with snapd. This CI upload is the SOLE publish path: the Launchpad /
|
||||||
# (packaging/snap-slint/snapcraft.yaml, plugin: dump).
|
# "Build from GitHub" source recipe (root snapcraft.yaml) is DELETED — core24
|
||||||
# arm64: build-binary-arm64 (native ubuntu-24.04-arm, free for public repos,
|
# has Qt 6.4.2 and the tree needs 6.8, so a source build there cannot even
|
||||||
# reuses release-linux-aarch64.yml's cargo cache) -> snap-arm64
|
# resolve its dependencies (it OOM'd on their amd64 fleet in the Slint era
|
||||||
# (packaging/snap-slint/snapcraft-arm64.yaml, plugin: dump).
|
# anyway).
|
||||||
# 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).
|
|
||||||
#
|
#
|
||||||
# NOTE: build-binary* duplicates the deb/rpm builders' compile. When the release
|
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch.
|
||||||
# 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.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
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
|
required: false
|
||||||
type: string
|
type: string
|
||||||
channel:
|
channel:
|
||||||
@@ -30,6 +24,11 @@ on:
|
|||||||
default: edge
|
default: edge
|
||||||
type: choice
|
type: choice
|
||||||
options: [edge, beta, candidate, stable]
|
options: [edge, beta, candidate, stable]
|
||||||
|
publish:
|
||||||
|
description: "Publish to the selected Snap Store channel"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
@@ -40,186 +39,133 @@ concurrency:
|
|||||||
group: release-snap-${{ github.ref }}
|
group: release-snap-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
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:
|
jobs:
|
||||||
build-binary:
|
version:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 180
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- 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:
|
with:
|
||||||
toolchain: nightly-2026-06-23
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Cargo cache (Swatinem/rust-cache)
|
- name: Resolve and validate package version
|
||||||
uses: Swatinem/rust-cache@v2
|
id: release
|
||||||
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)
|
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: '0'
|
DISPATCH_TAG: ${{ inputs.tag }}
|
||||||
CARGO_BUILD_JOBS: '1'
|
DISPATCH_PUBLISH: ${{ inputs.publish }}
|
||||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256'
|
|
||||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: '2'
|
|
||||||
RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1'
|
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --manifest-path crates/Cargo.toml -p qbz
|
set -euo pipefail
|
||||||
strip crates/target/release/qbz || true
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
ls -lh crates/target/release/qbz
|
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
|
- name: Verify tag is on main
|
||||||
uses: actions/upload-artifact@v7
|
if: startsWith(github.ref, 'refs/tags/') || inputs.publish
|
||||||
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)
|
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: '0'
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
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'
|
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --manifest-path crates/Cargo.toml -p qbz
|
set -euo pipefail
|
||||||
strip crates/target/release/qbz || true
|
tag="v${VERSION}"
|
||||||
ls -lh crates/target/release/qbz
|
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
|
build-amd64:
|
||||||
uses: actions/upload-artifact@v7
|
needs: version
|
||||||
with:
|
uses: ./.github/workflows/build-qt-linux.yml
|
||||||
name: qbz-bin-arm64
|
with:
|
||||||
path: crates/target/release/qbz
|
arch: x86_64
|
||||||
if-no-files-found: error
|
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:
|
snap:
|
||||||
needs: build-binary
|
needs: [version, build-amd64]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
# 30 min was too tight: the v2.0.0 tag run got axed mid-snapcraft (LXD
|
# No compilation here; the headroom is for snapcraft's LXD bootstrap,
|
||||||
# setup + platform snaps + pack ran past it). The job does no compilation,
|
# which is highly variable on hosted runners (a 30 min cap got the
|
||||||
# but snapcraft's container bootstrap alone is highly variable on public
|
# v2.0.0 run axed mid-pack).
|
||||||
# runners — give it real headroom.
|
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
# Credentials at job level so the publish step can gate on them: an empty /
|
# 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
|
# unset secret SKIPS publish instead of failing the whole run.
|
||||||
# still built and uploaded as an artifact).
|
|
||||||
env:
|
env:
|
||||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Download prebuilt binary
|
- name: Download AppDir
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: qbz-bin-amd64
|
name: qbz-appdir-x86_64
|
||||||
path: dl
|
|
||||||
|
|
||||||
- name: Stage snap payload (binary + desktop + icon) and recipe
|
- name: Stage snap payload (AppDir tree + desktop + icon) and recipe
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
VER="$VERSION"
|
||||||
VER="${TAG#v}"
|
tar -xf appdir.tar
|
||||||
mkdir -p snap-payload
|
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.desktop snap-payload/qbz.desktop
|
||||||
cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png
|
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
|
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
|
sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml
|
||||||
# The repo ROOT also carries snapcraft.yaml (the SOURCE-build recipe
|
# Belt and braces: no root recipe may shadow snap/snapcraft.yaml.
|
||||||
# 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.
|
|
||||||
rm -f snapcraft.yaml .snapcraft.yaml
|
rm -f snapcraft.yaml .snapcraft.yaml
|
||||||
echo "Built snap version: ${VER}"
|
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
|
id: build
|
||||||
uses: snapcore/action-build@v1
|
uses: snapcore/action-build@v1
|
||||||
|
|
||||||
@@ -229,51 +175,52 @@ jobs:
|
|||||||
name: qbz-snap-amd64
|
name: qbz-snap-amd64
|
||||||
path: ${{ steps.build.outputs.snap }}
|
path: ${{ steps.build.outputs.snap }}
|
||||||
if-no-files-found: error
|
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)
|
- 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
|
uses: snapcore/action-publish@v1
|
||||||
with:
|
with:
|
||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
release: ${{ github.event.inputs.channel || 'edge' }}
|
release: ${{ github.event.inputs.channel || 'edge' }}
|
||||||
|
|
||||||
snap-arm64:
|
snap-arm64:
|
||||||
needs: build-binary-arm64
|
needs: [version, build-arm64]
|
||||||
runs-on: ubuntu-24.04-arm
|
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
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Download prebuilt binary
|
- name: Download AppDir
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: qbz-bin-arm64
|
name: qbz-appdir-aarch64
|
||||||
path: dl
|
|
||||||
|
|
||||||
- name: Stage snap payload (binary + desktop + icon) and recipe
|
- name: Stage snap payload (AppDir tree + desktop + icon) and recipe
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
VER="$VERSION"
|
||||||
VER="${TAG#v}"
|
tar -xf appdir.tar
|
||||||
mkdir -p snap-payload
|
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.desktop snap-payload/qbz.desktop
|
||||||
cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png
|
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
|
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
|
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
|
rm -f snapcraft.yaml .snapcraft.yaml
|
||||||
echo "Built snap version: ${VER} (arm64)"
|
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
|
id: build
|
||||||
uses: snapcore/action-build@v1
|
uses: snapcore/action-build@v1
|
||||||
|
|
||||||
@@ -283,9 +230,10 @@ jobs:
|
|||||||
name: qbz-snap-arm64
|
name: qbz-snap-arm64
|
||||||
path: ${{ steps.build.outputs.snap }}
|
path: ${{ steps.build.outputs.snap }}
|
||||||
if-no-files-found: error
|
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)
|
- 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
|
uses: snapcore/action-publish@v1
|
||||||
with:
|
with:
|
||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
|
|||||||
@@ -18,11 +18,19 @@ jobs:
|
|||||||
# Only run on successful tag-triggered builds (not manual dispatches)
|
# Only run on successful tag-triggered builds (not manual dispatches)
|
||||||
if: >
|
if: >
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
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')
|
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
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
|
- name: Resolve version from tag
|
||||||
id: version
|
id: version
|
||||||
# `head_branch` on a `workflow_run` event is attacker-controllable
|
# `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.
|
# the shape before letting it flow into shell or $GITHUB_OUTPUT.
|
||||||
env:
|
env:
|
||||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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"
|
echo "::error::Rejected head_branch for manifest job: $HEAD_BRANCH"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
TAG="$HEAD_BRANCH"
|
TAG="$HEAD_BRANCH"
|
||||||
VERSION="${TAG#v}"
|
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 "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -48,7 +71,12 @@ jobs:
|
|||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
# Wait up to 20 minutes for .sig files from all platforms
|
# 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
|
MAX_ATTEMPTS=40
|
||||||
SLEEP_SECONDS=30
|
SLEEP_SECONDS=30
|
||||||
|
|
||||||
@@ -69,8 +97,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then
|
if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then
|
||||||
echo "Warning: Not all .sig files found after $MAX_ATTEMPTS attempts. Generating manifest with available assets."
|
echo "::error::Not all updater signatures appeared after $MAX_ATTEMPTS attempts; refusing to replace latest.json with a partial manifest."
|
||||||
break
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Attempt $attempt/$MAX_ATTEMPTS: waiting for all .sig files..."
|
echo "Attempt $attempt/$MAX_ATTEMPTS: waiting for all .sig files..."
|
||||||
@@ -158,6 +186,13 @@ jobs:
|
|||||||
'. + {"linux-aarch64": {"signature": $sig, "url": $url}}')
|
'. + {"linux-aarch64": {"signature": $sig, "url": $url}}')
|
||||||
fi
|
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
|
# Assemble final latest.json
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg version "$VERSION" \
|
--arg version "$VERSION" \
|
||||||
@@ -175,7 +210,4 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
# Delete existing latest.json if present (from a previous run)
|
gh release upload "$TAG" latest.json --repo "${{ github.repository }}" --clobber
|
||||||
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 }}"
|
|
||||||
@@ -106,13 +106,13 @@ jobs:
|
|||||||
foreach ($f in "qbz.exe", "platforms\qwindows.dll", "qml\QtQuick\Controls\Basic\qmldir", "imageformats\qsvg.dll", "LICENSE.txt") {
|
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" }
|
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 "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" }
|
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
|
$uninstall = Start-Process msiexec.exe -ArgumentList "/x `"$msi`" /qn /norestart" -Wait -PassThru
|
||||||
if ($uninstall.ExitCode -ne 0) { throw "synthetic MSI uninstall failed: $($uninstall.ExitCode)" }
|
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" }
|
if (Test-Path "HKCU:\Software\Classes\$protocol") { throw "$protocol protocol key survived uninstall" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +171,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
# MSI ProductVersion must be numeric x.y.z; the full string goes to ARPCOMMENTS.
|
# 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/')"
|
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 "version=$VERSION"
|
||||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
echo "msi_version=$MSI_VERSION"
|
||||||
|
echo "prerelease=$PRERELEASE"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Verify tag is on main
|
- name: Verify tag is on main
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -238,7 +240,7 @@ jobs:
|
|||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: crates -> target
|
workspaces: crates -> target
|
||||||
shared-key: qt-windows-x86_64
|
shared-key: qt-windows-x86_64-${{ env.QT_VERSION }}
|
||||||
cache-on-failure: true
|
cache-on-failure: true
|
||||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
|
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.
|
# the link and let the PDB carry them.
|
||||||
CARGO_PROFILE_RELEASE_STRIP: "none"
|
CARGO_PROFILE_RELEASE_STRIP: "none"
|
||||||
run: |
|
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"
|
-p qbz-qt --target "$TARGET"
|
||||||
ls -la "crates/target/$TARGET/release/qbz.exe" "crates/target/$TARGET/release/qbz.pdb"
|
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).
|
# (the committed .qsb carry HLSL 50 *source*; d3d11 compiles it at runtime).
|
||||||
# Exit code is checked (pipefail); the grep is a separate, informational pass.
|
# Exit code is checked (pipefail); the grep is a separate, informational pass.
|
||||||
"$QT_ROOT_DIR/bin/windeployqt.exe" --release --qmldir crates/qbz-qt/qml \
|
"$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
|
grep -iE 'error|warning' windeploy.log || true
|
||||||
# Nothing uses Qt SQL, Qt TLS (rustls) or network-information.
|
# Nothing uses Qt SQL, Qt TLS (rustls) or network-information.
|
||||||
rm -rf "$DIST/sqldrivers" "$DIST/tls" "$DIST/networkinformation"
|
rm -rf "$DIST/sqldrivers" "$DIST/tls" "$DIST/networkinformation"
|
||||||
@@ -319,43 +321,14 @@ jobs:
|
|||||||
# Hard fail, not a warning — plan A task 14 adds licenses/.
|
# 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
|
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"
|
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"
|
du -sh "$DIST"
|
||||||
|
|
||||||
# THE gate: the deployed directory boots without the Qt install. Pass =
|
# Both products harvest this exact app-local runtime. Audit every EXE,
|
||||||
# >= 10 log lines, zero QML/loader complaints, core initialized (login
|
# DLL and plugin (normal + delay imports) before making either artifact.
|
||||||
# screen reached; `home published` needs a session CI does not have).
|
- name: Deploy official MSVC CRT app-local and audit imports
|
||||||
# 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)
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: ./scripts/packaging/deploy_windows_crt.ps1 -Dist dist/qbz
|
||||||
$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)"
|
|
||||||
# D4 = unsigned, so this is a no-op until a cert exists (HAVE_CERT is
|
# 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
|
# 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,
|
# for an OV cert in a PFX: cloud-issued keys (Azure Trusted Signing,
|
||||||
@@ -400,37 +373,43 @@ jobs:
|
|||||||
-o "dist/QBZ_${VERSION}_x64.msi"
|
-o "dist/QBZ_${VERSION}_x64.msi"
|
||||||
ls -la dist
|
ls -la dist
|
||||||
|
|
||||||
# The MSI is exercised, not just built: install per-user (no UAC), assert
|
# Product smoke uses qwindows from the installed payload with an isolated
|
||||||
# the layout + registry, launch the INSTALLED exe offscreen, uninstall,
|
# profile/PATH. A VS runner still does not certify a clean Windows image.
|
||||||
# assert nothing is left. `wix msi validate` is deliberately NOT run:
|
- name: Install / upgrade / launch / uninstall the MSI (runner smoke)
|
||||||
# per-user harvested components fail ICE38 by WiX design (issue #8633).
|
|
||||||
- name: Install / launch / uninstall the MSI (runner smoke)
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.release.outputs.version }}
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
$msi = (Resolve-Path "dist\QBZ_$($env:VERSION)_x64.msi").Path
|
$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"
|
$root = "$env:LOCALAPPDATA\Programs\QBZ"
|
||||||
foreach ($f in "qbz.exe","platforms\qwindows.dll","qml\QtQuick\Controls\Basic","imageformats\qsvg.dll","LICENSE.txt") {
|
$installed = $false
|
||||||
if (-not (Test-Path "$root\$f")) { Write-Host "::error::installed layout is missing $f"; exit 1 }
|
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 ($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 '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
|
||||||
}
|
}
|
||||||
foreach ($protocol in "qbz", "qobuzapp") {
|
finally {
|
||||||
if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { Write-Host "::error::$protocol protocol key missing"; exit 1 }
|
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; throw "msiexec uninstall exit $($u.ExitCode)" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { Write-Host "::error::Start-menu shortcut missing"; exit 1 }
|
if (Test-Path "$root\qbz.exe") { throw 'Uninstall left qbz.exe' }
|
||||||
$env:QT_QPA_PLATFORM = "offscreen"; $env:RUST_LOG = "info"
|
if (Test-Path 'HKCU:\Software\Classes\qbz') { throw 'Uninstall left the qbz protocol key' }
|
||||||
$app = Start-Process "$root\qbz.exe" -RedirectStandardOutput inst.out -RedirectStandardError inst.err -PassThru -NoNewWindow
|
Write-Host 'MSI install/upgrade/GUI launch/uninstall OK'
|
||||||
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 }
|
|
||||||
$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 }
|
|
||||||
}
|
|
||||||
Write-Host "MSI install/launch/uninstall OK"
|
|
||||||
|
|
||||||
- name: Sign MSI
|
- name: Sign MSI
|
||||||
if: ${{ env.HAVE_CERT == 'true' }}
|
if: ${{ env.HAVE_CERT == 'true' }}
|
||||||
@@ -450,6 +429,31 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Compress-Archive -Path dist\qbz\* -DestinationPath "dist\qbz_$($env:VERSION)_windows_x64.zip"
|
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"
|
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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v7
|
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:
|
# Three jobs, three economies:
|
||||||
# - release-linux / build-slint / release-snap compile -p qbz (full UI, ~30 GB peak)
|
# test — validate all eight gettext catalogs, then `cargo test` on
|
||||||
# - this job runs `cargo test` on every non-UI crate only (~5 min cold, ~1–2 min warm)
|
# 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
|
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Release packaging
|
||||||
# never enters the graph. Cache key is intentionally separate from
|
# stays tag/dispatch-only; this workflow also tests optimized startup on
|
||||||
# `slint-linux` (stable + test profile vs nightly + release).
|
# pre-release
|
||||||
#
|
# integration so regressions land before a release cut.
|
||||||
# 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.
|
|
||||||
|
|
||||||
name: test-crates
|
name: test-crates
|
||||||
|
|
||||||
@@ -22,6 +40,14 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "crates/**"
|
- "crates/**"
|
||||||
- "scripts/cargo-test.sh"
|
- "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"
|
- ".github/workflows/test-crates.yml"
|
||||||
push:
|
push:
|
||||||
# Integration branch only — never main (a push to main is a release merge).
|
# Integration branch only — never main (a push to main is a release merge).
|
||||||
@@ -29,6 +55,14 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "crates/**"
|
- "crates/**"
|
||||||
- "scripts/cargo-test.sh"
|
- "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"
|
- ".github/workflows/test-crates.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -39,12 +73,17 @@ concurrency:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
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:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: cargo test (workspace, no UI)
|
name: cargo test (workspace, no UI)
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
# Cold first run ~5 min (compile + test); warm ~1–2 min. Headroom for
|
# The rule: --no-fail-fast, 20 minutes. All cores: measured cold on an
|
||||||
# registry lag / cache restore, not for a Slint UI compile.
|
# 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
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -52,54 +91,135 @@ jobs:
|
|||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
# Minimal subset of release-linux.yml deps: non-UI crates still link
|
# Non-UI crates still link ALSA / JACK / D-Bus (qbz-audio) and
|
||||||
# ALSA / JACK / D-Bus (qbz-audio) and OpenSSL (reqwest). mold matches
|
# OpenSSL (reqwest).
|
||||||
# the release linker for faster test-binary link.
|
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y --no-install-recommends \
|
sudo apt-get install -y --no-install-recommends \
|
||||||
build-essential pkg-config mold \
|
build-essential pkg-config gettext \
|
||||||
libasound2-dev libjack-jackd2-dev \
|
libasound2-dev libjack-jackd2-dev \
|
||||||
libdbus-1-dev libssl-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
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
# Manifest is crates/Cargo.toml → artifacts in crates/target (same
|
|
||||||
# workspaces mapping as release-linux / build-slint).
|
|
||||||
workspaces: crates -> target
|
workspaces: crates -> target
|
||||||
shared-key: test-crates-linux
|
shared-key: test-crates-linux
|
||||||
cache-on-failure: true
|
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' }}
|
save-if: ${{ github.ref == 'refs/heads/pre-release' }}
|
||||||
|
|
||||||
- name: cargo test
|
- name: cargo test (scripts/cargo-test.sh — the local mirror)
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
CARGO_BUILD_JOBS: "2"
|
|
||||||
# Faster link; no -Z flags (stable toolchain).
|
|
||||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
|
||||||
run: |
|
run: |
|
||||||
echo "::group::toolchain"
|
rustc -V; cargo -V
|
||||||
rustc -V
|
|
||||||
cargo -V
|
|
||||||
echo "::endgroup::"
|
|
||||||
START=$(date +%s)
|
START=$(date +%s)
|
||||||
./scripts/cargo-test.sh
|
./scripts/cargo-test.sh
|
||||||
END=$(date +%s)
|
echo "cargo test wall time: $(( $(date +%s) - START ))s"
|
||||||
echo "cargo test wall time: $((END - 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: |
|
run: |
|
||||||
set -euo pipefail
|
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)
|
| grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true)
|
||||||
if [ -n "$hits" ]; then
|
[ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; }
|
||||||
echo "::error::qbzd graph resolves Slint crates:"; echo "$hits"; exit 1
|
bash scripts/qt-runtime-gate.sh
|
||||||
fi
|
|
||||||
@@ -50,6 +50,7 @@ static/*upscayl*
|
|||||||
/.tmp/
|
/.tmp/
|
||||||
/tmp/
|
/tmp/
|
||||||
/tools/
|
/tools/
|
||||||
|
/scratchpad/
|
||||||
|
|
||||||
# Documentation (kept external)
|
# Documentation (kept external)
|
||||||
qbz-nix-docs/
|
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
|
## Where the code lives
|
||||||
|
|
||||||
The live app is the Rust workspace under `crates/` — a single native process
|
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)
|
with a Qt/QML interface. UI code and the desktop binary live in
|
||||||
and `crates/qbz` (the binary). The old Svelte `src/` and Tauri `src-tauri/`
|
`crates/qbz-qt`; shared behavior belongs in the relevant core crate.
|
||||||
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 —
|
The Slint crates (`crates/qbz-ui`, `crates/qbz`, `crates/qbz-slint-common` and
|
||||||
port the change to `crates/` instead.
|
`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
|
## Quick rules
|
||||||
|
|
||||||
- Write clear, concise English (no emojis in code, comments, or commit messages).
|
- Write clear, concise English (no emojis in code, comments, or commit messages).
|
||||||
- Keep PRs focused and small when possible.
|
- 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 change app branding or legal disclaimers without discussing it first.
|
||||||
- Do not modify protected audio-backend behavior unless explicitly requested by the maintainer.
|
- 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 checkout pre-release`
|
||||||
- `git merge --no-ff <type>/external/<topic>`
|
- `git merge --no-ff <type>/external/<topic>`
|
||||||
5. **Run checks**
|
5. **Run checks**
|
||||||
- Build/validate a touched core crate: `cargo check -p <crate>` (run from
|
- Test or check each touched core crate from `crates/`.
|
||||||
`crates/`). The full UI (`qbz`/`qbz-ui`) is a ~20–30 GB compile — see the
|
- For Qt/QML changes, run the relevant QML audits and use
|
||||||
README "Building from Source" section before attempting it.
|
`./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**
|
6. **Push pre-release**
|
||||||
- `git push origin pre-release`
|
- `git push origin pre-release`
|
||||||
7. **Close the PR with a comment** explaining it was merged to `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
|
## What to include in PRs
|
||||||
|
|
||||||
- A short description of the problem and solution.
|
- 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.
|
- 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.
|
- 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
|
## What not to include
|
||||||
|
|
||||||
- Large refactors mixed with feature work.
|
- 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
|
QBZ ships 8 locales (`en es de fr pt ru ja nl`) as gettext `.po` files, bundled
|
||||||
via the `qbz-i18n` crate. Rules:
|
via the `qbz-i18n` crate. Rules:
|
||||||
|
|
||||||
- **No hardcoded UI strings in `.slint`** — every string goes through
|
- **No hardcoded UI strings in QML** — use
|
||||||
`@tr("...")`.
|
`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
|
- Adding or changing a string means updating **all** locale `.po` files, not
|
||||||
just English.
|
just English.
|
||||||
- `@tr` property defaults are not reactive — re-seed from Rust on language
|
- The English text is the gettext `msgid`; do not introduce dotted keys.
|
||||||
change (see `crates/qbz-i18n` and `select_bundled_translation()`, called
|
|
||||||
after `AppWindow::new()`).
|
|
||||||
|
|
||||||
### Checklist for PRs with UI Text
|
### 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
|
- [ ] Every new/changed string updated across all 8 `.po` locales
|
||||||
- [ ] Reused an existing string where one already fit
|
- [ ] 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-integrations",
|
||||||
"qbz-cache",
|
"qbz-cache",
|
||||||
"qbz-library",
|
"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-cast",
|
||||||
"qbz-cmaf",
|
"qbz-cmaf",
|
||||||
"qbz-dsd",
|
"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-secrets",
|
||||||
"qbz-offline-cache",
|
"qbz-offline-cache",
|
||||||
"qbz-plex",
|
"qbz-plex",
|
||||||
@@ -23,15 +31,15 @@ members = [
|
|||||||
"qconnect-protocol",
|
"qconnect-protocol",
|
||||||
"qconnect-transport-ws",
|
"qconnect-transport-ws",
|
||||||
"qconnect-app",
|
"qconnect-app",
|
||||||
"qbz-ui",
|
"qconnect-lan",
|
||||||
"qbz-dac-wizard",
|
# The HiFi Wizard's frontend-agnostic core (ADR-006), consumed by Qt.
|
||||||
|
"qbz-dac-wizard-core",
|
||||||
"qbz-text-utils",
|
"qbz-text-utils",
|
||||||
"qbz-slint-common",
|
|
||||||
"qbz",
|
|
||||||
"qbz-credentials",
|
"qbz-credentials",
|
||||||
"qbz-media-controls",
|
"qbz-media-controls",
|
||||||
"qbz-mixtape",
|
"qbz-mixtape",
|
||||||
"qbz-playlist-import",
|
"qbz-playlist-import",
|
||||||
|
"qbz-account-migration",
|
||||||
"qbz-lyrics",
|
"qbz-lyrics",
|
||||||
"qbz-i18n",
|
"qbz-i18n",
|
||||||
"qbz-music-link",
|
"qbz-music-link",
|
||||||
@@ -39,10 +47,25 @@ members = [
|
|||||||
"qbz-external-reco",
|
"qbz-external-reco",
|
||||||
"qbz-log",
|
"qbz-log",
|
||||||
"qbzd",
|
"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]
|
[workspace.package]
|
||||||
version = "2.0.2"
|
version = "2.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/vicrodh/qbz"
|
repository = "https://github.com/vicrodh/qbz"
|
||||||
@@ -59,7 +82,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
# HTTP
|
# 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
|
# Logging
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
@@ -71,7 +94,7 @@ anyhow = "1"
|
|||||||
|
|
||||||
# Crypto (Qobuz auth)
|
# Crypto (Qobuz auth)
|
||||||
md-5 = "0.10"
|
md-5 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.23"
|
||||||
|
|
||||||
# Time
|
# Time
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
@@ -81,6 +104,7 @@ regex = "1"
|
|||||||
|
|
||||||
# Audio
|
# Audio
|
||||||
symphonia = { version = "0.5", features = ["all"] }
|
symphonia = { version = "0.5", features = ["all"] }
|
||||||
|
lofty = "0.25.1"
|
||||||
|
|
||||||
# Random number generation (shuffle / DJ-mix) — mirrors src-tauri pin
|
# Random number generation (shuffle / DJ-mix) — mirrors src-tauri pin
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
@@ -91,59 +115,22 @@ strsim = "0.11"
|
|||||||
# Note: Linux-specific dependencies (alsa) go in individual crate Cargo.toml files
|
# Note: Linux-specific dependencies (alsa) go in individual crate Cargo.toml files
|
||||||
# because workspace manifests cannot have [target] sections
|
# 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]
|
[profile.release]
|
||||||
# Strip symbol tables from release binaries. Measured on 2026-07-02:
|
# Strip symbol tables from release binaries. Measured on 2026-07-02:
|
||||||
# 400 MB -> 218 MB (-45%). There are no .debug_* sections in a default
|
# 400 MB -> 218 MB (-45%). Trade-off: panic backtraces show addresses instead
|
||||||
# release build — the weight is .strtab (151 MB) + .symtab (22 MB), inflated
|
# of names; release automation retains an unstripped artifact for offline
|
||||||
# by Slint's generated generic symbol names. Trade-off: panic backtraces show
|
# symbolication.
|
||||||
# addresses instead of names; the release workflow archives the UNSTRIPPED
|
|
||||||
# binary as an Actions artifact per tag for offline symbolication.
|
|
||||||
# Codegen knobs (opt-level / codegen-units / jobs) intentionally stay in the
|
# Codegen knobs (opt-level / codegen-units / jobs) intentionally stay in the
|
||||||
# workflows' env (CARGO_PROFILE_RELEASE_*) — they are CI-memory tuning, not
|
# workflows' env (CARGO_PROFILE_RELEASE_*) — they are CI-memory tuning, not
|
||||||
# a property of the package.
|
# a property of the package.
|
||||||
strip = "symbols"
|
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
|
# cross (https://github.com/cross-rs/cross) config for cross-compiling the
|
||||||
# aarch64 `qbz` binary from an x86-64 Linux host. Used by
|
# aarch64 `qbzd` daemon from an x86-64 Linux host. Used by
|
||||||
# scripts/build-aarch64-linux.sh (CROSS mode).
|
# 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
|
# `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
|
# 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 = [
|
pre-build = [
|
||||||
"dpkg --add-architecture arm64",
|
"dpkg --add-architecture arm64",
|
||||||
"apt-get update",
|
"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]
|
[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-core = { path = "../qbz-core" }
|
||||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||||
qbz-credentials = { path = "../qbz-credentials" }
|
qbz-credentials = { path = "../qbz-credentials" }
|
||||||
|
qbz-offline-cache = { path = "../qbz-offline-cache" }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
md-5 = { workspace = true }
|
md-5 = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
rand = "0.10"
|
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_preferred_sample_rate: Option<u32>,
|
||||||
pub audio_alsa_plugin: Option<String>,
|
pub audio_alsa_plugin: Option<String>,
|
||||||
pub audio_alsa_hardware_volume: bool,
|
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_enabled: bool,
|
||||||
pub audio_normalization_target_lufs: f32,
|
pub audio_normalization_target_lufs: f32,
|
||||||
pub audio_gapless_enabled: bool,
|
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_preferred_sample_rate: audio.preferred_sample_rate,
|
||||||
audio_alsa_plugin: audio.alsa_plugin.map(|p| format!("{:?}", p)),
|
audio_alsa_plugin: audio.alsa_plugin.map(|p| format!("{:?}", p)),
|
||||||
audio_alsa_hardware_volume: audio.alsa_hardware_volume,
|
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_enabled: audio.normalization_enabled,
|
||||||
audio_normalization_target_lufs: audio.normalization_target_lufs,
|
audio_normalization_target_lufs: audio.normalization_target_lufs,
|
||||||
audio_gapless_enabled: audio.gapless_enabled,
|
audio_gapless_enabled: audio.gapless_enabled,
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
|
pub mod device_cap;
|
||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
|
pub mod memory_watchdog;
|
||||||
pub mod offline_mode;
|
pub mod offline_mode;
|
||||||
pub mod graphics_autoconfig;
|
pub mod graphics_autoconfig;
|
||||||
|
pub mod listen_log;
|
||||||
pub mod playback_context;
|
pub mod playback_context;
|
||||||
pub mod playback_driver;
|
pub mod playback_driver;
|
||||||
pub mod qconnect_identity;
|
pub mod qconnect_identity;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod scrobble_timing;
|
pub mod scrobble_timing;
|
||||||
|
pub mod session_persist;
|
||||||
pub mod session_store;
|
pub mod session_store;
|
||||||
|
pub mod subscription_lifecycle;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod user_data;
|
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,
|
expect: ProbeExpect::Status204,
|
||||||
},
|
},
|
||||||
ProbeEndpoint {
|
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"),
|
expect: ProbeExpect::BodyContains("Microsoft Connect Test"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -595,4 +599,21 @@ mod tests {
|
|||||||
assert!(!ipv6_has_default_route(non_default));
|
assert!(!ipv6_has_default_route(non_default));
|
||||||
assert!(!ipv6_has_default_route(lo_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()
|
store.get_settings()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the network-folders-in-manual-offline policy flag.
|
/// Restore the Tauri manual-offline policy: submit scrobbles through the
|
||||||
///
|
/// network immediately even while the Qobuz offline gate is induced.
|
||||||
/// NOTE (2026-06-10): no UI calls this anymore — the Slint "Show Network
|
/// Enabling it atomically disables accumulated mode in the shared store.
|
||||||
/// Folder Content" toggle was removed when library visibility stopped
|
pub fn set_allow_immediate_scrobbling(&self, enabled: bool) -> Result<(), String> {
|
||||||
/// 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> {
|
|
||||||
let guard = self
|
let guard = self
|
||||||
.store
|
.store
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| format!("offline store lock poisoned: {}", e))?;
|
.map_err(|e| format!("offline store lock poisoned: {}", e))?;
|
||||||
let store = guard.as_ref().ok_or("No active session")?;
|
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
|
/// 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`
|
//! Opens the SAME `offline_settings.db` Tauri's `src-tauri/src/offline/mod.rs`
|
||||||
//! uses (identical creation SQL + additive migrations), so the file stays
|
//! uses (identical creation SQL + additive migrations), so the file stays
|
||||||
//! frontend-portable like `library.db`/`index.db`. This shared store exposes
|
//! 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).
|
//! - `manual_offline_mode` — the induced-offline flag (persisted; D1).
|
||||||
//! - `show_network_folders_in_manual_offline` — network-mount policy (D9).
|
//! - `show_network_folders_in_manual_offline` — network-mount policy (D9).
|
||||||
//! - `pre_offline_stream_first_track` — the issue #279 snapshot of
|
//! - `pre_offline_stream_first_track` — the issue #279 snapshot of
|
||||||
//! `audio_settings.stream_first_track` taken on entering induced offline.
|
//! `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`,
|
//! The remaining legacy columns/tables (`allow_cast_while_offline`,
|
||||||
//! `scrobble_queue`, `cache_limit_bytes`) are still CREATED for byte-level
|
//! `pending_playlist_sync`, `cache_limit_bytes`) are still CREATED for
|
||||||
//! compatibility with the Tauri schema, but get no API here: the dead toggles
|
//! byte-level compatibility with the Tauri schema. Offline playlist creation
|
||||||
//! are not ported (spec §1) and offline playlist creation is replaced by
|
//! is replaced by first-class local playlists (D7/D8).
|
||||||
//! first-class local playlists (D7/D8).
|
|
||||||
|
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// The offline-mode settings the port consumes.
|
/// The offline-mode settings the port consumes.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct OfflineModeSettings {
|
pub struct OfflineModeSettings {
|
||||||
pub manual_offline_mode: bool,
|
pub manual_offline_mode: bool,
|
||||||
pub show_network_folders_in_manual_offline: 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
|
/// One row of the Last.fm offline scrobble queue (`scrobble_queue`). Mirrors
|
||||||
@@ -120,13 +136,17 @@ impl OfflineModeStore {
|
|||||||
self.conn
|
self.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT manual_offline_mode,
|
"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",
|
FROM offline_settings WHERE id = 1",
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(OfflineModeSettings {
|
Ok(OfflineModeSettings {
|
||||||
manual_offline_mode: row.get::<_, i64>(0)? != 0,
|
manual_offline_mode: row.get::<_, i64>(0)? != 0,
|
||||||
show_network_folders_in_manual_offline: row.get::<_, i64>(1)? != 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(())
|
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
|
/// Issue #279 snapshot: the user's `stream_first_track` preference stashed
|
||||||
/// when entering induced offline. `None` = no snapshot active.
|
/// when entering induced offline. `None` = no snapshot active.
|
||||||
pub fn get_pre_offline_stream_first_track(&self) -> Result<Option<bool>, String> {
|
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();
|
let settings = store.get_settings().unwrap();
|
||||||
assert!(!settings.manual_offline_mode);
|
assert!(!settings.manual_offline_mode);
|
||||||
assert!(!settings.show_network_folders_in_manual_offline);
|
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);
|
assert_eq!(store.get_pre_offline_stream_first_track().unwrap(), None);
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
@@ -347,6 +401,30 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(dir);
|
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]
|
#[test]
|
||||||
fn scrobble_queue_round_trips_and_marks_sent() {
|
fn scrobble_queue_round_trips_and_marks_sent() {
|
||||||
let dir = unique_test_dir("offline-store-scrobbles");
|
let dir = unique_test_dir("offline-store-scrobbles");
|
||||||
@@ -420,6 +498,8 @@ mod tests {
|
|||||||
let settings = store.get_settings().unwrap();
|
let settings = store.get_settings().unwrap();
|
||||||
assert!(settings.manual_offline_mode, "Tauri-era flag must survive");
|
assert!(settings.manual_offline_mode, "Tauri-era flag must survive");
|
||||||
assert!(!settings.show_network_folders_in_manual_offline);
|
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);
|
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 is_local: bool,
|
||||||
pub album_id: Option<String>,
|
pub album_id: Option<String>,
|
||||||
pub artist_id: Option<u64>,
|
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")]
|
#[serde(default = "default_streamable")]
|
||||||
pub streamable: bool,
|
pub streamable: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -52,6 +60,31 @@ pub struct PersistedPlaybackSession {
|
|||||||
pub saved_at: i64,
|
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 {
|
impl Default for PersistedPlaybackSession {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -153,6 +186,19 @@ impl SessionStore {
|
|||||||
source TEXT
|
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)
|
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);
|
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 })
|
Ok(Self { conn })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +360,14 @@ impl SessionStore {
|
|||||||
let _ = self.conn.execute("ROLLBACK", []);
|
let _ = self.conn.execute("ROLLBACK", []);
|
||||||
return Err(format!("Failed to clear queue: {}", e));
|
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() {
|
for (pos, track) in session.playback.queue_tracks.iter().enumerate() {
|
||||||
if let Err(e) = self.conn.execute(
|
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> {
|
pub fn save_position(&self, position_secs: u64) -> Result<(), String> {
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -473,6 +661,12 @@ impl SessionStore {
|
|||||||
self.conn
|
self.conn
|
||||||
.execute("DELETE FROM queue_tracks", [])
|
.execute("DELETE FROM queue_tracks", [])
|
||||||
.map_err(|e| format!("Failed to clear queue: {}", e))?;
|
.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(
|
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",
|
"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.was_playing);
|
||||||
assert!(loaded.playback.saved_at > 0);
|
assert!(loaded.playback.saved_at > 0);
|
||||||
assert_eq!(loaded.shell_view.last_view, "album");
|
assert_eq!(loaded.shell_view.last_view, "album");
|
||||||
assert_eq!(loaded.shell_view.view_context_id.as_deref(), Some("album-1"));
|
assert_eq!(
|
||||||
assert_eq!(loaded.shell_view.view_context_type.as_deref(), Some("album"));
|
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);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,30 +52,46 @@ fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
updated_at INTEGER NOT NULL
|
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> {
|
fn open_db() -> Option<Connection> {
|
||||||
let path = db_path()?;
|
let path = db_path()?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
if let Err(e) = std::fs::create_dir_all(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;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let conn = match Connection::open(&path) {
|
let conn = match Connection::open(&path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[qbz-slint] album_play_history open failed: {e}");
|
log::warn!("[qbz-app] album_play_history open failed: {e}");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// ADR-002: WAL for any SQLite store touched off the UI thread.
|
// 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;") {
|
if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") {
|
||||||
log::warn!("[qbz-slint] album_play_history pragma failed: {e}");
|
log::warn!("[qbz-app] album_play_history pragma failed: {e}");
|
||||||
}
|
}
|
||||||
if let Err(e) = init_schema(&conn) {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
Some(conn)
|
Some(conn)
|
||||||
@@ -103,6 +119,11 @@ pub struct AlbumPlayMeta<'a> {
|
|||||||
pub quality_label: &'a str,
|
pub quality_label: &'a str,
|
||||||
pub year: &'a str,
|
pub year: &'a str,
|
||||||
pub source: &'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.
|
/// 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.
|
/// Internal so tests can drive it against an in-memory connection.
|
||||||
fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
|
fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
|
||||||
if let Err(e) = conn.execute(
|
if let Err(e) = conn.execute(
|
||||||
"INSERT INTO album_play_events (album_id, occurred_at) VALUES (?, ?)",
|
"INSERT INTO album_play_events (album_id, occurred_at, context_kind) \
|
||||||
params![m.album_id, now],
|
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(
|
if let Err(e) = conn.execute(
|
||||||
r#"
|
r#"
|
||||||
@@ -159,7 +181,7 @@ fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
|
|||||||
now
|
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 (
|
JOIN (
|
||||||
SELECT album_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
|
SELECT album_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
|
||||||
FROM album_play_events
|
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
|
GROUP BY album_id
|
||||||
) p ON p.album_id = m.album_id
|
) p ON p.album_id = m.album_id
|
||||||
ORDER BY p.plays DESC, p.last_at DESC
|
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
|
/// 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
|
/// audible playback. No-op when the album id is empty (some local/Plex
|
||||||
/// sources carry none — same guard as the recently-played rail).
|
/// 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) {
|
pub fn record_album_play(m: AlbumPlayMeta) {
|
||||||
if m.album_id.is_empty() {
|
if m.album_id.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -223,17 +251,45 @@ pub fn record_album_play(m: AlbumPlayMeta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The top `limit` most-played albums (the carousel).
|
/// The top `limit` most-played albums (the carousel).
|
||||||
#[allow(dead_code)] // wired by home/foryou
|
|
||||||
pub fn top_albums(limit: u32) -> Vec<AlbumPlayRow> {
|
pub fn top_albums(limit: u32) -> Vec<AlbumPlayRow> {
|
||||||
with_db(|conn| Some(query_on(conn, Some(limit)))).unwrap_or_default()
|
with_db(|conn| Some(query_on(conn, Some(limit)))).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every played album, ranked (the "View all" page).
|
/// Every played album, ranked (the "View all" page).
|
||||||
#[allow(dead_code)] // wired by the View-all loader
|
|
||||||
pub fn all_albums() -> Vec<AlbumPlayRow> {
|
pub fn all_albums() -> Vec<AlbumPlayRow> {
|
||||||
with_db(|conn| Some(query_on(conn, None))).unwrap_or_default()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -249,6 +305,7 @@ mod tests {
|
|||||||
quality_label: "Hi-Res",
|
quality_label: "Hi-Res",
|
||||||
year: "2024",
|
year: "2024",
|
||||||
source: "qobuz",
|
source: "qobuz",
|
||||||
|
context_kind: "album",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ impl BlacklistService {
|
|||||||
.map_err(|e| format!("Failed to open blacklist database: {}", e))?;
|
.map_err(|e| format!("Failed to open blacklist database: {}", e))?;
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent access (ADR-002).
|
// 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))?;
|
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||||
|
|
||||||
let service = Self {
|
let service = Self {
|
||||||
@@ -179,7 +179,7 @@ impl BlacklistService {
|
|||||||
.map_err(|e| format!("Failed to prepare blacklist query: {}", e))?;
|
.map_err(|e| format!("Failed to prepare blacklist query: {}", e))?;
|
||||||
|
|
||||||
let ids: Vec<u64> = stmt
|
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))?
|
.map_err(|e| format!("Failed to query blacklist: {}", e))?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.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"
|
| "output_device"
|
||||||
| "alsa_plugin"
|
| "alsa_plugin"
|
||||||
| "alsa_hardware_volume"
|
| "alsa_hardware_volume"
|
||||||
|
| "alsa_hardware_volume_controls"
|
||||||
| "dsd_mode"
|
| "dsd_mode"
|
||||||
)
|
)
|
||||||
|| k.eq_ignore_ascii_case("volume");
|
|| 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);
|
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 Some(v) = map.get(key) else { continue };
|
||||||
let no_change = alsa_field_no_change(current, key, v);
|
let no_change = alsa_field_no_change(current, key, v);
|
||||||
if no_change {
|
if no_change {
|
||||||
applied_line(plan, &format!("audio.{key}"), v, "");
|
applied_line(plan, &format!("audio.{key}"), v, "");
|
||||||
} else if device_survives && resolved_backend_alsa {
|
} else if device_survives && resolved_backend_alsa {
|
||||||
applied_line(plan, &format!("audio.{key}"), v, "rides the ALSA device");
|
applied_line(plan, &format!("audio.{key}"), v, "rides the ALSA device");
|
||||||
|
plan.routing_critical_changed = true;
|
||||||
} else {
|
} else {
|
||||||
plan.skipped.push(skip_line(
|
plan.skipped.push(skip_line(
|
||||||
&format!("audio.{key}"),
|
&format!("audio.{key}"),
|
||||||
@@ -978,6 +986,7 @@ fn backend_name(b: AudioBackendType) -> &'static str {
|
|||||||
AudioBackendType::Alsa => "Alsa",
|
AudioBackendType::Alsa => "Alsa",
|
||||||
AudioBackendType::Pulse => "Pulse",
|
AudioBackendType::Pulse => "Pulse",
|
||||||
AudioBackendType::Jack => "Jack",
|
AudioBackendType::Jack => "Jack",
|
||||||
|
AudioBackendType::WasapiExclusive => "WasapiExclusive",
|
||||||
AudioBackendType::SystemDefault => "SystemDefault",
|
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 {
|
fn alsa_field_no_change(current: &AudioSettings, key: &str, v: &Value) -> bool {
|
||||||
match key {
|
match key {
|
||||||
"alsa_hardware_volume" => v.as_bool() == Some(current.alsa_hardware_volume),
|
"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" => {
|
"alsa_plugin" => {
|
||||||
let cur = serde_json::to_value(current.alsa_plugin).unwrap_or(Value::Null);
|
let cur = serde_json::to_value(current.alsa_plugin).unwrap_or(Value::Null);
|
||||||
*v == cur
|
*v == cur
|
||||||
@@ -1165,6 +1180,11 @@ fn apply_audio_writes(data_root: &Path, writes: &[(&str, &Value)]) -> Result<(),
|
|||||||
store.set_alsa_plugin(p)?;
|
store.set_alsa_plugin(p)?;
|
||||||
}
|
}
|
||||||
"alsa_hardware_volume" => store.set_alsa_hardware_volume(as_bool(value))?,
|
"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))?,
|
"exclusive_mode" => store.set_exclusive_mode(as_bool(value))?,
|
||||||
"dac_passthrough" => store.set_dac_passthrough(as_bool(value))?,
|
"dac_passthrough" => store.set_dac_passthrough(as_bool(value))?,
|
||||||
"pw_force_bitperfect" => store.set_pw_force_bitperfect(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> {
|
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())?;
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
"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 {
|
/// The machine's name, for user-facing device labels.
|
||||||
std::env::var("HOSTNAME")
|
///
|
||||||
.ok()
|
/// `HOSTNAME` is a SHELL variable rather than an environment one on most Linux
|
||||||
.filter(|h| !h.trim().is_empty())
|
/// setups, hence the file fallback. Windows exports `COMPUTERNAME` and has no
|
||||||
.or_else(|| {
|
/// `/etc/hostname`, so every Windows install used to land on the same
|
||||||
std::fs::read_to_string("/etc/hostname")
|
/// "unknown" and collide on Qobuz Connect.
|
||||||
.ok()
|
///
|
||||||
.map(|s| s.trim().to_string())
|
/// The single implementation: `qbz-qt` and `qbzd` both call it and keep only
|
||||||
.filter(|s| !s.is_empty())
|
/// their own last-resort word, which differ on purpose.
|
||||||
})
|
pub fn hostname() -> String {
|
||||||
.unwrap_or_else(|| "unknown".to_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 {
|
fn now_rfc3339() -> String {
|
||||||
|
|||||||
@@ -483,6 +483,43 @@ fn apply_writes_are_idempotent_and_persist() {
|
|||||||
cleanup(&p);
|
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]
|
#[test]
|
||||||
fn bundle_json_roundtrips_flat() {
|
fn bundle_json_roundtrips_flat() {
|
||||||
// The on-disk shape is flat (§2.9): header + domains at the top level.
|
// 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
|
/// (CONSOLE ext). Default ON; the `QBZD_MPRIS` env var overrides it when
|
||||||
/// set. Toggling this needs a daemon restart (the service spawns at boot).
|
/// set. Toggling this needs a daemon restart (the service spawns at boot).
|
||||||
pub mpris_enabled: bool,
|
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 {
|
impl Default for DaemonPrefs {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -23,6 +29,7 @@ impl Default for DaemonPrefs {
|
|||||||
streaming_quality: "hires_plus".into(),
|
streaming_quality: "hires_plus".into(),
|
||||||
volume: 0.5,
|
volume: 0.5,
|
||||||
mpris_enabled: true,
|
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)
|
// forward-compatible: unknown fields ignored WITH a warning (01 §10.3)
|
||||||
if let Some(obj) = v.as_object() {
|
if let Some(obj) = v.as_object() {
|
||||||
for k in obj.keys() {
|
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}");
|
log::warn!("[daemon_prefs] unknown field ignored: {k}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +103,8 @@ mod tests {
|
|||||||
// Non-default (default is true) so the roundtrip actually exercises
|
// Non-default (default is true) so the roundtrip actually exercises
|
||||||
// the field, not just its presence.
|
// the field, not just its presence.
|
||||||
mpris_enabled: false,
|
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");
|
save_at(&prefs, &dir).expect("save daemon prefs");
|
||||||
@@ -100,6 +113,7 @@ mod tests {
|
|||||||
assert_eq!(loaded.streaming_quality, "cd");
|
assert_eq!(loaded.streaming_quality, "cd");
|
||||||
assert_eq!(loaded.volume, 0.72);
|
assert_eq!(loaded.volume, 0.72);
|
||||||
assert!(!loaded.mpris_enabled);
|
assert!(!loaded.mpris_enabled);
|
||||||
|
assert_eq!(loaded.hook_script, "/usr/local/bin/qbz-hook.sh");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ impl DeveloperSettingsStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open developer settings database: {}", e))?;
|
.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| {
|
.map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to enable WAL for developer settings database: {}",
|
"Failed to enable WAL for developer settings database: {}",
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ pub enum DiscoverySectionId {
|
|||||||
/// "Most Played Albums" — top albums by local play count
|
/// "Most Played Albums" — top albums by local play count
|
||||||
/// (`qbz_app::settings::album_play_history`). Home + For You, default off.
|
/// (`qbz_app::settings::album_play_history`). Home + For You, default off.
|
||||||
MostPlayedAlbums,
|
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 {
|
impl DiscoverySectionId {
|
||||||
@@ -115,6 +125,7 @@ impl DiscoverySectionId {
|
|||||||
ArtistSpotlight => "artistSpotlight",
|
ArtistSpotlight => "artistSpotlight",
|
||||||
Pinned => "pinned",
|
Pinned => "pinned",
|
||||||
MostPlayedAlbums => "mostPlayedAlbums",
|
MostPlayedAlbums => "mostPlayedAlbums",
|
||||||
|
RecentlyPlayedPlaylists => "recentlyPlayedPlaylists",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +153,7 @@ impl DiscoverySectionId {
|
|||||||
"artistSpotlight" => ArtistSpotlight,
|
"artistSpotlight" => ArtistSpotlight,
|
||||||
"pinned" => Pinned,
|
"pinned" => Pinned,
|
||||||
"mostPlayedAlbums" => MostPlayedAlbums,
|
"mostPlayedAlbums" => MostPlayedAlbums,
|
||||||
|
"recentlyPlayedPlaylists" => RecentlyPlayedPlaylists,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -192,6 +204,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
|||||||
pref(PressAwards, true),
|
pref(PressAwards, true),
|
||||||
pref(QobuzPlaylists, true),
|
pref(QobuzPlaylists, true),
|
||||||
pref(RecentlyPlayedAlbums, true),
|
pref(RecentlyPlayedAlbums, true),
|
||||||
|
pref(RecentlyPlayedPlaylists, true),
|
||||||
pref(ContinueListening, true),
|
pref(ContinueListening, true),
|
||||||
pref(IdealDiscography, true),
|
pref(IdealDiscography, true),
|
||||||
pref(MostStreamed, true),
|
pref(MostStreamed, true),
|
||||||
@@ -223,6 +236,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
|||||||
pref(RadioStations, true),
|
pref(RadioStations, true),
|
||||||
pref(ContinueListening, true),
|
pref(ContinueListening, true),
|
||||||
pref(RecentlyPlayedAlbums, true),
|
pref(RecentlyPlayedAlbums, true),
|
||||||
|
pref(RecentlyPlayedPlaylists, true),
|
||||||
pref(TopArtists, true),
|
pref(TopArtists, true),
|
||||||
pref(FavoriteAlbums, true),
|
pref(FavoriteAlbums, true),
|
||||||
pref(SimilarAlbums, true),
|
pref(SimilarAlbums, true),
|
||||||
@@ -418,6 +432,24 @@ pub fn reconcile_list(persisted: Option<&Vec<Value>>, fallback: &[SectionPref])
|
|||||||
// SQLite store
|
// 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 {
|
pub struct DiscoverPrefsStore {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
}
|
}
|
||||||
@@ -431,7 +463,7 @@ impl DiscoverPrefsStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open discover prefs database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to enable WAL for discover prefs database: {}", e))?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@@ -448,6 +480,33 @@ impl DiscoverPrefsStore {
|
|||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to initialize discover prefs: {}", e))?;
|
.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 })
|
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).
|
/// Persist the whole prefs blob (upsert row 1).
|
||||||
pub fn save(&self, prefs: &DiscoverPrefs) -> Result<(), String> {
|
pub fn save(&self, prefs: &DiscoverPrefs) -> Result<(), String> {
|
||||||
self.conn
|
self.conn
|
||||||
@@ -525,18 +633,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn defaults_match_spec_exactly() {
|
fn defaults_match_spec_exactly() {
|
||||||
let d = default_prefs();
|
let d = default_prefs();
|
||||||
// home: 15 entries, first 8 ON (Tauri sectionPrefs.ts + Slint `pinned`
|
// home: 16 entries, first 9 ON (Tauri sectionPrefs.ts + Slint `pinned`
|
||||||
// + the local mostPlayedAlbums, default off).
|
// + the local mostPlayedAlbums, default off, + recentlyPlayedPlaylists
|
||||||
|
// sitting right after its album sibling, default on).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids(&d.home),
|
ids(&d.home),
|
||||||
vec![
|
vec![
|
||||||
NewReleases, PressAwards, QobuzPlaylists, RecentlyPlayedAlbums,
|
NewReleases, PressAwards, QobuzPlaylists, RecentlyPlayedAlbums,
|
||||||
|
RecentlyPlayedPlaylists,
|
||||||
ContinueListening, IdealDiscography, MostStreamed, Pinned,
|
ContinueListening, IdealDiscography, MostStreamed, Pinned,
|
||||||
QobuzMixes, ReleaseWatch, EditorPicks, Qobuzissimes, TopArtists,
|
QobuzMixes, ReleaseWatch, EditorPicks, Qobuzissimes, TopArtists,
|
||||||
FavoriteAlbums, MostPlayedAlbums,
|
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, MostStreamed));
|
||||||
assert!(!d.is_enabled(DiscoveryTab::Home, Qobuzissimes));
|
assert!(!d.is_enabled(DiscoveryTab::Home, Qobuzissimes));
|
||||||
// editorPicks: 7 entries, all ON.
|
// editorPicks: 7 entries, all ON.
|
||||||
@@ -545,13 +655,15 @@ mod tests {
|
|||||||
vec![NewReleases, EditorPicks, Qobuzissimes, PressAwards, MostStreamed, IdealDiscography, QobuzPlaylists]
|
vec![NewReleases, EditorPicks, Qobuzissimes, PressAwards, MostStreamed, IdealDiscography, QobuzPlaylists]
|
||||||
);
|
);
|
||||||
assert_eq!(d.enabled_count(DiscoveryTab::EditorPicks), 7);
|
assert_eq!(d.enabled_count(DiscoveryTab::EditorPicks), 7);
|
||||||
// forYou: 14 entries, qobuzMixes first, pinned second; the 13
|
// forYou: 15 entries, qobuzMixes first, pinned second; the 13
|
||||||
// Tauri+Slint ones ON, mostPlayedAlbums (local addition) OFF.
|
// Tauri+Slint ones plus recentlyPlayedPlaylists ON, mostPlayedAlbums
|
||||||
assert_eq!(d.for_you.len(), 14);
|
// (local addition) OFF.
|
||||||
|
assert_eq!(d.for_you.len(), 15);
|
||||||
assert_eq!(d.for_you[0].id, QobuzMixes);
|
assert_eq!(d.for_you[0].id, QobuzMixes);
|
||||||
assert_eq!(d.for_you[1].id, Pinned);
|
assert_eq!(d.for_you[1].id, Pinned);
|
||||||
assert_eq!(d.for_you[13].id, MostPlayedAlbums);
|
assert_eq!(d.for_you[6].id, RecentlyPlayedPlaylists);
|
||||||
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 13);
|
assert_eq!(d.for_you[14].id, MostPlayedAlbums);
|
||||||
|
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Group 2: reconcile_list ---
|
// --- Group 2: reconcile_list ---
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ impl FavoritesPreferencesStore {
|
|||||||
let db_path = dir.join(db_name);
|
let db_path = dir.join(db_name);
|
||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open favorites preferences database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to set WAL mode on favorites preferences: {}", e))?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ impl FavoritesCacheStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open favorites cache database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to enable WAL for favorites cache database: {}", e))?;
|
||||||
|
|
||||||
// Create tables
|
// Create tables
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ impl GraphicsSettingsStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open graphics settings database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to enable WAL for graphics settings database: {}", e))?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
//! Headless local-favorites service.
|
//! Headless local-favorites service.
|
||||||
//!
|
//!
|
||||||
//! Frontend-agnostic store for favoriting LOCAL library items (genuine local
|
//! Frontend-agnostic store for favoriting LOCAL LIBRARY items (genuine local
|
||||||
//! files + Plex — never the Qobuz offline cache). Mirrors `pinned_items.rs`
|
//! 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
|
//! (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`).
|
//! per-user lifecycle lives in the `qbz` crate wrapper (`crate::local_favorites`).
|
||||||
//!
|
//!
|
||||||
//! Rows carry a display snapshot (title/subtitle/artwork) taken at favorite
|
//! Rows carry a display snapshot (title/subtitle/artwork) taken at favorite
|
||||||
//! time plus a denormalized `artist` (for per-artist counts) and `source`
|
//! 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
|
//! write time, so the mixed-library feed built from this store is inherently
|
||||||
//! free of Qobuz-offline duplicates.
|
//! 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.
|
/// A favorited local item with its display snapshot.
|
||||||
///
|
///
|
||||||
/// Ids are Strings: album = the local group key (`plex:…` / contains `|`/`/`),
|
/// Ids are Strings: album = the source-aware group key (`plex:…`,
|
||||||
/// artist = the artist NAME (local artists have no numeric id), track =
|
/// `jellyfin:…`, `subsonic:…` or a local key containing `|`/`/`), artist =
|
||||||
/// `file_path` (local) or `plex:<file_path>` (Plex) — the stable key.
|
/// the artist NAME, track = a source-aware stable key.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LocalFavItem {
|
pub struct LocalFavItem {
|
||||||
/// "album" | "artist" | "track".
|
/// "album" | "artist" | "track".
|
||||||
@@ -35,7 +37,8 @@ pub struct LocalFavItem {
|
|||||||
pub artwork_url: String,
|
pub artwork_url: String,
|
||||||
/// Denormalized artist name (for per-artist counts); empty for kind="artist".
|
/// Denormalized artist name (for per-artist counts); empty for kind="artist".
|
||||||
pub artist: String,
|
pub artist: String,
|
||||||
/// "local" | "plex" — never "qobuz_download".
|
/// "local" | "plex" | "jellyfin" | "subsonic" — never a Qobuz
|
||||||
|
/// download spelling.
|
||||||
pub source: String,
|
pub source: String,
|
||||||
/// Unix seconds; the ordering key (newest first).
|
/// Unix seconds; the ordering key (newest first).
|
||||||
pub favorited_at: i64,
|
pub favorited_at: i64,
|
||||||
@@ -56,8 +59,10 @@ impl LocalFavoritesService {
|
|||||||
let conn = Connection::open(db_path)
|
let conn = Connection::open(db_path)
|
||||||
.map_err(|e| format!("Failed to open local favorites database: {}", e))?;
|
.map_err(|e| format!("Failed to open local favorites database: {}", e))?;
|
||||||
|
|
||||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
conn.execute_batch(
|
||||||
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;",
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||||
|
|
||||||
let service = Self {
|
let service = Self {
|
||||||
conn,
|
conn,
|
||||||
@@ -97,17 +102,68 @@ impl LocalFavoritesService {
|
|||||||
subtitle TEXT,
|
subtitle TEXT,
|
||||||
artwork_url TEXT,
|
artwork_url TEXT,
|
||||||
artist 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')),
|
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||||
PRIMARY KEY (kind, id)
|
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
|
CREATE INDEX IF NOT EXISTS idx_local_favorites_at
|
||||||
ON local_favorites(favorited_at);
|
ON local_favorites(favorited_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_favorites_artist
|
CREATE INDEX IF NOT EXISTS idx_local_favorites_artist
|
||||||
ON local_favorites(kind, 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -298,10 +354,48 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn source_check_rejects_offline() {
|
fn source_check_rejects_offline() {
|
||||||
let s = LocalFavoritesService::new_in_memory().expect("svc");
|
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!(
|
assert!(
|
||||||
s.favorite(&item("album", "x", "X", "A", "qobuz_download"))
|
s.favorite(&item("album", "x", "X", "A", "qobuz_download"))
|
||||||
.is_err(),
|
.is_err(),
|
||||||
"the source CHECK refuses qobuz-offline rows"
|
"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 artist_blacklist;
|
||||||
|
pub mod blacklist_portable;
|
||||||
pub mod bundle;
|
pub mod bundle;
|
||||||
pub mod daemon_prefs;
|
pub mod daemon_prefs;
|
||||||
pub mod developer;
|
pub mod developer;
|
||||||
@@ -7,8 +8,11 @@ pub mod favorites;
|
|||||||
pub mod favorites_cache;
|
pub mod favorites_cache;
|
||||||
pub mod graphics;
|
pub mod graphics;
|
||||||
pub mod local_favorites;
|
pub mod local_favorites;
|
||||||
|
pub mod payload_cache;
|
||||||
pub mod pinned_items;
|
pub mod pinned_items;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
|
pub mod playlist_play_history;
|
||||||
|
pub mod media_servers;
|
||||||
pub mod plex;
|
pub mod plex;
|
||||||
pub mod album_play_history;
|
pub mod album_play_history;
|
||||||
pub mod reco_store;
|
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))?;
|
.map_err(|e| format!("Failed to open pinned items database: {}", e))?;
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent access (ADR-002).
|
// 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))?;
|
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||||
|
|
||||||
let service = Self {
|
let service = Self {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ impl PlaybackPreferencesStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open playback preferences database: {}", e))?;
|
.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| {
|
.map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to enable WAL for playback preferences database: {}",
|
"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)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open Plex settings database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to enable WAL for Plex settings database: {}", e))?;
|
||||||
|
|
||||||
// Base (slice 2a) schema. New columns are added by idempotent ALTERs
|
// Base (slice 2a) schema. New columns are added by idempotent ALTERs
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ impl RecoStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open reco database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to enable WAL for reco database: {}", e))?;
|
||||||
|
|
||||||
let store = Self { conn };
|
let store = Self { conn };
|
||||||
@@ -335,11 +335,11 @@ impl RecoStore {
|
|||||||
params![
|
params![
|
||||||
event.event_type.as_str(),
|
event.event_type.as_str(),
|
||||||
event.item_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.album_id.as_deref(),
|
||||||
event.artist_id,
|
event.artist_id.map(|v| v as i64),
|
||||||
event.playlist_id,
|
event.playlist_id.map(|v| v as i64),
|
||||||
event.genre_id,
|
event.genre_id.map(|v| v as i64),
|
||||||
now_ts(),
|
now_ts(),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -405,7 +405,7 @@ impl RecoStore {
|
|||||||
.map_err(|e| format!("Failed to prepare recent tracks query: {}", e))?;
|
.map_err(|e| format!("Failed to prepare recent tracks query: {}", e))?;
|
||||||
|
|
||||||
let rows = stmt
|
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))?;
|
.map_err(|e| format!("Failed to query recent tracks: {}", e))?;
|
||||||
|
|
||||||
let mut tracks = Vec::new();
|
let mut tracks = Vec::new();
|
||||||
@@ -439,7 +439,7 @@ impl RecoStore {
|
|||||||
.map_err(|e| format!("Failed to prepare windowed recent tracks query: {}", e))?;
|
.map_err(|e| format!("Failed to prepare windowed recent tracks query: {}", e))?;
|
||||||
|
|
||||||
let rows = stmt
|
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))?;
|
.map_err(|e| format!("Failed to query windowed recent tracks: {}", e))?;
|
||||||
|
|
||||||
let mut tracks = Vec::new();
|
let mut tracks = Vec::new();
|
||||||
@@ -467,7 +467,7 @@ impl RecoStore {
|
|||||||
.map_err(|e| format!("Failed to prepare favorite tracks query: {}", e))?;
|
.map_err(|e| format!("Failed to prepare favorite tracks query: {}", e))?;
|
||||||
|
|
||||||
let rows = stmt
|
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))?;
|
.map_err(|e| format!("Failed to query favorite tracks: {}", e))?;
|
||||||
|
|
||||||
let mut tracks = Vec::new();
|
let mut tracks = Vec::new();
|
||||||
@@ -542,7 +542,7 @@ impl RecoStore {
|
|||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![limit], |row| {
|
.query_map(params![limit], |row| {
|
||||||
Ok(TopArtistSeed {
|
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)?,
|
play_count: row.get::<_, u32>(1)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -576,7 +576,7 @@ impl RecoStore {
|
|||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to prepare known artists query: {}", e))?;
|
.map_err(|e| format!("Failed to prepare known artists query: {}", e))?;
|
||||||
let rows = stmt
|
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))?;
|
.map_err(|e| format!("Failed to query known artists: {}", e))?;
|
||||||
let mut ids = std::collections::HashSet::new();
|
let mut ids = std::collections::HashSet::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -604,7 +604,7 @@ impl RecoStore {
|
|||||||
.map_err(|e| format!("Failed to prepare top genres query: {}", e))?;
|
.map_err(|e| format!("Failed to prepare top genres query: {}", e))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![limit], |row| {
|
.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))?;
|
.map_err(|e| format!("Failed to query top genres: {}", e))?;
|
||||||
let mut genres = Vec::new();
|
let mut genres = Vec::new();
|
||||||
@@ -703,7 +703,7 @@ impl RecoStore {
|
|||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to prepare scored tracks query: {}", e))?;
|
.map_err(|e| format!("Failed to prepare scored tracks query: {}", e))?;
|
||||||
let rows = stmt
|
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))?;
|
.map_err(|e| format!("Failed to query scored tracks: {}", e))?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -729,7 +729,7 @@ impl RecoStore {
|
|||||||
.map_err(|e| format!("Failed to prepare scored artists query: {}", e))?;
|
.map_err(|e| format!("Failed to prepare scored artists query: {}", e))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![score_type, limit], |row| {
|
.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))?;
|
.map_err(|e| format!("Failed to query scored artists: {}", e))?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -761,10 +761,10 @@ impl RecoStore {
|
|||||||
Ok(RecoEventRecord {
|
Ok(RecoEventRecord {
|
||||||
event_type: row.get(0)?,
|
event_type: row.get(0)?,
|
||||||
item_type: row.get(1)?,
|
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)?,
|
album_id: row.get(3)?,
|
||||||
artist_id: row.get(4)?,
|
artist_id: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
|
||||||
genre_id: row.get(5)?,
|
genre_id: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
|
||||||
created_at: row.get(6)?,
|
created_at: row.get(6)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -808,9 +808,9 @@ impl RecoStore {
|
|||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
score_type,
|
score_type,
|
||||||
item_type,
|
item_type,
|
||||||
entry.track_id,
|
entry.track_id.map(|v| v as i64),
|
||||||
entry.album_id.as_deref(),
|
entry.album_id.as_deref(),
|
||||||
entry.artist_id,
|
entry.artist_id.map(|v| v as i64),
|
||||||
entry.score,
|
entry.score,
|
||||||
updated_at,
|
updated_at,
|
||||||
])
|
])
|
||||||
@@ -1071,7 +1071,7 @@ impl RecoStore {
|
|||||||
.conn
|
.conn
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE reco_events SET genre_id = ? WHERE album_id = ? AND genre_id IS NULL",
|
"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))?;
|
.map_err(|e| format!("Failed to update genre for album: {}", e))?;
|
||||||
Ok(affected as u64)
|
Ok(affected as u64)
|
||||||
@@ -1147,7 +1147,7 @@ mod tests {
|
|||||||
r#"INSERT INTO reco_events
|
r#"INSERT INTO reco_events
|
||||||
(event_type, item_type, track_id, album_id, artist_id, playlist_id, genre_id, created_at)
|
(event_type, item_type, track_id, album_id, artist_id, playlist_id, genre_id, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)"#,
|
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");
|
.expect("insert event at ts");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ impl RemoteControlSettingsStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open remote control settings database: {}", e))?;
|
.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| {
|
.map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to enable WAL for remote control settings database: {}",
|
"Failed to enable WAL for remote control settings database: {}",
|
||||||
@@ -305,7 +305,7 @@ impl AllowedOriginsStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open allowed origins database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to enable WAL for allowed origins database: {}", e))?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ScrobblerSettings {
|
pub struct ScrobblerSettings {
|
||||||
/// Master toggle. When false, the whole scrobblers section body is hidden.
|
/// Master toggle. When false, the whole scrobblers section body is hidden.
|
||||||
/// Default OFF — integrations are opt-in.
|
/// Default OFF — integrations are opt-in.
|
||||||
@@ -56,6 +56,26 @@ pub struct ScrobblerSettings {
|
|||||||
pub listenbrainz_token: String,
|
pub listenbrainz_token: String,
|
||||||
/// ListenBrainz username (`UserInfo.user_name`).
|
/// ListenBrainz username (`UserInfo.user_name`).
|
||||||
pub listenbrainz_username: String,
|
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 {
|
impl ScrobblerSettings {
|
||||||
@@ -78,6 +98,25 @@ impl ScrobblerSettings {
|
|||||||
pub fn listenbrainz_active(&self) -> bool {
|
pub fn listenbrainz_active(&self) -> bool {
|
||||||
self.enabled && self.listenbrainz_enabled && self.listenbrainz_is_authed()
|
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 {
|
pub struct ScrobblerSettingsStore {
|
||||||
@@ -93,7 +132,7 @@ impl ScrobblerSettingsStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open scrobbler settings database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to enable WAL for scrobbler settings database: {}", e))?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@@ -106,11 +145,20 @@ impl ScrobblerSettingsStore {
|
|||||||
lastfm_username TEXT NOT NULL DEFAULT '',
|
lastfm_username TEXT NOT NULL DEFAULT '',
|
||||||
listenbrainz_enabled INTEGER NOT NULL DEFAULT 0,
|
listenbrainz_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
listenbrainz_token TEXT NOT NULL DEFAULT '',
|
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))?;
|
.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(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO scrobbler_settings
|
"INSERT OR IGNORE INTO scrobbler_settings
|
||||||
(id, enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
|
(id, enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
|
||||||
@@ -135,7 +183,7 @@ impl ScrobblerSettingsStore {
|
|||||||
.query_row(
|
.query_row(
|
||||||
"SELECT enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
|
"SELECT enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
|
||||||
lastfm_username, listenbrainz_enabled, listenbrainz_token,
|
lastfm_username, listenbrainz_enabled, listenbrainz_token,
|
||||||
listenbrainz_username
|
listenbrainz_username, allow_logged_out_scrobbling
|
||||||
FROM scrobbler_settings WHERE id = 1",
|
FROM scrobbler_settings WHERE id = 1",
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -147,6 +195,7 @@ impl ScrobblerSettingsStore {
|
|||||||
let listenbrainz_enabled: i32 = row.get(5)?;
|
let listenbrainz_enabled: i32 = row.get(5)?;
|
||||||
let listenbrainz_token: String = row.get(6)?;
|
let listenbrainz_token: String = row.get(6)?;
|
||||||
let listenbrainz_username: String = row.get(7)?;
|
let listenbrainz_username: String = row.get(7)?;
|
||||||
|
let allow_logged_out_scrobbling: i32 = row.get(8)?;
|
||||||
Ok(ScrobblerSettings {
|
Ok(ScrobblerSettings {
|
||||||
enabled: enabled != 0,
|
enabled: enabled != 0,
|
||||||
ui_collapsed: ui_collapsed != 0,
|
ui_collapsed: ui_collapsed != 0,
|
||||||
@@ -156,6 +205,7 @@ impl ScrobblerSettingsStore {
|
|||||||
listenbrainz_enabled: listenbrainz_enabled != 0,
|
listenbrainz_enabled: listenbrainz_enabled != 0,
|
||||||
listenbrainz_token,
|
listenbrainz_token,
|
||||||
listenbrainz_username,
|
listenbrainz_username,
|
||||||
|
allow_logged_out_scrobbling: allow_logged_out_scrobbling != 0,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -182,6 +232,17 @@ impl ScrobblerSettingsStore {
|
|||||||
Ok(())
|
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 ---
|
// --- Last.fm ---
|
||||||
|
|
||||||
pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> {
|
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))
|
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> {
|
pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> {
|
||||||
self.with_store(|s| s.set_lastfm_enabled(value))
|
self.with_store(|s| s.set_lastfm_enabled(value))
|
||||||
}
|
}
|
||||||
@@ -363,6 +428,7 @@ mod tests {
|
|||||||
assert!(!s.listenbrainz_enabled);
|
assert!(!s.listenbrainz_enabled);
|
||||||
assert!(s.listenbrainz_token.is_empty());
|
assert!(s.listenbrainz_token.is_empty());
|
||||||
assert!(s.listenbrainz_username.is_empty());
|
assert!(s.listenbrainz_username.is_empty());
|
||||||
|
assert!(s.allow_logged_out_scrobbling);
|
||||||
assert!(!s.lastfm_is_authed());
|
assert!(!s.lastfm_is_authed());
|
||||||
assert!(!s.listenbrainz_is_authed());
|
assert!(!s.listenbrainz_is_authed());
|
||||||
assert!(!s.lastfm_active());
|
assert!(!s.lastfm_active());
|
||||||
@@ -375,6 +441,7 @@ mod tests {
|
|||||||
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
|
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
|
||||||
let s = store.get_settings().expect("get settings");
|
let s = store.get_settings().expect("get settings");
|
||||||
assert!(!s.enabled);
|
assert!(!s.enabled);
|
||||||
|
assert!(s.allow_logged_out_scrobbling);
|
||||||
assert!(!s.lastfm_is_authed());
|
assert!(!s.lastfm_is_authed());
|
||||||
assert!(!s.listenbrainz_is_authed());
|
assert!(!s.listenbrainz_is_authed());
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
@@ -387,6 +454,9 @@ mod tests {
|
|||||||
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
|
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
|
||||||
store.set_enabled(true).expect("enabled");
|
store.set_enabled(true).expect("enabled");
|
||||||
store.set_ui_collapsed(true).expect("collapsed");
|
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_enabled(true).expect("lfm enabled");
|
||||||
store
|
store
|
||||||
.set_lastfm_session(" sk-123 ", " alice ")
|
.set_lastfm_session(" sk-123 ", " alice ")
|
||||||
@@ -400,6 +470,7 @@ mod tests {
|
|||||||
let s = reopened.get_settings().expect("get settings");
|
let s = reopened.get_settings().expect("get settings");
|
||||||
assert!(s.enabled);
|
assert!(s.enabled);
|
||||||
assert!(s.ui_collapsed);
|
assert!(s.ui_collapsed);
|
||||||
|
assert!(!s.allow_logged_out_scrobbling);
|
||||||
assert!(s.lastfm_enabled);
|
assert!(s.lastfm_enabled);
|
||||||
// set_lastfm_session trims whitespace.
|
// set_lastfm_session trims whitespace.
|
||||||
assert_eq!(s.lastfm_session_key, "sk-123");
|
assert_eq!(s.lastfm_session_key, "sk-123");
|
||||||
@@ -412,6 +483,37 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(dir);
|
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]
|
#[test]
|
||||||
fn scrobbler_disconnect_keeps_enable_flags() {
|
fn scrobbler_disconnect_keeps_enable_flags() {
|
||||||
let dir = unique_test_dir("scrobbler-disconnect");
|
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.
|
/// 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.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -55,7 +55,7 @@ impl SubscriptionStateStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open subscription state database: {}", e))?;
|
.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| {
|
.map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to enable WAL for subscription state database: {}",
|
"Failed to enable WAL for subscription state database: {}",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ impl TraySettingsStore {
|
|||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("Failed to open tray settings database: {}", e))?;
|
.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))?;
|
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
|
|||||||
+153
-28
@@ -1,36 +1,35 @@
|
|||||||
//! Framework-agnostic application runtime facade.
|
//! Framework-agnostic application runtime facade.
|
||||||
//!
|
//!
|
||||||
//! [`AppRuntime`] is the composition root that a non-Tauri UI shell (Slint,
|
//! [`AppRuntime`] is the composition root that a native UI shell (Qt, TUI,
|
||||||
//! TUI, headless) builds on. It owns an `Arc<QbzCore<A>>`, the framework-
|
//! headless) builds on. It owns an `Arc<QbzCore<A>>`, the framework-
|
||||||
//! agnostic runtime state machine, and the per-user session, all without any
|
//! agnostic runtime state machine, and the per-user session, all without any
|
||||||
//! Tauri dependency.
|
//! Tauri dependency.
|
||||||
//!
|
//!
|
||||||
//! Scope (Slint POC readiness audit, sessions 21-22):
|
//! Session activation is deliberately minimal: it opens the session store and
|
||||||
//!
|
//! performs the portable scaffolding (user paths, directories, last-user
|
||||||
//! - Task 1 — composition and accessors: [`AppRuntime::new`], [`AppRuntime::core`].
|
//! marker, runtime state). The shell binds its additional per-user stores as
|
||||||
//! - Task 2 — minimal session activation: [`AppRuntime::activate`] and
|
//! each session comes online.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap};
|
use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap};
|
||||||
use qbz_core::{FrontendAdapter, QbzCore};
|
use qbz_core::{FrontendAdapter, QbzCore};
|
||||||
|
use qbz_offline_cache::OfflineCacheState;
|
||||||
use qbz_player::Player;
|
use qbz_player::Player;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::runtime::RuntimeManager;
|
use crate::runtime::RuntimeManager;
|
||||||
use crate::session_store::SessionStore;
|
use crate::session_store::SessionStore;
|
||||||
|
use crate::subscription_lifecycle;
|
||||||
use crate::user_data::UserDataPaths;
|
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.
|
/// The per-user stores opened for the currently active session.
|
||||||
///
|
///
|
||||||
/// Minimal by design: it holds only the session store. A shell opens further
|
/// 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.
|
/// loading the full WebKit-era store set up front.
|
||||||
struct ActiveSession {
|
struct ActiveSession {
|
||||||
user_id: u64,
|
user_id: u64,
|
||||||
|
data_dir: PathBuf,
|
||||||
session_store: SessionStore,
|
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.
|
/// Composition root for a non-Tauri UI shell.
|
||||||
///
|
///
|
||||||
/// Generic over the [`FrontendAdapter`] the shell supplies, so the same
|
/// 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
|
/// the FFT producer and toggle capture. `None` for shells that do not drive
|
||||||
/// audio visualization (the default `new`/`with_audio_settings` path).
|
/// audio visualization (the default `new`/`with_audio_settings` path).
|
||||||
visualizer_tap: Option<VisualizerTap>,
|
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> {
|
impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||||
@@ -76,6 +85,8 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
|||||||
user_paths: UserDataPaths::new(),
|
user_paths: UserDataPaths::new(),
|
||||||
session: Mutex::new(None),
|
session: Mutex::new(None),
|
||||||
visualizer_tap: 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
|
/// Build like [`AppRuntime::new`], but also wire a [`VisualizerTap`] into the
|
||||||
/// player and retain it so the shell can start the frontend-agnostic FFT
|
/// player and retain it so the shell can start the frontend-agnostic FFT
|
||||||
/// producer ([`qbz_audio::visualizer::spawn_visualizer_thread`]) and toggle
|
/// producer ([`qbz_audio::visualizer::spawn_visualizer_thread`]) and toggle
|
||||||
/// capture via the tap's `set_enabled`. Used by the Slint shell for the
|
/// capture via the tap's `set_enabled`. Used by the Qt shell for the
|
||||||
/// ImmersiveView audio visualizers. The tap starts disabled, so it adds no
|
/// immersive-view audio visualizers. The tap starts disabled, so it adds
|
||||||
/// runtime cost until the immersive view enables it.
|
/// no runtime cost until the immersive view enables it.
|
||||||
pub fn with_visualizer(adapter: A) -> Self {
|
pub fn with_visualizer(adapter: A) -> Self {
|
||||||
let (device_name, audio_settings) = AudioSettingsStore::new()
|
let (device_name, audio_settings) = AudioSettingsStore::new()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -174,19 +185,106 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
|||||||
|
|
||||||
self.runtime.set_session_activated(true, user_id).await;
|
self.runtime.set_session_activated(true, user_id).await;
|
||||||
|
|
||||||
let mut guard = self
|
{
|
||||||
.session
|
let mut guard = self
|
||||||
.lock()
|
.session
|
||||||
.map_err(|e| format!("session lock poisoned: {}", e))?;
|
.lock()
|
||||||
*guard = Some(ActiveSession {
|
.map_err(|e| format!("session lock poisoned: {}", e))?;
|
||||||
user_id,
|
*guard = Some(ActiveSession {
|
||||||
session_store,
|
user_id,
|
||||||
});
|
data_dir: data_dir.to_path_buf(),
|
||||||
|
session_store,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("[AppRuntime] Session activated for user");
|
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(())
|
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`.
|
/// Activate the per-user session for `user_id`.
|
||||||
///
|
///
|
||||||
/// Resolves the real per-user directories through [`UserDataPaths`],
|
/// 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
|
/// `last_user_id` marker is intentionally kept on disk so a later
|
||||||
/// offline session can still find the user's data.
|
/// offline session can still find the user's data.
|
||||||
pub async fn deactivate(&self) -> Result<(), String> {
|
pub async fn deactivate(&self) -> Result<(), String> {
|
||||||
|
self.stop_subscription_ticker();
|
||||||
{
|
{
|
||||||
let mut guard = self
|
let mut guard = self
|
||||||
.session
|
.session
|
||||||
@@ -318,6 +417,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn test_runtime() -> AppRuntime<NoOpAdapter> {
|
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)
|
AppRuntime::with_audio_settings(NoOpAdapter, None, AudioSettings::default(), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +469,27 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&cache_dir);
|
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]
|
#[tokio::test]
|
||||||
async fn deactivate_clears_session_and_runtime() {
|
async fn deactivate_clears_session_and_runtime() {
|
||||||
let rt = test_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()
|
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> {
|
fn last_user_id_path() -> Result<PathBuf, String> {
|
||||||
let dir = Self::global_data_dir()?;
|
let dir = Self::global_data_dir()?;
|
||||||
Ok(dir.join("last_user_id"))
|
Ok(dir.join("last_user_id"))
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ rustfft = "6.4"
|
|||||||
spectrum-analyzer = "1.5"
|
spectrum-analyzer = "1.5"
|
||||||
|
|
||||||
# Database for loudness cache
|
# Database for loudness cache
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
|
|
||||||
# System directories
|
# System directories
|
||||||
dirs = "6"
|
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
|
# 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.
|
# required because of the `links = "alsa"` constraint shared with cpal.
|
||||||
alsa-sys = "0.4"
|
alsa-sys = "0.4"
|
||||||
|
libc = "0.2"
|
||||||
# zbus for org.freedesktop.ReserveDevice1 D-Bus client (device reservation).
|
# zbus for org.freedesktop.ReserveDevice1 D-Bus client (device reservation).
|
||||||
# Pinned to v4 to share the transitive copy already pulled in by
|
# Pinned to v4 to share the transitive copy already pulled in by
|
||||||
# secret-service/souvlaki and avoid duplicate zbus compiles.
|
# 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 for device probing, sample rate switching, and (future) hog mode / exclusive mode
|
||||||
coreaudio-rs = "0.14.1"
|
coreaudio-rs = "0.14.1"
|
||||||
objc2-core-audio = "0.3.2"
|
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
|
/// 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
|
/// stop/teardown. Call it once the exclusive device has actually been released
|
||||||
/// so the rest of the system can use the sink again.
|
/// 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() {
|
let target = match SUSPENDED_SINK.lock() {
|
||||||
Ok(mut guard) => guard.take(),
|
Ok(guard) => guard.clone(),
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
if let Some(sink) = target {
|
let Some(sink) = target else {
|
||||||
let _ = std::process::Command::new("pactl")
|
return Ok(());
|
||||||
.args(["suspend-sink", &sink, "0"])
|
};
|
||||||
.output();
|
|
||||||
log::info!("[ALSA Backend] Resumed PipeWire sink '{}'", sink);
|
// 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
|
/// Common audio sample rates to check for device support
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! This module bypasses rodio/CPAL completely for direct hardware access.
|
//! This module bypasses rodio/CPAL completely for direct hardware access.
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use alsa::pcm::{Access, Format, HwParams, PCM};
|
use alsa::pcm::{Access, Format, Frames, HwParams, PCM};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use alsa::{Direction, ValueOr};
|
use alsa::{Direction, ValueOr};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -113,6 +113,18 @@ pub struct AlsaDirectStream {
|
|||||||
device_id: String,
|
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.
|
/// Defensive settle delay between reservation acquisition and PCM open.
|
||||||
///
|
///
|
||||||
/// Only applied when the reservation actually transitioned ownership (i.e.
|
/// 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
|
// immediately before a real `PCM::new()` and held for as long as that
|
||||||
// PCM is open.
|
// PCM is open.
|
||||||
// TODO(Task 5): replace second arg with user-facing DAC name from settings.
|
// TODO(Task 5): replace second arg with user-facing DAC name from settings.
|
||||||
let reservation =
|
let reservation = crate::DeviceReservation::acquire(device_id, device_id)
|
||||||
crate::DeviceReservation::acquire(device_id, device_id).map_err(|e| {
|
.map_err(|e| format!("Cannot acquire exclusive device '{}': {}", device_id, e))?;
|
||||||
format!(
|
|
||||||
"Cannot acquire exclusive device '{}': {}",
|
|
||||||
device_id, e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Defensive margin only matters when the reservation actually displaced
|
// Defensive margin only matters when the reservation actually displaced
|
||||||
// a holder (or could have). On the degraded D-Bus path the bus name is
|
// 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)
|
// Set buffer size (larger buffer for high-res audio)
|
||||||
let buffer_size = if sample_rate >= 192000 {
|
let buffer_size = if sample_rate >= 192000 {
|
||||||
// 500ms buffer for 192kHz+ (like MPD config)
|
// 500ms buffer for 192kHz+ (like MPD config)
|
||||||
(sample_rate / 2) as i64
|
(sample_rate / 2) as Frames
|
||||||
} else if sample_rate >= 96000 {
|
} else if sample_rate >= 96000 {
|
||||||
// 250ms buffer for 96kHz
|
// 250ms buffer for 96kHz
|
||||||
(sample_rate / 4) as i64
|
(sample_rate / 4) as Frames
|
||||||
} else {
|
} else {
|
||||||
// 125ms buffer for lower rates
|
// 125ms buffer for lower rates
|
||||||
(sample_rate / 8) as i64
|
(sample_rate / 8) as Frames
|
||||||
};
|
};
|
||||||
|
|
||||||
hwp.set_buffer_size_near(buffer_size)
|
hwp.set_buffer_size_near(buffer_size)
|
||||||
@@ -300,11 +307,11 @@ impl AlsaDirectStream {
|
|||||||
hwp.set_rate(carrier_rate, ValueOr::Nearest)
|
hwp.set_rate(carrier_rate, ValueOr::Nearest)
|
||||||
.map_err(|e| format!("Failed to set DoP carrier rate {}: {}", carrier_rate, e))?;
|
.map_err(|e| format!("Failed to set DoP carrier rate {}: {}", carrier_rate, e))?;
|
||||||
let buffer_size = if carrier_rate >= 192000 {
|
let buffer_size = if carrier_rate >= 192000 {
|
||||||
(carrier_rate / 2) as i64
|
(carrier_rate / 2) as Frames
|
||||||
} else if carrier_rate >= 96000 {
|
} else if carrier_rate >= 96000 {
|
||||||
(carrier_rate / 4) as i64
|
(carrier_rate / 4) as Frames
|
||||||
} else {
|
} else {
|
||||||
(carrier_rate / 8) as i64
|
(carrier_rate / 8) as Frames
|
||||||
};
|
};
|
||||||
hwp.set_buffer_size_near(buffer_size)
|
hwp.set_buffer_size_near(buffer_size)
|
||||||
.map_err(|e| format!("Failed to set buffer size: {}", e))?;
|
.map_err(|e| format!("Failed to set buffer size: {}", e))?;
|
||||||
@@ -380,15 +387,13 @@ impl AlsaDirectStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Some((format, le)) = selected else {
|
let Some((format, le)) = selected else {
|
||||||
return Err(
|
return Err("Device has no native DSD format (kernel quirk missing?)".to_string());
|
||||||
"Device has no native DSD format (kernel quirk missing?)".to_string()
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
hwp.set_channels(channels as u32)
|
hwp.set_channels(channels as u32)
|
||||||
.map_err(|e| format!("Failed to set channels: {}", e))?;
|
.map_err(|e| format!("Failed to set channels: {}", e))?;
|
||||||
hwp.set_rate(rate, ValueOr::Nearest)
|
hwp.set_rate(rate, ValueOr::Nearest)
|
||||||
.map_err(|e| format!("Failed to set native DSD rate {}: {}", rate, e))?;
|
.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)
|
hwp.set_buffer_size_near(buffer_size)
|
||||||
.map_err(|e| format!("Failed to set buffer size: {}", e))?;
|
.map_err(|e| format!("Failed to set buffer size: {}", e))?;
|
||||||
hwp.set_period_size_near(buffer_size / 10, ValueOr::Nearest)
|
hwp.set_period_size_near(buffer_size / 10, ValueOr::Nearest)
|
||||||
@@ -441,7 +446,11 @@ impl AlsaDirectStream {
|
|||||||
match io.writei(samples) {
|
match io.writei(samples) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial DoP write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial DoP write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -852,7 +861,10 @@ impl AlsaDirectStream {
|
|||||||
// libc::EBADFD — the PCM was not in a running-ish state.
|
// libc::EBADFD — the PCM was not in a running-ish state.
|
||||||
const EBADFD: i32 = 77;
|
const EBADFD: i32 = 77;
|
||||||
if e.errno() as i32 != EBADFD {
|
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()
|
pcm.prepare()
|
||||||
@@ -869,61 +881,37 @@ impl AlsaDirectStream {
|
|||||||
self.channels
|
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
|
/// Get device ID
|
||||||
pub fn device_id(&self) -> &str {
|
pub fn device_id(&self) -> &str {
|
||||||
&self.device_id
|
&self.device_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to set hardware volume via ALSA mixer
|
/// Try to set hardware volume via ALSA mixer.
|
||||||
///
|
///
|
||||||
/// Returns error if:
|
/// Returns error if:
|
||||||
/// - DAC doesn't have mixer controls (common for USB DACs)
|
/// - DAC doesn't have mixer controls (common for USB DACs)
|
||||||
/// - Mixer API fails
|
/// - Mixer API fails
|
||||||
///
|
///
|
||||||
/// NOTE: Failure doesn't break playback, just means volume can't be controlled.
|
/// NOTE: Failure doesn't break playback, just means volume can't be controlled.
|
||||||
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
|
pub fn set_hardware_volume(
|
||||||
use alsa::mixer::SelemChannelId::*;
|
&self,
|
||||||
use alsa::mixer::{Mixer, SelemId};
|
control: &crate::alsa_hardware_volume::AlsaMixerControlId,
|
||||||
|
volume: f32,
|
||||||
// Open mixer for device
|
) -> Result<(), String> {
|
||||||
let mixer = Mixer::new(&self.device_id, false)
|
crate::alsa_hardware_volume::set_hardware_volume(&self.device_id, control, volume)
|
||||||
.map_err(|e| format!("Failed to open mixer for {}: {}", self.device_id, e))?;
|
.map(|_| ())
|
||||||
|
|
||||||
// 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
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if device is a bit-perfect hardware device
|
/// 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"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
impl AlsaDirectStream {
|
impl AlsaDirectStream {
|
||||||
pub fn new(_device_id: &str, _sample_rate: u32, _channels: u16) -> Result<Self, String> {
|
pub fn new(_device_id: &str, _sample_rate: u32, _channels: u16) -> Result<Self, String> {
|
||||||
@@ -965,8 +994,118 @@ impl AlsaDirectStream {
|
|||||||
2
|
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)
|
/// Check if device is a bit-perfect hardware device (always false on non-Linux)
|
||||||
pub fn is_hw_device(_device_id: &str) -> bool {
|
pub fn is_hw_device(_device_id: &str) -> bool {
|
||||||
false
|
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.
|
/// Messages sent from the audio pipeline to the loudness analyzer thread.
|
||||||
pub enum AnalyzerMessage {
|
pub enum AnalyzerMessage {
|
||||||
/// A batch of interleaved f32 samples.
|
/// A batch of interleaved f32 samples with its absolute track frame.
|
||||||
Samples(Vec<f32>),
|
Samples { start_frame: u64, samples: Vec<f32> },
|
||||||
/// A new track has started — reset analyzer state.
|
/// The source reached its first audible frame. Emitting this from the tap,
|
||||||
NewTrack {
|
/// rather than when a gapless source is queued, preserves the true track
|
||||||
track_id: u64,
|
/// boundary inside one output stream for both analyzers.
|
||||||
sample_rate: u32,
|
NewTrack(AnalyzerWaveformTrack),
|
||||||
channels: u16,
|
|
||||||
target_lufs: f32,
|
|
||||||
/// Shared gain atomic — analyzer writes, DynamicAmplify reads.
|
|
||||||
gain_atomic: Arc<AtomicU32>,
|
|
||||||
},
|
|
||||||
/// Seek occurred — reset accumulated samples but keep current gain.
|
/// Seek occurred — reset accumulated samples but keep current gain.
|
||||||
Reset,
|
Reset,
|
||||||
/// Shut down the analyzer thread.
|
/// Shut down the analyzer thread.
|
||||||
Shutdown,
|
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;
|
const BATCH_SIZE: usize = 4096;
|
||||||
|
|
||||||
pub struct AnalyzerTap<S>
|
pub struct AnalyzerTap<S>
|
||||||
@@ -41,6 +48,11 @@ where
|
|||||||
sender: SyncSender<AnalyzerMessage>,
|
sender: SyncSender<AnalyzerMessage>,
|
||||||
enabled: Arc<AtomicBool>,
|
enabled: Arc<AtomicBool>,
|
||||||
buffer: Vec<f32>,
|
buffer: Vec<f32>,
|
||||||
|
channels: u16,
|
||||||
|
sample_cursor: u64,
|
||||||
|
batch_start_frame: u64,
|
||||||
|
waveform_track: Option<AnalyzerWaveformTrack>,
|
||||||
|
waveform_boundary_sent: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S> AnalyzerTap<S>
|
impl<S> AnalyzerTap<S>
|
||||||
@@ -48,22 +60,87 @@ where
|
|||||||
S: Source<Item = f32>,
|
S: Source<Item = f32>,
|
||||||
{
|
{
|
||||||
pub fn new(source: S, sender: SyncSender<AnalyzerMessage>, enabled: Arc<AtomicBool>) -> Self {
|
pub fn new(source: S, sender: SyncSender<AnalyzerMessage>, enabled: Arc<AtomicBool>) -> Self {
|
||||||
|
let channels = source.channels().get();
|
||||||
Self {
|
Self {
|
||||||
inner: source,
|
inner: source,
|
||||||
sender,
|
sender,
|
||||||
enabled,
|
enabled,
|
||||||
buffer: Vec::with_capacity(BATCH_SIZE),
|
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]
|
#[inline]
|
||||||
fn flush_if_full(&mut self) {
|
fn flush_if_full(&mut self) {
|
||||||
if self.buffer.len() >= BATCH_SIZE {
|
// Keep every message frame-aligned. 4096 is not divisible by every
|
||||||
let batch = std::mem::replace(&mut self.buffer, Vec::with_capacity(BATCH_SIZE));
|
// legal channel count (notably 3), and splitting an interleaved frame
|
||||||
// Non-blocking send — drop batch if channel is full
|
// would shift both RMS analyzers at the next batch boundary.
|
||||||
let _ = self.sender.try_send(AnalyzerMessage::Samples(batch));
|
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>
|
impl<S> Iterator for AnalyzerTap<S>
|
||||||
@@ -74,11 +151,23 @@ where
|
|||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
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.buffer.push(sample);
|
||||||
|
self.sample_cursor = self.sample_cursor.saturating_add(1);
|
||||||
self.flush_if_full();
|
self.flush_if_full();
|
||||||
|
} else {
|
||||||
|
self.sample_cursor = self.sample_cursor.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(sample)
|
Some(sample)
|
||||||
@@ -114,3 +203,88 @@ where
|
|||||||
self.inner.total_duration()
|
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.
|
/// bit-perfect ALSA-exclusive / DAC-passthrough paths.
|
||||||
Jack,
|
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)
|
/// System default backend (non-Linux platforms)
|
||||||
/// - Uses CPAL default host (CoreAudio on macOS, WASAPI on Windows)
|
/// - Uses CPAL default host (CoreAudio on macOS, WASAPI on Windows)
|
||||||
/// - Automatic device selection via OS audio system
|
/// - Automatic device selection via OS audio system
|
||||||
@@ -308,6 +318,14 @@ impl BackendManager {
|
|||||||
backends.push(AudioBackendType::SystemDefault);
|
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
|
backends
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +351,14 @@ impl BackendManager {
|
|||||||
Ok(Box::new(CpalDefaultBackend::new()?))
|
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 => {
|
AudioBackendType::SystemDefault => {
|
||||||
// "System": play through the OS default output via CPAL's default
|
// "System": play through the OS default output via CPAL's default
|
||||||
// host — CoreAudio/WASAPI off-Linux, the ALSA "default" PCM on Linux
|
// 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
|
// (e.g. a non-default socket name, or Flatpak where the socket path
|
||||||
// differs but the pulse shim is bridged).
|
// differs but the pulse shim is bridged).
|
||||||
if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,6 +482,20 @@ impl AudioBackend for CpalDefaultBackend {
|
|||||||
.map(|desc| desc.name().to_string())
|
.map(|desc| desc.name().to_string())
|
||||||
.unwrap_or_else(|_| "Default Output".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();
|
let mut devices = Vec::new();
|
||||||
for device in self
|
for device in self
|
||||||
.host
|
.host
|
||||||
@@ -463,23 +506,92 @@ impl AudioBackend for CpalDefaultBackend {
|
|||||||
.description()
|
.description()
|
||||||
.map(|desc| desc.name().to_string())
|
.map(|desc| desc.name().to_string())
|
||||||
.unwrap_or_else(|_| "Unknown Device".to_string());
|
.unwrap_or_else(|_| "Unknown Device".to_string());
|
||||||
let is_default = name == default_name;
|
|
||||||
|
|
||||||
// On macOS, probe device capabilities via CoreAudio
|
// On macOS, probe device capabilities via CoreAudio
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let (supported_rates, max_rate, bus_type, is_hw) = { Self::probe_macos_device(&name) };
|
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): (
|
let (supported_rates, max_rate, bus_type, is_hw): (
|
||||||
Option<Vec<u32>>,
|
Option<Vec<u32>>,
|
||||||
Option<u32>,
|
Option<u32>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
bool,
|
bool,
|
||||||
) = (None, None, None, false);
|
) = (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 {
|
devices.push(AudioDevice {
|
||||||
id: name.clone(),
|
id,
|
||||||
name,
|
name: display,
|
||||||
description: None,
|
description,
|
||||||
is_default,
|
is_default,
|
||||||
max_sample_rate: max_rate,
|
max_sample_rate: max_rate,
|
||||||
supported_sample_rates: supported_rates,
|
supported_sample_rates: supported_rates,
|
||||||
@@ -560,6 +672,14 @@ impl AudioBackend for CpalDefaultBackend {
|
|||||||
.output_devices()
|
.output_devices()
|
||||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||||
.find(|d| {
|
.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()
|
d.description()
|
||||||
.map(|desc| desc.name() == device_id.as_str())
|
.map(|desc| desc.name() == device_id.as_str())
|
||||||
.unwrap_or(false)
|
.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
|
/// 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).
|
/// as nominal (matches the prior Tauri behavior; do not present it as measured).
|
||||||
fn nominal_formats() -> Vec<String> {
|
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,
|
/// Pure assembly (testable): combine detected rates + description into the DTO,
|
||||||
@@ -64,16 +68,15 @@ fn assemble(
|
|||||||
/// Read-only — reads `/proc/asound` and runs `pw-dump`; never opens a stream.
|
/// Read-only — reads `/proc/asound` and runs `pw-dump`; never opens a stream.
|
||||||
pub fn query_dac_capabilities(node_name: &str) -> DacCapabilities {
|
pub fn query_dac_capabilities(node_name: &str) -> DacCapabilities {
|
||||||
// Pretty description from robust (pw-dump-backed, Slice 0) enumeration.
|
// Pretty description from robust (pw-dump-backed, Slice 0) enumeration.
|
||||||
let description = crate::backend::BackendManager::create_backend(
|
let description =
|
||||||
crate::backend::AudioBackendType::PipeWire,
|
crate::backend::BackendManager::create_backend(crate::backend::AudioBackendType::PipeWire)
|
||||||
)
|
.ok()
|
||||||
.ok()
|
.and_then(|b| b.enumerate_devices().ok())
|
||||||
.and_then(|b| b.enumerate_devices().ok())
|
.and_then(|devs| {
|
||||||
.and_then(|devs| {
|
devs.into_iter()
|
||||||
devs.into_iter()
|
.find(|d| d.id == node_name || d.name == node_name)
|
||||||
.find(|d| d.id == node_name || d.name == node_name)
|
})
|
||||||
})
|
.map(|d| d.description.unwrap_or(d.name));
|
||||||
.map(|d| d.description.unwrap_or(d.name));
|
|
||||||
|
|
||||||
// Real sample rates: PipeWire sink -> ALSA card -> /proc/asound, with an
|
// Real sample rates: PipeWire sink -> ALSA card -> /proc/asound, with an
|
||||||
// ALSA-direct fallback. Hardware-only; non-Linux gets the fallback set.
|
// ALSA-direct fallback. Hardware-only; non-Linux gets the fallback set.
|
||||||
@@ -92,7 +95,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn uses_detected_rates_when_present() {
|
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.sample_rates, vec![44100, 96000, 192000]);
|
||||||
assert_eq!(caps.description.as_deref(), Some("My DAC"));
|
assert_eq!(caps.description.as_deref(), Some("My DAC"));
|
||||||
assert_eq!(caps.channels, Some(2));
|
assert_eq!(caps.channels, Some(2));
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
//! bit-perfect / sample-rate-passthrough path is untouched.
|
//! bit-perfect / sample-rate-passthrough path is untouched.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
// Every target but Windows still runs `pw-dump`.
|
||||||
|
#[cfg(not(windows))]
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// The DAC's live, negotiated hardware state — what the card is REALLY clocked
|
/// 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`.
|
/// 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> {
|
fn alsa_card_for_node(node_name: &str) -> Option<u32> {
|
||||||
let output = Command::new("pw-dump").output().ok()?;
|
let output = Command::new("pw-dump").output().ok()?;
|
||||||
if !output.status.success() {
|
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
|
/// description collapses the wrappers and keeps the genuinely distinct
|
||||||
/// outputs.
|
/// outputs.
|
||||||
fn dedup_key(display: &str) -> String {
|
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
|
/// 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.
|
// null appears FIRST in the raw list but must be emitted last.
|
||||||
let out = run(&[
|
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"),
|
("front:CARD=PCH,DEV=0", "HDA Intel PCH, ALC3254 Analog"),
|
||||||
]);
|
]);
|
||||||
let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect();
|
let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect();
|
||||||
@@ -173,12 +183,21 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn keeps_genuinely_distinct_outputs() {
|
fn keeps_genuinely_distinct_outputs() {
|
||||||
let out = run(&[
|
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"),
|
("front:CARD=PCH,DEV=0", "HDA Intel PCH, ALC3254 Analog"),
|
||||||
("iec958:CARD=PCH,DEV=1", "HDA Intel PCH, ALC3254 Digital"),
|
("iec958:CARD=PCH,DEV=1", "HDA Intel PCH, ALC3254 Digital"),
|
||||||
("hdmi:CARD=PCH,DEV=3", "HDA Intel PCH, HDMI 0"),
|
("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();
|
let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -197,7 +216,10 @@ mod tests {
|
|||||||
fn passes_pipewire_node_names_through() {
|
fn passes_pipewire_node_names_through() {
|
||||||
let out = run(&[
|
let out = run(&[
|
||||||
("alsa_output.usb-Cambridge", "alsa_output.usb-Cambridge"),
|
("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);
|
assert_eq!(out.len(), 2);
|
||||||
}
|
}
|
||||||
@@ -205,11 +227,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn first_seen_order_is_preserved() {
|
fn first_seen_order_is_preserved() {
|
||||||
let out = run(&[
|
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"),
|
("default", "Default ALSA Output"),
|
||||||
// Better-ranked id for Cambridge appears later; it wins the group
|
// Better-ranked id for Cambridge appears later; it wins the group
|
||||||
// but the group keeps its first-seen position (before Default).
|
// 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();
|
let ids: Vec<&str> = out.iter().map(|r| r.0.as_str()).collect();
|
||||||
assert_eq!(ids, vec!["front:CARD=C20,DEV=0", "default"]);
|
assert_eq!(ids, vec!["front:CARD=C20,DEV=0", "default"]);
|
||||||
|
|||||||
@@ -311,9 +311,8 @@ fn try_acquire_name(
|
|||||||
bus_name: &str,
|
bus_name: &str,
|
||||||
replace: bool,
|
replace: bool,
|
||||||
) -> Result<RequestNameReply, ReservationError> {
|
) -> Result<RequestNameReply, ReservationError> {
|
||||||
let proxy = DBusProxy::new(conn).map_err(|e| {
|
let proxy = DBusProxy::new(conn)
|
||||||
ReservationError::DbusError(format!("DBusProxy::new failed: {}", e))
|
.map_err(|e| ReservationError::DbusError(format!("DBusProxy::new failed: {}", e)))?;
|
||||||
})?;
|
|
||||||
let well_known: WellKnownName<'_> = bus_name.try_into().map_err(|e| {
|
let well_known: WellKnownName<'_> = bus_name.try_into().map_err(|e| {
|
||||||
ReservationError::DbusError(format!("invalid bus name '{}': {}", bus_name, 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
|
// 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
|
// extra GetAll round trip on some bus daemons; one proxy is both faster
|
||||||
// and clearer.
|
// and clearer.
|
||||||
let holder_proxy = open_holder_proxy(conn, bus_name, object_path).map_err(|e| {
|
let holder_proxy = open_holder_proxy(conn, bus_name, object_path)
|
||||||
ReservationError::DbusError(format!("Proxy::new for holder failed: {}", e))
|
.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.
|
// Default to 0 if the holder is uncooperative or doesn't expose Priority.
|
||||||
// Rationale: PulseAudio/PipeWire are the most common holders and run at
|
// Rationale: PulseAudio/PipeWire are the most common holders and run at
|
||||||
@@ -681,10 +679,7 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
match parse_card_index(s) {
|
match parse_card_index(s) {
|
||||||
Ok(_) | Err(ReservationError::InvalidDevice(_)) => {}
|
Ok(_) | Err(ReservationError::InvalidDevice(_)) => {}
|
||||||
Err(other) => panic!(
|
Err(other) => panic!("unexpected error variant for {:?}: {:?}", s, other),
|
||||||
"unexpected error variant for {:?}: {:?}",
|
|
||||||
s, other
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! the check step can show the right `apt`/`dnf`/`pacman` remediation. None of
|
//! the check step can show the right `apt`/`dnf`/`pacman` remediation. None of
|
||||||
//! this opens a stream — purely diagnostic.
|
//! this opens a stream — purely diagnostic.
|
||||||
|
|
||||||
|
// Every target but Windows still probes; see `audio_stack_health`.
|
||||||
|
#[cfg(not(windows))]
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Result of the audio-stack probes. All best-effort; a failed probe reads as
|
/// 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 {
|
fn sh_ok(probe: &str) -> bool {
|
||||||
Command::new("sh")
|
Command::new("sh")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
@@ -44,6 +47,31 @@ fn sh_ok(probe: &str) -> bool {
|
|||||||
|
|
||||||
/// Run the audio-stack probes. Linux-only meaningfully; elsewhere everything
|
/// Run the audio-stack probes. Linux-only meaningfully; elsewhere everything
|
||||||
/// reads false except where trivially true.
|
/// 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 {
|
pub fn audio_stack_health() -> AudioStackHealth {
|
||||||
// systemd path first; fall back to a process check so non-systemd inits
|
// systemd path first; fall back to a process check so non-systemd inits
|
||||||
// (Gentoo/OpenRC, Void/runit) don't read as "WirePlumber down".
|
// (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
|
// os-release values may be bare, double-quoted, or single-quoted
|
||||||
// (Gentoo uses single quotes), so strip both quote styles.
|
// (Gentoo uses single quotes), so strip both quote styles.
|
||||||
if let Some(v) = line.strip_prefix("ID=") {
|
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=") {
|
} 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);
|
let hay = format!("{} {}", id, id_like);
|
||||||
@@ -315,7 +349,10 @@ mod tests {
|
|||||||
fn classifies_fedora_arch_suse_gentoo_void_other() {
|
fn classifies_fedora_arch_suse_gentoo_void_other() {
|
||||||
assert_eq!(parse_distro("ID=fedora\n"), Distro::Fedora);
|
assert_eq!(parse_distro("ID=fedora\n"), Distro::Fedora);
|
||||||
assert_eq!(parse_distro("ID=arch\n"), Distro::Arch);
|
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);
|
assert_eq!(parse_distro("ID=gentoo\n"), Distro::Gentoo);
|
||||||
// Gentoo's real os-release single-quotes the value.
|
// Gentoo's real os-release single-quotes the value.
|
||||||
assert_eq!(parse_distro("ID='gentoo'\n"), Distro::Gentoo);
|
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
|
/// Open the JACK client, register stable stereo ports, activate, and
|
||||||
/// auto-connect to the system's physical playback.
|
/// auto-connect to the system's physical playback.
|
||||||
pub fn new(channels: u16) -> Result<Self, String> {
|
pub fn new(channels: u16) -> Result<Self, String> {
|
||||||
let (client, _status) = Client::new("qbz", ClientOptions::NO_START_SERVER)
|
let (client, _status) =
|
||||||
.map_err(|e| format!("JACK client open failed (is a JACK/pipewire-jack server running?): {e}"))?;
|
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;
|
let sample_rate = client.sample_rate() as u32;
|
||||||
|
|
||||||
@@ -123,7 +125,8 @@ impl JackStream {
|
|||||||
}
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"[JACK] auto-connected qbz:out_FL/FR -> {} / {}",
|
"[JACK] auto-connected qbz:out_FL/FR -> {} / {}",
|
||||||
playback[0], playback[1]
|
playback[0],
|
||||||
|
playback[1]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
|
|||||||
+40
-21
@@ -26,33 +26,39 @@
|
|||||||
//! └───────────────┘
|
//! └───────────────┘
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod backend;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub mod pipewire_backend;
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod alsa_backend;
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod alsa_error_handler;
|
pub mod alsa_error_handler;
|
||||||
#[cfg(target_os = "linux")]
|
pub mod analysis;
|
||||||
pub mod pulse_backend;
|
pub mod analyzer_tap;
|
||||||
#[cfg(target_os = "linux")]
|
pub mod backend;
|
||||||
pub mod jack_backend;
|
|
||||||
pub mod alsa_direct;
|
|
||||||
pub mod coreaudio_direct;
|
pub mod coreaudio_direct;
|
||||||
pub mod dac_capabilities;
|
pub mod dac_capabilities;
|
||||||
pub mod dac_probe;
|
pub mod dac_probe;
|
||||||
pub mod health;
|
|
||||||
pub mod analysis;
|
|
||||||
pub mod analyzer_tap;
|
|
||||||
pub mod device_filter;
|
pub mod device_filter;
|
||||||
pub mod device_reservation;
|
pub mod device_reservation;
|
||||||
pub mod diagnostic;
|
pub mod diagnostic;
|
||||||
pub mod dynamic_amplify;
|
pub mod dynamic_amplify;
|
||||||
|
pub mod health;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod jack_backend;
|
||||||
pub mod loudness;
|
pub mod loudness;
|
||||||
pub mod loudness_analyzer;
|
pub mod loudness_analyzer;
|
||||||
pub mod loudness_cache;
|
pub mod loudness_cache;
|
||||||
pub mod network_throttle;
|
pub mod network_throttle;
|
||||||
pub mod output_sinks;
|
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 settings;
|
||||||
pub mod visualizer;
|
pub mod visualizer;
|
||||||
|
|
||||||
@@ -63,10 +69,9 @@ pub use alsa_backend::{
|
|||||||
resolve_stable_to_current_hw,
|
resolve_stable_to_current_hw,
|
||||||
};
|
};
|
||||||
pub use alsa_direct::AlsaDirectStream;
|
pub use alsa_direct::AlsaDirectStream;
|
||||||
#[cfg(target_os = "linux")]
|
pub use wasapi_direct::{Rung, LADDER};
|
||||||
pub use jack_backend::JackStream;
|
|
||||||
pub use analysis::SpectralAnalyzer;
|
pub use analysis::SpectralAnalyzer;
|
||||||
pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap};
|
pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap, AnalyzerWaveformTrack};
|
||||||
pub use backend::{
|
pub use backend::{
|
||||||
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
|
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
|
||||||
BackendManager, BackendResult, BitPerfectMode,
|
BackendManager, BackendResult, BitPerfectMode,
|
||||||
@@ -74,32 +79,46 @@ pub use backend::{
|
|||||||
pub use coreaudio_direct::CoreAudioExclusiveGuard;
|
pub use coreaudio_direct::CoreAudioExclusiveGuard;
|
||||||
pub use dac_capabilities::{query_dac_capabilities, DacCapabilities};
|
pub use dac_capabilities::{query_dac_capabilities, DacCapabilities};
|
||||||
pub use dac_probe::{negotiated_active_rate, negotiated_stream_rate, NegotiatedRate};
|
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::{
|
pub use health::{
|
||||||
audio_stack_health, detect_distro, detect_init, detect_sandbox, AudioStackHealth, Distro,
|
audio_stack_health, detect_distro, detect_init, detect_sandbox, AudioStackHealth, Distro,
|
||||||
InitSystem, Sandbox,
|
InitSystem, Sandbox,
|
||||||
};
|
};
|
||||||
pub use device_reservation::{DeviceReservation, ReservationError};
|
#[cfg(target_os = "linux")]
|
||||||
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
|
pub use jack_backend::JackStream;
|
||||||
pub use dynamic_amplify::DynamicAmplify;
|
|
||||||
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};
|
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};
|
||||||
pub use loudness_analyzer::LoudnessAnalyzer;
|
pub use loudness_analyzer::LoudnessAnalyzer;
|
||||||
pub use loudness_cache::LoudnessCache;
|
pub use loudness_cache::LoudnessCache;
|
||||||
pub use output_sinks::{list_output_sinks, OutputSinkInfo};
|
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 settings::AudioSettings;
|
||||||
pub use visualizer::{RingBuffer, TappedSource, VisualizerTap};
|
pub use visualizer::{RingBuffer, TappedSource, VisualizerTap};
|
||||||
|
|
||||||
/// Stub: returns the ID unchanged on non-Linux (no ALSA normalization needed).
|
/// Stub: returns the ID unchanged on non-Linux (no ALSA normalization needed).
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[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.
|
/// Stub: no ALSA device resolution on non-Linux.
|
||||||
#[cfg(not(target_os = "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.
|
/// Stub: no ALSA sample rate probing on non-Linux.
|
||||||
#[cfg(not(target_os = "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.
|
/// Stub: no ALSA rate enumeration on non-Linux.
|
||||||
#[cfg(not(target_os = "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::analyzer_tap::AnalyzerMessage;
|
||||||
use super::loudness::db_to_linear;
|
use super::loudness::db_to_linear;
|
||||||
use super::loudness_cache::LoudnessCache;
|
use super::loudness_cache::LoudnessCache;
|
||||||
|
use super::seek_waveform::{SeekWaveformAccumulator, SeekWaveformCache};
|
||||||
|
|
||||||
/// Maximum gain boost in dB (conservative clipping prevention)
|
/// Maximum gain boost in dB (conservative clipping prevention)
|
||||||
const MAX_GAIN_DB: f32 = 6.0;
|
const MAX_GAIN_DB: f32 = 6.0;
|
||||||
@@ -44,6 +45,11 @@ impl LoudnessAnalyzer {
|
|||||||
|
|
||||||
fn run(rx: Receiver<AnalyzerMessage>, cache: Arc<LoudnessCache>) {
|
fn run(rx: Receiver<AnalyzerMessage>, cache: Arc<LoudnessCache>) {
|
||||||
let mut state: Option<AnalyzerState> = None;
|
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 {
|
loop {
|
||||||
let msg = match rx.recv() {
|
let msg = match rx.recv() {
|
||||||
@@ -55,50 +61,76 @@ impl LoudnessAnalyzer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
AnalyzerMessage::NewTrack {
|
AnalyzerMessage::NewTrack(track) => {
|
||||||
track_id,
|
state = match (track.target_lufs, track.gain_atomic.clone()) {
|
||||||
sample_rate,
|
(Some(target_lufs), Some(gain_atomic)) => {
|
||||||
channels,
|
log::info!(
|
||||||
target_lufs,
|
"[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)",
|
||||||
gain_atomic,
|
track.track_id,
|
||||||
} => {
|
track.sample_rate,
|
||||||
log::info!(
|
track.channels,
|
||||||
"[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)",
|
target_lufs
|
||||||
track_id,
|
);
|
||||||
sample_rate,
|
let mut analyzer = AnalyzerState::new(
|
||||||
channels,
|
track.track_id,
|
||||||
target_lufs
|
track.sample_rate,
|
||||||
);
|
track.channels,
|
||||||
|
target_lufs,
|
||||||
// Check cache first
|
);
|
||||||
if let Some(cached) = cache.get(track_id) {
|
if let Some(cached) = cache.get(track.track_id) {
|
||||||
let gain = compute_gain_capped(cached.gain_db);
|
let gain = compute_gain_capped(cached.gain_db);
|
||||||
log::info!(
|
gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
|
||||||
"[LoudnessAnalyzer] Cache hit for track {}: {:.2} dB (source: {}), gain {:.4}",
|
analyzer.initial_done = true;
|
||||||
track_id, cached.gain_db, cached.source, gain
|
log::info!(
|
||||||
);
|
"[LoudnessAnalyzer] Cache hit for track {}: {:.2} dB (source: {}), gain {:.4}",
|
||||||
|
track.track_id, cached.gain_db, cached.source, gain
|
||||||
// Set gain immediately via the atomic
|
);
|
||||||
gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
|
}
|
||||||
|
analyzer.gain_atomic = Some(gain_atomic);
|
||||||
// Create state marked as cached — still accept samples for refinement
|
Some(analyzer)
|
||||||
let mut s =
|
}
|
||||||
AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
|
_ => None,
|
||||||
s.gain_atomic = Some(gain_atomic);
|
};
|
||||||
s.initial_done = true;
|
waveform = if super::seek_waveform::seek_waveform_enabled() {
|
||||||
state = Some(s);
|
ensure_waveform_cache(&mut waveform_cache);
|
||||||
continue;
|
Some(SeekWaveformAccumulator::begin(
|
||||||
}
|
track.track_id,
|
||||||
|
track.sample_rate,
|
||||||
// No cache — start fresh analysis
|
track.channels,
|
||||||
let mut s = AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
|
track.duration_secs,
|
||||||
s.gain_atomic = Some(gain_atomic);
|
waveform_cache.as_ref(),
|
||||||
state = Some(s);
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
current_track = Some(track);
|
||||||
}
|
}
|
||||||
AnalyzerMessage::Samples(samples) => {
|
AnalyzerMessage::Samples {
|
||||||
|
start_frame,
|
||||||
|
samples,
|
||||||
|
} => {
|
||||||
if let Some(ref mut s) = state {
|
if let Some(ref mut s) = state {
|
||||||
s.feed_samples(&samples, &cache);
|
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 => {
|
AnalyzerMessage::Reset => {
|
||||||
if let Some(ref mut s) = state {
|
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 {
|
struct AnalyzerState {
|
||||||
track_id: u64,
|
track_id: u64,
|
||||||
target_lufs: f32,
|
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