refactor(tauri): use qbz-app session store
cx
This commit is contained in:
1 file changed
+68
-403
@@ -1,45 +1,14 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use qbz_app::session_store::{
|
||||
PersistedPlaybackSession, PersistedSessionSnapshot, PersistedShellViewState,
|
||||
SessionStore as AppSessionStore,
|
||||
};
|
||||
pub use qbz_app::session_store::PersistedQueueTrack;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn default_streamable() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Represents a track in the persisted queue
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedQueueTrack {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration_secs: u64,
|
||||
pub artwork_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hires: bool,
|
||||
pub bit_depth: Option<u32>,
|
||||
pub sample_rate: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub is_local: bool,
|
||||
pub album_id: Option<String>,
|
||||
pub artist_id: Option<u64>,
|
||||
#[serde(default = "default_streamable")]
|
||||
pub streamable: bool,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parental_warning: bool,
|
||||
/// Opaque id of the Mixtape/Collection item that produced this track.
|
||||
/// Persisted so a Mixtape-sourced queue restored from disk keeps the
|
||||
/// item association the live queue would have (album_id is the safe
|
||||
/// fallback for non-Mixtape paths; None reads fine downstream).
|
||||
#[serde(default)]
|
||||
pub source_item_id_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Represents the full persisted session state
|
||||
/// Represents the full persisted session state as exposed through Tauri
|
||||
/// commands. Keep this flat shape stable for the existing frontend payloads.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedSession {
|
||||
pub queue_tracks: Vec<PersistedQueueTrack>,
|
||||
@@ -80,389 +49,85 @@ impl Default for PersistedSession {
|
||||
}
|
||||
}
|
||||
|
||||
impl PersistedSession {
|
||||
fn to_snapshot(&self) -> PersistedSessionSnapshot {
|
||||
PersistedSessionSnapshot {
|
||||
playback: PersistedPlaybackSession {
|
||||
queue_tracks: self.queue_tracks.clone(),
|
||||
current_index: self.current_index,
|
||||
current_position_secs: self.current_position_secs,
|
||||
volume: self.volume,
|
||||
shuffle_enabled: self.shuffle_enabled,
|
||||
repeat_mode: self.repeat_mode.clone(),
|
||||
was_playing: self.was_playing,
|
||||
saved_at: self.saved_at,
|
||||
},
|
||||
shell_view: PersistedShellViewState {
|
||||
last_view: self.last_view.clone(),
|
||||
view_context_id: self.view_context_id.clone(),
|
||||
view_context_type: self.view_context_type.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn from_snapshot(snapshot: PersistedSessionSnapshot) -> Self {
|
||||
Self {
|
||||
queue_tracks: snapshot.playback.queue_tracks,
|
||||
current_index: snapshot.playback.current_index,
|
||||
current_position_secs: snapshot.playback.current_position_secs,
|
||||
volume: snapshot.playback.volume,
|
||||
shuffle_enabled: snapshot.playback.shuffle_enabled,
|
||||
repeat_mode: snapshot.playback.repeat_mode,
|
||||
was_playing: snapshot.playback.was_playing,
|
||||
saved_at: snapshot.playback.saved_at,
|
||||
last_view: snapshot.shell_view.last_view,
|
||||
view_context_id: snapshot.shell_view.view_context_id,
|
||||
view_context_type: snapshot.shell_view.view_context_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin Tauri-side adapter that preserves the existing flat command model while
|
||||
/// delegating persistence to qbz-app.
|
||||
pub struct SessionStore {
|
||||
conn: Connection,
|
||||
inner: AppSessionStore,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "session.db")
|
||||
}
|
||||
|
||||
/// Open the session store at a specific base directory
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "session.db")
|
||||
}
|
||||
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open session database: {}", e))?;
|
||||
|
||||
// WAL mode for non-blocking reads/writes (ADR-002). synchronous=FULL,
|
||||
// not NORMAL: in WAL mode NORMAL only fsyncs at a checkpoint, so a
|
||||
// committed session survives an app restart (the WAL frames are still
|
||||
// in the OS page cache) but is LOST on a hard reboot — the page cache
|
||||
// is wiped and the frames were never fsync'd. FULL fsyncs every
|
||||
// commit; the session DB is written infrequently so the cost is
|
||||
// negligible. Without this, "restore session" silently breaks across
|
||||
// reboots (issue #440).
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")
|
||||
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||
|
||||
// Create tables
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS player_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_index INTEGER,
|
||||
current_position_secs INTEGER NOT NULL DEFAULT 0,
|
||||
volume REAL NOT NULL DEFAULT 0.75,
|
||||
shuffle_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
repeat_mode TEXT NOT NULL DEFAULT 'off',
|
||||
was_playing INTEGER NOT NULL DEFAULT 0,
|
||||
saved_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queue_tracks (
|
||||
position INTEGER PRIMARY KEY,
|
||||
track_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
album TEXT NOT NULL,
|
||||
duration_secs INTEGER NOT NULL,
|
||||
artwork_url TEXT,
|
||||
hires INTEGER NOT NULL DEFAULT 0,
|
||||
bit_depth INTEGER,
|
||||
sample_rate REAL,
|
||||
source TEXT
|
||||
);
|
||||
|
||||
-- Insert default row if not exists
|
||||
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);
|
||||
"
|
||||
).map_err(|e| format!("Failed to create session tables: {}", e))?;
|
||||
|
||||
// Migrate existing queue_tracks table to add new columns if they don't exist
|
||||
// SQLite doesn't support IF NOT EXISTS for ALTER TABLE, so we check the schema
|
||||
let has_hires: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'hires'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
> 0;
|
||||
|
||||
if !has_hires {
|
||||
let _ = conn.execute_batch(
|
||||
"
|
||||
ALTER TABLE queue_tracks ADD COLUMN hires INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE queue_tracks ADD COLUMN bit_depth INTEGER;
|
||||
ALTER TABLE queue_tracks ADD COLUMN sample_rate REAL;
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
// Add is_local, album_id, artist_id columns if they don't exist
|
||||
let has_is_local: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'is_local'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
> 0;
|
||||
|
||||
if !has_is_local {
|
||||
let _ = conn.execute_batch(
|
||||
"
|
||||
ALTER TABLE queue_tracks ADD COLUMN is_local INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE queue_tracks ADD COLUMN album_id TEXT;
|
||||
ALTER TABLE queue_tracks ADD COLUMN artist_id INTEGER;
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
let has_source: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'source'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
> 0;
|
||||
|
||||
if !has_source {
|
||||
let _ = conn.execute_batch(
|
||||
"
|
||||
ALTER TABLE queue_tracks ADD COLUMN source TEXT;
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
// streamable / parental_warning / source_item_id_hint were hardcoded
|
||||
// to defaults on load (streamable=true, parental_warning=false,
|
||||
// source_item_id_hint=None) because the columns didn't exist.
|
||||
// Persisting them round-trips the live QueueTrack faithfully so
|
||||
// a restored queue matches what the user had open before close.
|
||||
let has_streamable: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'streamable'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
> 0;
|
||||
|
||||
if !has_streamable {
|
||||
let _ = conn.execute_batch(
|
||||
"
|
||||
ALTER TABLE queue_tracks ADD COLUMN streamable INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE queue_tracks ADD COLUMN parental_warning INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE queue_tracks ADD COLUMN source_item_id_hint TEXT;
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
// Add last_view, view_context_id, view_context_type columns to player_state
|
||||
let has_last_view: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('player_state') WHERE name = 'last_view'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
> 0;
|
||||
|
||||
if !has_last_view {
|
||||
let _ = conn.execute_batch(
|
||||
"
|
||||
ALTER TABLE player_state ADD COLUMN last_view TEXT NOT NULL DEFAULT 'home';
|
||||
ALTER TABLE player_state ADD COLUMN view_context_id TEXT;
|
||||
ALTER TABLE player_state ADD COLUMN view_context_type TEXT;
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Save the complete session state
|
||||
pub fn save_session(&self, session: &PersistedSession) -> Result<(), String> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
// Use a transaction for atomicity
|
||||
self.conn
|
||||
.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
|
||||
// Clear existing queue
|
||||
if let Err(e) = self.conn.execute("DELETE FROM queue_tracks", []) {
|
||||
let _ = self.conn.execute("ROLLBACK", []);
|
||||
return Err(format!("Failed to clear queue: {}", e));
|
||||
}
|
||||
|
||||
// Insert queue tracks
|
||||
for (pos, track) in session.queue_tracks.iter().enumerate() {
|
||||
if let Err(e) = self.conn.execute(
|
||||
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source, streamable, parental_warning, source_item_id_hint)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
pos as i64,
|
||||
track.id as i64,
|
||||
track.title,
|
||||
track.artist,
|
||||
track.album,
|
||||
track.duration_secs as i64,
|
||||
track.artwork_url,
|
||||
track.hires as i64,
|
||||
track.bit_depth.map(|v| v as i64),
|
||||
track.sample_rate,
|
||||
track.is_local as i64,
|
||||
track.album_id,
|
||||
track.artist_id.map(|v| v as i64),
|
||||
track.source,
|
||||
track.streamable as i64,
|
||||
track.parental_warning as i64,
|
||||
track.source_item_id_hint,
|
||||
],
|
||||
) {
|
||||
let _ = self.conn.execute("ROLLBACK", []);
|
||||
return Err(format!("Failed to insert queue track: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Update player state
|
||||
if let Err(e) = self.conn.execute(
|
||||
"UPDATE player_state SET
|
||||
current_index = ?1,
|
||||
current_position_secs = ?2,
|
||||
volume = ?3,
|
||||
shuffle_enabled = ?4,
|
||||
repeat_mode = ?5,
|
||||
was_playing = ?6,
|
||||
saved_at = ?7,
|
||||
last_view = ?8,
|
||||
view_context_id = ?9,
|
||||
view_context_type = ?10
|
||||
WHERE id = 1",
|
||||
params![
|
||||
session.current_index.map(|i| i as i64),
|
||||
session.current_position_secs as i64,
|
||||
session.volume as f64,
|
||||
session.shuffle_enabled as i64,
|
||||
session.repeat_mode,
|
||||
session.was_playing as i64,
|
||||
now,
|
||||
session.last_view,
|
||||
session.view_context_id,
|
||||
session.view_context_type,
|
||||
],
|
||||
) {
|
||||
let _ = self.conn.execute("ROLLBACK", []);
|
||||
return Err(format!("Failed to update player state: {}", e));
|
||||
}
|
||||
|
||||
self.conn
|
||||
.execute("COMMIT", [])
|
||||
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the persisted session state
|
||||
pub fn load_session(&self) -> Result<PersistedSession, String> {
|
||||
// Load player state
|
||||
let (current_index, current_position_secs, volume, shuffle_enabled, repeat_mode, was_playing, saved_at, last_view, view_context_id, view_context_type):
|
||||
(Option<i64>, i64, f64, i64, String, i64, i64, String, Option<String>, Option<String>) = self.conn
|
||||
.query_row(
|
||||
"SELECT current_index, current_position_secs, volume, shuffle_enabled, repeat_mode, was_playing, saved_at, last_view, view_context_id, view_context_type
|
||||
FROM player_state WHERE id = 1",
|
||||
[],
|
||||
|row| Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
row.get::<_, String>(7).unwrap_or_else(|_| "home".to_string()),
|
||||
row.get(8)?,
|
||||
row.get(9)?,
|
||||
)),
|
||||
)
|
||||
.map_err(|e| format!("Failed to load player state: {}", e))?;
|
||||
|
||||
// Load queue tracks
|
||||
let mut stmt = self.conn
|
||||
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source, streamable, parental_warning, source_item_id_hint FROM queue_tracks ORDER BY position")
|
||||
.map_err(|e| format!("Failed to prepare queue query: {}", e))?;
|
||||
|
||||
let tracks: Vec<PersistedQueueTrack> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(PersistedQueueTrack {
|
||||
id: row.get::<_, i64>(0)? as u64,
|
||||
title: row.get(1)?,
|
||||
artist: row.get(2)?,
|
||||
album: row.get(3)?,
|
||||
duration_secs: row.get::<_, i64>(4)? as u64,
|
||||
artwork_url: row.get(5)?,
|
||||
hires: row.get::<_, i64>(6).unwrap_or(0) != 0,
|
||||
bit_depth: row.get::<_, Option<i64>>(7)?.map(|v| v as u32),
|
||||
sample_rate: row.get(8)?,
|
||||
is_local: row.get::<_, i64>(9).unwrap_or(0) != 0,
|
||||
album_id: row.get(10)?,
|
||||
artist_id: row.get::<_, Option<i64>>(11)?.map(|v| v as u64),
|
||||
source: row.get(12)?,
|
||||
streamable: row.get::<_, i64>(13).unwrap_or(1) != 0,
|
||||
parental_warning: row.get::<_, i64>(14).unwrap_or(0) != 0,
|
||||
source_item_id_hint: row.get(15)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query queue tracks: {}", e))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(PersistedSession {
|
||||
queue_tracks: tracks,
|
||||
current_index: current_index.map(|i| i as usize),
|
||||
current_position_secs: current_position_secs as u64,
|
||||
volume: volume as f32,
|
||||
shuffle_enabled: shuffle_enabled != 0,
|
||||
repeat_mode,
|
||||
was_playing: was_playing != 0,
|
||||
saved_at,
|
||||
last_view,
|
||||
view_context_id,
|
||||
view_context_type,
|
||||
Ok(Self {
|
||||
inner: AppSessionStore::new()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Quick save of just player position (for debounced saves during playback)
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
inner: AppSessionStore::new_at(base_dir)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_session(&self, session: &PersistedSession) -> Result<(), String> {
|
||||
self.inner.save_session(&session.to_snapshot())
|
||||
}
|
||||
|
||||
pub fn load_session(&self) -> Result<PersistedSession, String> {
|
||||
self.inner.load_session().map(PersistedSession::from_snapshot)
|
||||
}
|
||||
|
||||
pub fn save_position(&self, position_secs: u64) -> Result<(), String> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE player_state SET current_position_secs = ?1, saved_at = ?2 WHERE id = 1",
|
||||
params![position_secs as i64, now],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save position: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
self.inner.save_position(position_secs)
|
||||
}
|
||||
|
||||
/// Quick save of volume
|
||||
pub fn save_volume(&self, volume: f32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE player_state SET volume = ?1 WHERE id = 1",
|
||||
params![volume as f64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save volume: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
self.inner.save_volume(volume)
|
||||
}
|
||||
|
||||
/// Save shuffle/repeat state
|
||||
pub fn save_playback_mode(&self, shuffle: bool, repeat_mode: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE player_state SET shuffle_enabled = ?1, repeat_mode = ?2 WHERE id = 1",
|
||||
params![shuffle as i64, repeat_mode],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save playback mode: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
self.inner.save_playback_mode(shuffle, repeat_mode)
|
||||
}
|
||||
|
||||
/// Clear the session (e.g., on logout)
|
||||
pub fn clear_session(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute("DELETE FROM queue_tracks", [])
|
||||
.map_err(|e| format!("Failed to clear queue: {}", e))?;
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE player_state SET current_index = NULL, current_position_secs = 0, was_playing = 0, last_view = 'home', view_context_id = NULL, view_context_type = NULL WHERE id = 1",
|
||||
[],
|
||||
).map_err(|e| format!("Failed to reset player state: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
self.inner.clear_session()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user