Merge refactor/qbz-app-session-store: session store foundation
This commit is contained in:
3 files changed
+730
-403
No files matched your search
@@ -2,5 +2,6 @@ pub mod boot_watchdog;
|
|||||||
pub mod graphics_autoconfig;
|
pub mod graphics_autoconfig;
|
||||||
pub mod graphics_detection;
|
pub mod graphics_detection;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
pub mod session_store;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod user_data;
|
pub mod user_data;
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
//! Session persistence store.
|
||||||
|
//!
|
||||||
|
//! The playback queue/session state is portable application state. The current
|
||||||
|
//! Tauri/Svelte shell also stores view restoration fields in the same DB table;
|
||||||
|
//! those fields are modeled here only so the existing schema can round-trip
|
||||||
|
//! unchanged during the extraction.
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn default_streamable() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
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,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_item_id_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PersistedPlaybackSession {
|
||||||
|
pub queue_tracks: Vec<PersistedQueueTrack>,
|
||||||
|
pub current_index: Option<usize>,
|
||||||
|
pub current_position_secs: u64,
|
||||||
|
pub volume: f32,
|
||||||
|
pub shuffle_enabled: bool,
|
||||||
|
pub repeat_mode: String,
|
||||||
|
pub was_playing: bool,
|
||||||
|
pub saved_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PersistedPlaybackSession {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
queue_tracks: Vec::new(),
|
||||||
|
current_index: None,
|
||||||
|
current_position_secs: 0,
|
||||||
|
volume: 0.75,
|
||||||
|
shuffle_enabled: false,
|
||||||
|
repeat_mode: "off".to_string(),
|
||||||
|
was_playing: false,
|
||||||
|
saved_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PersistedShellViewState {
|
||||||
|
#[serde(default = "default_last_view")]
|
||||||
|
pub last_view: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub view_context_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub view_context_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_last_view() -> String {
|
||||||
|
"home".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PersistedShellViewState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
last_view: "home".to_string(),
|
||||||
|
view_context_id: None,
|
||||||
|
view_context_type: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct PersistedSessionSnapshot {
|
||||||
|
pub playback: PersistedPlaybackSession,
|
||||||
|
pub shell_view: PersistedShellViewState,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SessionStore {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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: the session DB must survive hard reboots (issue #440).
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")
|
||||||
|
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||||
|
|
||||||
|
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 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))?;
|
||||||
|
|
||||||
|
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;
|
||||||
|
",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_session(&self, session: &PersistedSessionSnapshot) -> Result<(), String> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute("BEGIN TRANSACTION", [])
|
||||||
|
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||||
|
|
||||||
|
if let Err(e) = self.conn.execute("DELETE FROM queue_tracks", []) {
|
||||||
|
let _ = self.conn.execute("ROLLBACK", []);
|
||||||
|
return Err(format!("Failed to clear queue: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pos, track) in session.playback.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.playback.current_index.map(|i| i as i64),
|
||||||
|
session.playback.current_position_secs as i64,
|
||||||
|
session.playback.volume as f64,
|
||||||
|
session.playback.shuffle_enabled as i64,
|
||||||
|
session.playback.repeat_mode,
|
||||||
|
session.playback.was_playing as i64,
|
||||||
|
now,
|
||||||
|
session.shell_view.last_view,
|
||||||
|
session.shell_view.view_context_id,
|
||||||
|
session.shell_view.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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_session(&self) -> Result<PersistedSessionSnapshot, String> {
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
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(|result| result.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(PersistedSessionSnapshot {
|
||||||
|
playback: PersistedPlaybackSession {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
shell_view: PersistedShellViewState {
|
||||||
|
last_view,
|
||||||
|
view_context_id,
|
||||||
|
view_context_type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn pragma_synchronous(&self) -> Result<i64, String> {
|
||||||
|
self.conn
|
||||||
|
.query_row("PRAGMA synchronous", [], |row| row.get(0))
|
||||||
|
.map_err(|e| format!("Failed to read synchronous pragma: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn pragma_journal_mode(&self) -> Result<String, String> {
|
||||||
|
self.conn
|
||||||
|
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
|
||||||
|
.map_err(|e| format!("Failed to read journal mode pragma: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_track() -> PersistedQueueTrack {
|
||||||
|
PersistedQueueTrack {
|
||||||
|
id: 42,
|
||||||
|
title: "Track".to_string(),
|
||||||
|
artist: "Artist".to_string(),
|
||||||
|
album: "Album".to_string(),
|
||||||
|
duration_secs: 300,
|
||||||
|
artwork_url: Some("https://example.test/art.jpg".to_string()),
|
||||||
|
hires: true,
|
||||||
|
bit_depth: Some(24),
|
||||||
|
sample_rate: Some(96_000.0),
|
||||||
|
is_local: true,
|
||||||
|
album_id: Some("album-1".to_string()),
|
||||||
|
artist_id: Some(7),
|
||||||
|
streamable: false,
|
||||||
|
source: Some("mixtape".to_string()),
|
||||||
|
parental_warning: true,
|
||||||
|
source_item_id_hint: Some("item-1".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_session_values_are_stable() {
|
||||||
|
let session = PersistedSessionSnapshot::default();
|
||||||
|
|
||||||
|
assert!(session.playback.queue_tracks.is_empty());
|
||||||
|
assert_eq!(session.playback.current_index, None);
|
||||||
|
assert_eq!(session.playback.current_position_secs, 0);
|
||||||
|
assert_eq!(session.playback.volume, 0.75);
|
||||||
|
assert!(!session.playback.shuffle_enabled);
|
||||||
|
assert_eq!(session.playback.repeat_mode, "off");
|
||||||
|
assert!(!session.playback.was_playing);
|
||||||
|
assert_eq!(session.shell_view.last_view, "home");
|
||||||
|
assert_eq!(session.shell_view.view_context_id, None);
|
||||||
|
assert_eq!(session.shell_view.view_context_type, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_store_uses_wal_and_full_synchronous() {
|
||||||
|
let dir = unique_test_dir("session-pragmas");
|
||||||
|
let store = SessionStore::new_at(&dir).expect("open store");
|
||||||
|
|
||||||
|
assert_eq!(store.pragma_journal_mode().expect("journal mode"), "wal");
|
||||||
|
assert_eq!(store.pragma_synchronous().expect("synchronous"), 2);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_store_round_trips_queue_and_shell_view_state() {
|
||||||
|
let dir = unique_test_dir("session-round-trip");
|
||||||
|
let store = SessionStore::new_at(&dir).expect("open store");
|
||||||
|
let session = PersistedSessionSnapshot {
|
||||||
|
playback: PersistedPlaybackSession {
|
||||||
|
queue_tracks: vec![sample_track()],
|
||||||
|
current_index: Some(0),
|
||||||
|
current_position_secs: 123,
|
||||||
|
volume: 0.42,
|
||||||
|
shuffle_enabled: true,
|
||||||
|
repeat_mode: "all".to_string(),
|
||||||
|
was_playing: true,
|
||||||
|
saved_at: 0,
|
||||||
|
},
|
||||||
|
shell_view: PersistedShellViewState {
|
||||||
|
last_view: "album".to_string(),
|
||||||
|
view_context_id: Some("album-1".to_string()),
|
||||||
|
view_context_type: Some("album".to_string()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
store.save_session(&session).expect("save session");
|
||||||
|
let loaded = store.load_session().expect("load session");
|
||||||
|
|
||||||
|
assert_eq!(loaded.playback.queue_tracks, vec![sample_track()]);
|
||||||
|
assert_eq!(loaded.playback.current_index, Some(0));
|
||||||
|
assert_eq!(loaded.playback.current_position_secs, 123);
|
||||||
|
assert_eq!(loaded.playback.volume, 0.42);
|
||||||
|
assert!(loaded.playback.shuffle_enabled);
|
||||||
|
assert_eq!(loaded.playback.repeat_mode, "all");
|
||||||
|
assert!(loaded.playback.was_playing);
|
||||||
|
assert!(loaded.playback.saved_at > 0);
|
||||||
|
assert_eq!(loaded.shell_view.last_view, "album");
|
||||||
|
assert_eq!(loaded.shell_view.view_context_id.as_deref(), Some("album-1"));
|
||||||
|
assert_eq!(loaded.shell_view.view_context_type.as_deref(), Some("album"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_saves_update_only_targeted_playback_fields() {
|
||||||
|
let dir = unique_test_dir("session-quick-save");
|
||||||
|
let store = SessionStore::new_at(&dir).expect("open store");
|
||||||
|
|
||||||
|
store.save_position(77).expect("save position");
|
||||||
|
store.save_volume(0.25).expect("save volume");
|
||||||
|
store
|
||||||
|
.save_playback_mode(true, "one")
|
||||||
|
.expect("save playback mode");
|
||||||
|
|
||||||
|
let loaded = store.load_session().expect("load session");
|
||||||
|
|
||||||
|
assert_eq!(loaded.playback.current_position_secs, 77);
|
||||||
|
assert_eq!(loaded.playback.volume, 0.25);
|
||||||
|
assert!(loaded.playback.shuffle_enabled);
|
||||||
|
assert_eq!(loaded.playback.repeat_mode, "one");
|
||||||
|
assert_eq!(loaded.shell_view.last_view, "home");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_session_resets_playback_and_shell_view_fields() {
|
||||||
|
let dir = unique_test_dir("session-clear");
|
||||||
|
let store = SessionStore::new_at(&dir).expect("open store");
|
||||||
|
let session = PersistedSessionSnapshot {
|
||||||
|
playback: PersistedPlaybackSession {
|
||||||
|
queue_tracks: vec![sample_track()],
|
||||||
|
current_index: Some(0),
|
||||||
|
current_position_secs: 55,
|
||||||
|
volume: 0.9,
|
||||||
|
shuffle_enabled: true,
|
||||||
|
repeat_mode: "all".to_string(),
|
||||||
|
was_playing: true,
|
||||||
|
saved_at: 0,
|
||||||
|
},
|
||||||
|
shell_view: PersistedShellViewState {
|
||||||
|
last_view: "artist".to_string(),
|
||||||
|
view_context_id: Some("7".to_string()),
|
||||||
|
view_context_type: Some("artist".to_string()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
store.save_session(&session).expect("save session");
|
||||||
|
store.clear_session().expect("clear session");
|
||||||
|
let loaded = store.load_session().expect("load session");
|
||||||
|
|
||||||
|
assert!(loaded.playback.queue_tracks.is_empty());
|
||||||
|
assert_eq!(loaded.playback.current_index, None);
|
||||||
|
assert_eq!(loaded.playback.current_position_secs, 0);
|
||||||
|
assert_eq!(loaded.playback.volume, 0.9);
|
||||||
|
assert!(loaded.playback.shuffle_enabled);
|
||||||
|
assert_eq!(loaded.playback.repeat_mode, "all");
|
||||||
|
assert!(!loaded.playback.was_playing);
|
||||||
|
assert_eq!(loaded.shell_view.last_view, "home");
|
||||||
|
assert_eq!(loaded.shell_view.view_context_id, None);
|
||||||
|
assert_eq!(loaded.shell_view.view_context_type, None);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn default_streamable() -> bool {
|
/// Represents the full persisted session state as exposed through Tauri
|
||||||
true
|
/// commands. Keep this flat shape stable for the existing frontend payloads.
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PersistedSession {
|
pub struct PersistedSession {
|
||||||
pub queue_tracks: Vec<PersistedQueueTrack>,
|
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 {
|
pub struct SessionStore {
|
||||||
conn: Connection,
|
inner: AppSessionStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionStore {
|
impl SessionStore {
|
||||||
pub fn new() -> Result<Self, String> {
|
pub fn new() -> Result<Self, String> {
|
||||||
let data_dir = dirs::data_dir()
|
Ok(Self {
|
||||||
.ok_or("Could not determine data directory")?
|
inner: AppSessionStore::new()?,
|
||||||
.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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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> {
|
pub fn save_position(&self, position_secs: u64) -> Result<(), String> {
|
||||||
let now = SystemTime::now()
|
self.inner.save_position(position_secs)
|
||||||
.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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quick save of volume
|
|
||||||
pub fn save_volume(&self, volume: f32) -> Result<(), String> {
|
pub fn save_volume(&self, volume: f32) -> Result<(), String> {
|
||||||
self.conn
|
self.inner.save_volume(volume)
|
||||||
.execute(
|
|
||||||
"UPDATE player_state SET volume = ?1 WHERE id = 1",
|
|
||||||
params![volume as f64],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to save volume: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save shuffle/repeat state
|
|
||||||
pub fn save_playback_mode(&self, shuffle: bool, repeat_mode: &str) -> Result<(), String> {
|
pub fn save_playback_mode(&self, shuffle: bool, repeat_mode: &str) -> Result<(), String> {
|
||||||
self.conn
|
self.inner.save_playback_mode(shuffle, repeat_mode)
|
||||||
.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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the session (e.g., on logout)
|
|
||||||
pub fn clear_session(&self) -> Result<(), String> {
|
pub fn clear_session(&self) -> Result<(), String> {
|
||||||
self.conn
|
self.inner.clear_session()
|
||||||
.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(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user