Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions crates/components/src/settings_items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,14 +372,19 @@ pub fn MultiDirectoryPicker(
pub fn LocalSourceSettings(
active_source: config::Source,
default_directories: Vec<std::path::PathBuf>,
default_portable_metadata: bool,
sources: Vec<SavedLocalSource>,
on_add: EventHandler<()>,
on_delete: EventHandler<String>,
on_switch: EventHandler<config::Source>,
on_add_folder: EventHandler<(config::Source, std::path::PathBuf)>,
on_remove_folder: EventHandler<(config::Source, usize)>,
on_portable_metadata: EventHandler<(config::Source, bool)>,
) -> Element {
let default_active = active_source == config::Source::Local;
let default_metadata_path = default_directories
.first()
.map(|root| root.join(server::source::PORTABLE_LIBRARY_DB_FILENAME));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
rsx! {
div { class: "flex flex-col gap-3 w-full",
div { class: "bg-white/5 p-3 rounded w-full space-y-2",
Expand All @@ -401,10 +406,25 @@ pub fn LocalSourceSettings(
}
}
MultiDirectoryPicker {
current_paths: default_directories,
current_paths: default_directories.clone(),
on_add: move |path| on_add_folder.call((config::Source::Local, path)),
on_remove: move |index| on_remove_folder.call((config::Source::Local, index)),
}
div { class: "flex items-center justify-between gap-4 border-t border-white/10 pt-2",
div { class: "min-w-0",
p { class: "text-xs font-medium text-white/80", "{i18n::t(\"shared_library_data\")}" }
if let Some(path) = default_metadata_path.as_ref() {
p { class: "text-[10px] text-white/45 font-mono truncate",
"{i18n::t_with(\"shared_library_data_path\", &[(\"path\", path.display().to_string())])}"
}
}
}
ToggleSetting {
enabled: default_portable_metadata,
compact: true,
on_change: move |enabled| on_portable_metadata.call((config::Source::Local, enabled)),
}
}
}
for source in sources.iter().cloned() {
{
Expand All @@ -414,7 +434,12 @@ pub fn LocalSourceSettings(
let switch_key = source_key.clone();
let add_folder_key = source_key.clone();
let remove_folder_key = source_key.clone();
let portable_key = source_key.clone();
let is_active = active_source.local_library_id() == Some(source.id.as_str());
let metadata_path = source
.directories
.first()
.map(|root| root.join(server::source::PORTABLE_LIBRARY_DB_FILENAME));
rsx! {
div { key: "{source.id}", class: "bg-white/5 p-3 rounded w-full space-y-2",
div { class: "flex items-center justify-between gap-3",
Expand Down Expand Up @@ -446,6 +471,21 @@ pub fn LocalSourceSettings(
on_add: move |path| on_add_folder.call((add_folder_key.clone(), path)),
on_remove: move |index| on_remove_folder.call((remove_folder_key.clone(), index)),
}
div { class: "flex items-center justify-between gap-4 border-t border-white/10 pt-2",
div { class: "min-w-0",
p { class: "text-xs font-medium text-white/80", "{i18n::t(\"shared_library_data\")}" }
if let Some(path) = metadata_path.as_ref() {
p { class: "text-[10px] text-white/45 font-mono truncate",
"{i18n::t_with(\"shared_library_data_path\", &[(\"path\", path.display().to_string())])}"
}
}
}
ToggleSetting {
enabled: source.portable_metadata,
compact: true,
on_change: move |enabled| on_portable_metadata.call((portable_key.clone(), enabled)),
}
}
}
}
}
Expand Down Expand Up @@ -740,7 +780,11 @@ pub fn DiscordPresencePausedSettings(enabled: bool, on_change: EventHandler<bool
}

#[component]
pub fn ToggleSetting(enabled: bool, on_change: EventHandler<bool>) -> Element {
pub fn ToggleSetting(
enabled: bool,
on_change: EventHandler<bool>,
#[props(default)] compact: bool,
) -> Element {
let slider_style = if enabled {
"inset-inline-start: 4px; width: calc(50% - 4px);"
} else {
Expand All @@ -758,10 +802,11 @@ pub fn ToggleSetting(enabled: bool, on_change: EventHandler<bool>) -> Element {
} else {
"text-slate-500 hover:text-slate-300"
};
let width_class = if compact { "w-36" } else { "w-48" };

rsx! {
div {
class: "bg-white/5 p-1 rounded-xl flex relative h-10 items-center border border-white/5 w-48",
class: "bg-white/5 p-1 rounded-xl flex relative h-10 items-center border border-white/5 {width_class}",
div {
class: "absolute h-8 bg-white/10 rounded-lg transition-all duration-300 ease-out",
style: "{slider_style}"
Expand Down
5 changes: 5 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,10 @@ pub struct AppConfig {
pub spotify_prefer_active_device: bool,
#[serde(default, deserialize_with = "deserialize_music_directories")]
pub music_directory: Vec<PathBuf>,
/// Whether the built-in Local library carries favorites, playlists, and
/// listening activity in the first configured music folder.
#[serde(default = "default_true")]
pub local_portable_metadata: bool,
#[serde(default = "default_theme")]
pub theme: String,
/// Palette file matugen or pywal writes, polled for changes while the live
Expand Down Expand Up @@ -915,6 +919,7 @@ impl Default for AppConfig {
spotify_browser: None,
spotify_prefer_active_device: true,
music_directory: vec![music_directory],
local_portable_metadata: true,
theme: default_theme(),
live_theme_path: String::new(),
device_id: default_device_id(),
Expand Down
9 changes: 9 additions & 0 deletions crates/config/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ pub struct SavedLocalSource {
pub name: String,
#[serde(default)]
pub directories: Vec<std::path::PathBuf>,
/// Store favorites, playlists, and listening activity in the first music
/// folder so the library can be shared with another Kopuz installation.
#[serde(default = "default_portable_metadata")]
pub portable_metadata: bool,
}

fn default_portable_metadata() -> bool {
true
}

impl SavedLocalSource {
Expand All @@ -77,6 +85,7 @@ impl SavedLocalSource {
id: format!("local:{}", uuid::Uuid::new_v4()),
name,
directories,
portable_metadata: true,
}
}
}
Expand Down
39 changes: 39 additions & 0 deletions crates/db/src/backend/cfg_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,45 @@ pub async fn bump_listen_count(
Ok(())
}

/// All source-qualified listen-count rows. The portable-library layer owns the
/// source/path interpretation because folder refs must be remapped there.
pub async fn listen_counts(pool: &SqlitePool) -> Result<Vec<(String, u64)>, DbError> {
let rows = sqlx::query!("SELECT track_key, count FROM listen_counts")
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| (row.track_key, row.count.max(0) as u64))
.collect())
}

/// Merge a snapshot of counts without ever decreasing a value that may have
/// been incremented concurrently on this machine.
pub async fn merge_listen_counts(
pool: &SqlitePool,
source: &Source,
counts: &[(String, u64)],
) -> Result<(), DbError> {
if counts.is_empty() {
return Ok(());
}
let mut tx = pool.begin().await?;
for (track_uid, count) in counts {
let key = source.listen_count_key(track_uid);
let count = (*count).min(i64::MAX as u64) as i64;
sqlx::query(
"INSERT INTO listen_counts (track_key, count) VALUES (?1, ?2) \
ON CONFLICT(track_key) DO UPDATE SET count = MAX(count, excluded.count)",
)
.bind(key)
.bind(count)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}

/// One source's recently-played track keys, newest first.
pub async fn recently_played(
pool: &SqlitePool,
Expand Down
41 changes: 41 additions & 0 deletions crates/db/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ impl Native {
})
}

/// Open a database stored beside a portable music library. Rollback
/// journaling keeps the committed state in one file and works on shared
/// filesystems where WAL's shared-memory sidecar is not supported.
pub async fn open_portable(path: &Path) -> Result<Self, DbError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| DbError::Io(e.to_string()))?;
}
let pool = open_portable_pool(path).await?;
migrations::run_migrations(&pool).await?;
Ok(Self {
pool: ArcSwap::from_pointee(pool),
})
}

fn pool(&self) -> Arc<SqlitePool> {
self.pool.load_full()
}
Expand All @@ -66,6 +80,21 @@ async fn open_pool(path: &Path) -> Result<SqlitePool, DbError> {
.map_err(Into::into)
}

async fn open_portable_pool(path: &Path) -> Result<SqlitePool, DbError> {
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Delete)
.synchronous(SqliteSynchronous::Full)
.busy_timeout(std::time::Duration::from_secs(10))
.foreign_keys(true);
SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.map_err(Into::into)
}

fn with_ext(path: &Path, suffix: &str) -> std::path::PathBuf {
if suffix.is_empty() {
path.to_path_buf()
Expand Down Expand Up @@ -135,6 +164,10 @@ impl ReadStore for Native {
cfg_store::recently_played(&self.pool(), source, limit).await
}

async fn listen_counts(&self) -> Result<Vec<(String, u64)>, DbError> {
cfg_store::listen_counts(&self.pool()).await
}

async fn artist_sample_tracks(
&self,
source: &crate::Source,
Expand Down Expand Up @@ -384,6 +417,14 @@ impl Storage for Native {
cfg_store::bump_listen_count(&self.pool(), source, track_uid).await
}

async fn merge_listen_counts(
&self,
source: &crate::Source,
counts: &[(String, u64)],
) -> Result<(), DbError> {
cfg_store::merge_listen_counts(&self.pool(), source, counts).await
}

async fn push_recent(&self, source: &crate::Source, track_key: &str) -> Result<(), DbError> {
cfg_store::push_recent(&self.pool(), source, track_key).await
}
Expand Down
24 changes: 24 additions & 0 deletions crates/db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ pub trait ReadStore: Send + Sync {
/// This source's recently-played track keys, newest first (capped).
async fn recently_played(&self, source: &Source, limit: u32) -> Result<Vec<String>, DbError>;

/// Every persisted play count as its source-qualified key and count.
/// Portable local-library databases use this to remap filesystem refs onto
/// the current machine before merging them into the app database.
async fn listen_counts(&self) -> Result<Vec<(String, u64)>, DbError>;

/// One representative (first-inserted) track per artist, artist A→Z — for
/// artist tiles that need a cover without pulling the whole source.
async fn artist_sample_tracks(
Expand Down Expand Up @@ -421,6 +426,15 @@ pub trait Storage: ReadStore {
/// Increment one track's play count in its source partition.
async fn bump_listen_count(&self, source: &Source, track_uid: &str) -> Result<(), DbError>;

/// Merge absolute play counts into one source partition. Existing larger
/// counts win because listening activity is monotonic and another writer
/// may have incremented a row after the incoming snapshot was read.
async fn merge_listen_counts(
&self,
source: &Source,
counts: &[(String, u64)],
) -> Result<(), DbError>;

/// Record a play for this source's recently-played history (caps + trims).
async fn push_recent(&self, source: &Source, track_key: &str) -> Result<(), DbError>;

Expand Down Expand Up @@ -558,6 +572,16 @@ pub async fn init(db_path: &std::path::Path) -> Result<Db, DbError> {
Ok(Db(Arc::new(native)))
}

/// Open the metadata database carried inside a local music folder.
///
/// Unlike the main app database this uses SQLite's single-file rollback
/// journal, which is suitable for a library on a shared filesystem and avoids
/// persistent `-wal`/`-shm` files being synchronized independently.
pub async fn init_portable(db_path: &std::path::Path) -> Result<Db, DbError> {
let native = backend::Native::open_portable(db_path).await?;
Ok(Db(Arc::new(native)))
}

/// The on-disk database path: `KOPUZ_DB_PATH` override, else `<config_dir>/kopuz.db`
/// (release) or `kopuz-debug.db` (debug builds, so `dx run` never touches real data).
pub fn default_db_path() -> std::path::PathBuf {
Expand Down
3 changes: 3 additions & 0 deletions crates/db/tests/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,12 @@ async fn named_local_source_round_trips_as_active() {
id: "local:test-library".into(),
name: "Work music".into(),
directories: vec![PathBuf::from("/music/work")],
portable_metadata: false,
};
let cfg = AppConfig {
active_source: Source::LocalLibrary(local.id.clone()),
local_sources: vec![local.clone()],
local_portable_metadata: false,
..Default::default()
};

Expand All @@ -146,6 +148,7 @@ async fn named_local_source_round_trips_as_active() {

assert_eq!(loaded.active_source, Source::LocalLibrary(local.id.clone()));
assert_eq!(loaded.local_sources, vec![local]);
assert!(!loaded.local_portable_metadata);
assert!(loaded.server.is_none());
assert_eq!(
loaded
Expand Down
1 change: 1 addition & 0 deletions crates/hooks/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod debug_db;
pub mod favorites;
pub mod playback_ref;
mod player_controller_queue;
pub mod portable_metadata;
pub mod scrobble_scheduler;
pub mod source_switch;
pub mod toast;
Expand Down
Loading
Loading