feat(offline-cache): schema migration for v2 CMAF bundle storage

Adds columns to cached_tracks for the v2 format that stores Qobuz's raw
CMAF segments bit-identical on disk, with the per-track AES content key
wrapped via qbz-secrets (OS keyring or KDF fallback).

New columns (added via idempotent ALTER TABLE, safe to re-run):
- cache_format    INTEGER DEFAULT 1   — 1 = legacy plain FLAC (existing),
                                          2 = raw CMAF bundle (new)
- init_path       TEXT                — path to init.mp4 (FLAC header +
                                          segment table)
- content_key_wrapped BLOB            — wrapped 16-byte AES content key
- infos_wrapped       BLOB            — wrapped session infos salt
- format_id       INTEGER             — Qobuz format id (5/6/7/27)
- n_segments      INTEGER             — number of audio segments

Existing rows keep cache_format=1 and continue to play from the plain
FLAC at file_path — zero regression for users with populated caches
when the upgrade ships. New downloads will land as cache_format=2. The
two formats coexist until v1 rows age out naturally (user re-download
or subscription-lapse cache wipe).

Adds set_cmaf_bundle() / get_cmaf_bundle() helpers + a CmafBundleRow
struct so the download path and the playback path have one authoritative
DB surface to talk to.

Also adds qbz-secrets as a direct src-tauri dep so the next commit
(rewiring spawn_track_cache_download) can wrap keys right at the
download site.

cc
This commit is contained in:
vicrodh committed 2026-04-17 07:30:50 -06:00
1 parent 1743c9e5ec
commit 935b3f81e0
3 files changed
+165

No files matched your search

+17
View File
@@ -4948,6 +4948,7 @@ dependencies = [
"qbz-models",
"qbz-player",
"qbz-qobuz",
"qbz-secrets",
"qconnect-app",
"qconnect-core",
"qconnect-transport-ws",
@@ -5025,6 +5026,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "qbz-secrets"
version = "0.1.0"
dependencies = [
"aes-gcm",
"base64 0.22.1",
"hkdf",
"keyring",
"log",
"rand 0.9.3",
"serde",
"sha2",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "qconnect-app"
version = "0.1.0"
+1
View File
@@ -35,6 +35,7 @@ qbz-cache = { path = "../crates/qbz-cache" }
qbz-library = { path = "../crates/qbz-library" }
qbz-cast = { path = "../crates/qbz-cast" }
qbz-cmaf = { path = "../crates/qbz-cmaf" }
qbz-secrets = { path = "../crates/qbz-secrets" }
mdns-sd = "0.11"
qconnect-app = { path = "../crates/qconnect-app" }
qconnect-core = { path = "../crates/qconnect-core" }
+147
View File
@@ -65,9 +65,63 @@ impl OfflineCacheDb {
)
.map_err(|e| format!("Failed to initialize database schema: {}", e))?;
self.migrate_v2_cmaf_columns()?;
Ok(())
}
/// Additive migration for the v2 offline format.
///
/// Adds columns for bit-identical CMAF storage:
/// - `cache_format`: 1 = legacy plain FLAC, 2 = raw CMAF bundle
/// - `init_path`: path to the init.mp4 (contains FLAC header + table)
/// - `content_key_wrapped`: AES content key wrapped with qbz-secrets
/// - `infos_wrapped`: session infos salt wrapped with qbz-secrets
/// - `format_id`: Qobuz format id (e.g. 5/6/7/27)
/// - `n_segments`: number of audio segments (s=1..=n)
///
/// Existing rows keep `cache_format=1` so playback continues to read
/// the legacy plain-FLAC `file_path` for them. New downloads go to
/// `cache_format=2`. We never rewrite v1 rows into v2 — the two
/// formats coexist until the v1 rows naturally expire via the
/// subscription-lapse cache wipe or user-triggered re-download.
fn migrate_v2_cmaf_columns(&self) -> Result<(), String> {
let existing = self.existing_columns("cached_tracks")?;
let add = |col: &str, ddl: &str| -> Result<(), String> {
if !existing.iter().any(|c| c == col) {
let sql = format!("ALTER TABLE cached_tracks ADD COLUMN {}", ddl);
self.conn
.execute(&sql, [])
.map_err(|e| format!("Failed to add column {}: {}", col, e))?;
log::info!(
"[OfflineCache/MIGRATE] Added column {} to cached_tracks",
col
);
}
Ok(())
};
add("cache_format", "cache_format INTEGER NOT NULL DEFAULT 1")?;
add("init_path", "init_path TEXT")?;
add("content_key_wrapped", "content_key_wrapped BLOB")?;
add("infos_wrapped", "infos_wrapped BLOB")?;
add("format_id", "format_id INTEGER")?;
add("n_segments", "n_segments INTEGER")?;
Ok(())
}
fn existing_columns(&self, table: &str) -> Result<Vec<String>, String> {
let mut stmt = self
.conn
.prepare(&format!("PRAGMA table_info({})", table))
.map_err(|e| format!("Failed to prepare PRAGMA: {}", e))?;
let cols = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| format!("Failed to read PRAGMA: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to iterate PRAGMA: {}", e))?;
Ok(cols)
}
/// Insert a new track to cache for offline
pub fn insert_track(&self, info: &TrackCacheInfo, file_path: &str) -> Result<(), String> {
self.conn.execute(
@@ -468,4 +522,97 @@ impl OfflineCacheDb {
.map_err(|e| format!("Failed to update artwork path: {}", e))?;
Ok(())
}
// ==================== v2 CMAF bundle fields ====================
/// Persist the CMAF-specific columns for a track after it was
/// successfully downloaded as a raw encrypted bundle.
///
/// `file_path` here is the concatenated-segments file (or primary
/// segment file, depending on how the caller lays out the bundle on
/// disk). `init_path` is the init.mp4. Both keys are already wrapped
/// by the caller via `qbz-secrets::SecretBox::wrap`.
#[allow(clippy::too_many_arguments)]
pub fn set_cmaf_bundle(
&self,
track_id: u64,
segments_path: &str,
init_path: &str,
content_key_wrapped: &[u8],
infos_wrapped: &[u8],
format_id: u32,
n_segments: u32,
total_bytes: u64,
) -> Result<(), String> {
self.conn
.execute(
"UPDATE cached_tracks
SET cache_format = 2,
file_path = ?1,
init_path = ?2,
content_key_wrapped = ?3,
infos_wrapped = ?4,
format_id = ?5,
n_segments = ?6,
file_size_bytes = ?7
WHERE track_id = ?8",
params![
segments_path,
init_path,
content_key_wrapped,
infos_wrapped,
format_id as i64,
n_segments as i64,
total_bytes as i64,
track_id as i64,
],
)
.map_err(|e| format!("Failed to write CMAF bundle fields: {}", e))?;
Ok(())
}
/// Read back the bundle fields for a track, for offline playback.
pub fn get_cmaf_bundle(&self, track_id: u64) -> Result<Option<CmafBundleRow>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT cache_format, file_path, init_path, content_key_wrapped,
infos_wrapped, format_id, n_segments
FROM cached_tracks
WHERE track_id = ?1",
)
.map_err(|e| format!("Failed to prepare CMAF bundle select: {}", e))?;
let row: Result<CmafBundleRow, _> = stmt.query_row(params![track_id as i64], |row| {
Ok(CmafBundleRow {
cache_format: row.get::<_, i64>(0)? as u8,
segments_path: row.get(1)?,
init_path: row.get::<_, Option<String>>(2)?,
content_key_wrapped: row.get::<_, Option<Vec<u8>>>(3)?,
infos_wrapped: row.get::<_, Option<Vec<u8>>>(4)?,
format_id: row.get::<_, Option<i64>>(5)?.map(|v| v as u32),
n_segments: row.get::<_, Option<i64>>(6)?.map(|v| v as u32),
})
});
match row {
Ok(r) => Ok(Some(r)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to read CMAF bundle: {}", e)),
}
}
}
/// Raw snapshot of the v2 bundle columns for a cached track.
///
/// `cache_format` tells the caller how to interpret the rest:
/// - `1` — legacy plain FLAC at `segments_path`; other fields empty.
/// - `2` — raw CMAF bundle; all fields populated.
#[derive(Debug, Clone)]
pub struct CmafBundleRow {
pub cache_format: u8,
pub segments_path: String,
pub init_path: Option<String>,
pub content_key_wrapped: Option<Vec<u8>>,
pub infos_wrapped: Option<Vec<u8>>,
pub format_id: Option<u32>,
pub n_segments: Option<u32>,
}