use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use crate::config::Config;
use crate::db::connection::Database;
use crate::db::queries;
use crate::player::commands::PlayerCommand;
use crate::player::state::{LoadState, PlaylistItem, QueueItemId, SharedPlayerState};
use crate::remote::client::{SubsonicAuth, SubsonicClient};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PasswordSource {
Keychain,
Config,
Missing,
Unreadable(String),
}
pub fn get_remote_password(cfg: &Config) -> Option<String> {
remote_password(cfg).0
}
pub fn remote_password(cfg: &Config) -> (Option<String>, PasswordSource) {
let refusal = match crate::credentials::get_password(&cfg.remote.url) {
Ok(pw) if !pw.is_empty() => return (Some(pw), PasswordSource::Keychain),
Ok(_) | Err(crate::credentials::CredentialError::NotFound) => None,
Err(e) => Some(e.to_string()),
};
if !cfg.remote.password.is_empty() {
return (Some(cfg.remote.password.clone()), PasswordSource::Config);
}
(
None,
refusal.map_or(PasswordSource::Missing, PasswordSource::Unreadable),
)
}
pub fn spawn_library_watch(
db_path: std::path::PathBuf,
on_state: impl Fn(bool) + Send + Sync + 'static,
) -> Option<std::thread::JoinHandle<()>> {
use notify::{RecursiveMode, Watcher};
std::thread::Builder::new()
.name("koan-library-watch".into())
.spawn(move || {
let scan_now = |reason: &str| {
let cfg = Config::load().unwrap_or_default();
if cfg.library.folders.is_empty() {
return;
}
let Ok(db) = Database::open(&db_path) else {
return;
};
on_state(true);
let result = crate::index::scanner::full_scan(
&db,
&cfg.library.folders,
crate::index::scanner::ScanOptions::default(),
None,
);
on_state(false);
log::info!(
"{reason} scan: {} added, {} updated, {} removed, {} unchanged",
result.added,
result.updated,
result.removed,
result.skipped
);
};
std::thread::sleep(std::time::Duration::from_secs(3));
scan_now("startup");
let (tx, rx) = std::sync::mpsc::channel();
let Ok(mut watcher) = notify::recommended_watcher(move |event| {
let _ = tx.send(event);
}) else {
log::warn!("could not watch the library folders");
return;
};
let cfg = Config::load().unwrap_or_default();
for folder in &cfg.library.folders {
if let Err(e) = watcher.watch(folder, RecursiveMode::Recursive) {
log::warn!("could not watch {}: {e}", folder.display());
}
}
const SETTLE: std::time::Duration = std::time::Duration::from_secs(5);
while let Ok(first) = rx.recv() {
if first.is_err() {
continue;
}
while rx.recv_timeout(SETTLE).is_ok() {}
scan_now("watched change");
}
})
.ok()
}
pub fn spawn_auto_sync(
db_path: std::path::PathBuf,
on_state: impl Fn(bool) + Send + 'static,
) -> Option<std::thread::JoinHandle<()>> {
std::thread::Builder::new()
.name("koan-auto-sync".into())
.spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(5));
loop {
let cfg = Config::load().unwrap_or_default();
if !cfg.remote.enabled || !cfg.remote.auto_sync {
std::thread::sleep(std::time::Duration::from_secs(60));
continue;
}
if let Some(client) = subsonic_client(&cfg)
&& let Ok(db) = Database::open(&db_path)
{
on_state(true);
match crate::remote::sync::sync_library(
&db,
&client,
false,
&cfg.remote.url,
&cfg.remote.username,
) {
Ok(r) => log::info!(
"auto sync: {} artists, {} albums, {} tracks ({} albums failed)",
r.artists_synced,
r.albums_synced,
r.tracks_synced,
r.albums_failed
),
Err(e) => log::warn!("auto sync failed: {e}"),
}
on_state(false);
}
match cfg.remote.auto_sync_interval_mins {
0 => return,
mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
}
}
})
.ok()
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RebuildSummary {
pub tracks: u64,
pub albums: u64,
pub artists: u64,
}
pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
let count = |sql: &str| -> u64 {
db.conn
.query_row(sql, [], |r| r.get::<_, i64>(0))
.unwrap_or(0) as u64
};
let summary = RebuildSummary {
tracks: count("SELECT COUNT(*) FROM tracks"),
albums: count("SELECT COUNT(*) FROM albums"),
artists: count("SELECT COUNT(*) FROM artists"),
};
db.conn.execute_batch(
"BEGIN;
DELETE FROM track_vectors;
DELETE FROM lyrics_cache;
DELETE FROM play_history;
DELETE FROM scan_cache;
DELETE FROM tracks_fts;
DELETE FROM tracks;
DELETE FROM similar_artists;
DELETE FROM albums;
DELETE FROM artists;
COMMIT;",
)?;
let _ = db.conn.execute_batch("VACUUM");
Ok(summary)
}
pub fn cache_size_bytes(cfg: &Config) -> u64 {
walkdir::WalkDir::new(cfg.cache_dir())
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum()
}
pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
let prefix = format!(
"{}{}%",
folder
.to_string_lossy()
.trim_end_matches(std::path::MAIN_SEPARATOR),
std::path::MAIN_SEPARATOR
);
db.conn
.query_row(
"SELECT COUNT(*) FROM tracks WHERE path LIKE ?1",
[&prefix],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) as u64
}
pub fn tracks_from_server(db: &Database) -> u64 {
db.conn
.query_row(
"SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
[],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) as u64
}
pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
let prefix = format!(
"{}{}%",
folder
.to_string_lossy()
.trim_end_matches(std::path::MAIN_SEPARATOR),
std::path::MAIN_SEPARATOR
);
let tx = db.conn.unchecked_transaction()?;
tx.execute(
"UPDATE tracks SET path = NULL, source = 'remote'
WHERE path LIKE ?1 AND remote_id IS NOT NULL",
[&prefix],
)?;
let ids: Vec<i64> = {
let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path LIKE ?1")?;
let rows = stmt.query_map([&prefix], |r| r.get(0))?;
rows.filter_map(Result::ok).collect()
};
for id in &ids {
tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
}
prune_empty_albums_and_artists(&tx)?;
tx.commit()?;
Ok(ids.len() as u64)
}
pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
let tx = db.conn.unchecked_transaction()?;
let ids: Vec<i64> = {
let mut stmt =
tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.filter_map(Result::ok).collect()
};
for id in &ids {
tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
}
tx.execute(
"UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
WHERE remote_id IS NOT NULL",
[],
)?;
tx.execute("DELETE FROM similar_artists", [])?;
prune_empty_albums_and_artists(&tx)?;
tx.commit()?;
Ok(ids.len() as u64)
}
fn prune_empty_albums_and_artists(
tx: &rusqlite::Transaction<'_>,
) -> Result<(), crate::db::connection::DbError> {
tx.execute(
"DELETE FROM albums WHERE NOT EXISTS
(SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
[],
)?;
tx.execute(
"DELETE FROM similar_artists WHERE NOT EXISTS
(SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
[],
)?;
tx.execute(
"DELETE FROM artists WHERE NOT EXISTS
(SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
AND NOT EXISTS
(SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
[],
)?;
Ok(())
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CacheCleared {
pub files: u64,
pub bytes: u64,
}
pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
let dir = cfg.cache_dir();
let mut cleared = CacheCleared::default();
for entry in walkdir::WalkDir::new(&dir)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
{
if let Ok(meta) = entry.metadata() {
cleared.bytes += meta.len();
cleared.files += 1;
}
}
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let _ = queries::clear_cached_paths(&db.conn);
cleared
}
pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
let cfg = Config::load().unwrap_or_default();
if !cfg.remote.enabled {
return;
}
let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
log::warn!("not syncing favourite: {} has no remote id", path.display());
return;
};
let Some(client) = subsonic_client(&cfg) else {
log::warn!("not syncing favourite: no usable server credentials");
return;
};
std::thread::Builder::new()
.name("koan-fav-sync".into())
.spawn(move || {
let result = if star {
client.star(&remote_id)
} else {
client.unstar(&remote_id)
};
match result {
Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
}
})
.ok();
}
#[derive(Debug, Default, Clone, Copy)]
pub struct FavouriteSync {
pub pushed: usize,
pub imported: usize,
}
pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
let mut out = FavouriteSync::default();
let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
for (_path, remote_id) in &tracks {
if client.star(remote_id).is_ok() {
out.pushed += 1;
}
}
for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
if client.star_album(&remote_id).is_ok() {
out.pushed += 1;
}
}
for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
{
if client.star_artist(&remote_id).is_ok() {
out.pushed += 1;
}
}
let starred = match client.get_starred_all() {
Ok(s) => s,
Err(e) => {
log::warn!("could not fetch starred items from the server: {e}");
return out;
}
};
let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FavouriteKind {
Track,
Album,
Artist,
}
pub fn sync_collection_favourite_to_remote(
db: &Database,
kind: FavouriteKind,
id: i64,
star: bool,
) {
let cfg = Config::load().unwrap_or_default();
if !cfg.remote.enabled {
return;
}
let remote_id = match kind {
FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
FavouriteKind::Track => return,
};
let Ok(Some(remote_id)) = remote_id else {
log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
return;
};
let Some(client) = subsonic_client(&cfg) else {
log::warn!("not syncing favourite: no usable server credentials");
return;
};
std::thread::Builder::new()
.name("koan-fav-sync".into())
.spawn(move || {
let result = match (kind, star) {
(FavouriteKind::Album, true) => client.star_album(&remote_id),
(FavouriteKind::Album, false) => client.unstar_album(&remote_id),
(FavouriteKind::Artist, true) => client.star_artist(&remote_id),
(FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
(FavouriteKind::Track, _) => Ok(()),
};
match result {
Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
}
})
.ok();
}
#[derive(Debug, thiserror::Error)]
pub enum SignInError {
#[error("the server did not accept those credentials: {0}")]
Rejected(#[from] crate::remote::client::SubsonicError),
#[error("could not save the password: {0}")]
Credentials(#[from] crate::credentials::CredentialError),
#[error("could not write the configuration: {0}")]
Config(#[from] crate::config::ConfigError),
}
pub fn set_remote_credentials(
url: &str,
username: &str,
password: &str,
) -> Result<(), SignInError> {
let url = url.trim_end_matches('/');
SubsonicClient::new(url, username, password).ping()?;
crate::credentials::store_password(url, password)?;
let mut values = toml::map::Map::new();
values.insert("enabled".into(), toml::Value::Boolean(true));
values.insert("url".into(), toml::Value::String(url.to_string()));
values.insert("username".into(), toml::Value::String(username.to_string()));
values.insert("password".into(), toml::Value::String(String::new()));
Config::patch_local("remote", &values)?;
Ok(())
}
pub const SUBSONIC_CREDENTIAL_ACCOUNT: &str = "koan-subsonic";
pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
if !cfg.subsonic.password.is_empty() {
return Some(cfg.subsonic.password.clone());
}
crate::credentials::get_password(SUBSONIC_CREDENTIAL_ACCOUNT)
.ok()
.filter(|p| !p.is_empty())
}
pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
if !cfg.remote.enabled || cfg.remote.url.is_empty() {
return None;
}
let password = get_remote_password(cfg)?;
Some(SubsonicAuth::new(
&cfg.remote.url,
&cfg.remote.username,
&password,
))
}
pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
let auth = subsonic_auth(cfg)?;
let mut slot = SUBSONIC_CLIENT.lock();
if let Some((cached, client)) = slot.as_ref()
&& *cached == auth
{
return Some(client.clone());
}
let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
*slot = Some((auth, client.clone()));
Some(client)
}
type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
#[derive(Debug, thiserror::Error)]
pub enum ShareError {
#[error("no remote server is configured")]
NoRemote,
#[error("none of these tracks are on the server, so a link has nothing to point at")]
NothingRemote,
#[error("the server refused to share these: {0}")]
Server(#[from] crate::remote::client::SubsonicError),
#[error(transparent)]
Database(#[from] crate::db::connection::DbError),
}
#[derive(Debug, Clone)]
pub struct ShareOutcome {
pub url: String,
pub id: String,
pub shared: usize,
pub skipped: usize,
}
pub fn create_share(
db: &Database,
cfg: &Config,
track_ids: &[i64],
description: Option<&str>,
) -> Result<ShareOutcome, ShareError> {
let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
if shared == 0 {
return Err(ShareError::NothingRemote);
}
let one_album = rows
.first()
.and_then(|f| f.album_id)
.filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
.and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
let remote_ids: Vec<String> = match one_album {
Some(rid) => vec![rid],
None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
};
let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
let share = client.create_share(&refs, description)?;
let url = share
.url
.clone()
.unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
Ok(ShareOutcome {
url,
id: share.id,
shared,
skipped: track_ids.len().saturating_sub(shared),
})
}
fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
let (remote_id, total): (Option<String>, i64) = conn
.query_row(
"SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
FROM albums al WHERE al.id = ?1",
[album_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok()?;
(total == selected as i64).then_some(remote_id).flatten()
}
pub fn truncate_bytes(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub fn sanitise_filename(s: &str) -> String {
let cleaned: String = s
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect::<String>()
.trim()
.to_string();
truncate_bytes(&cleaned, 240).trim_end().to_string()
}
pub fn cache_path_for_track(
cache_dir: &Path,
track: &queries::TrackRow,
album_date: Option<&str>,
) -> PathBuf {
let artist_dir = sanitise_filename(&track.artist_name);
let year = album_date
.and_then(|d| if d.len() >= 4 { Some(&d[..4]) } else { None })
.map(|y| format!("({}) ", y))
.unwrap_or_default();
let codec = track
.codec
.as_deref()
.map(|c| format!(" [{}]", c))
.unwrap_or_default();
let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
let disc_prefix = match track.disc {
Some(d) if d > 1 => format!("{}-", d),
_ => String::new(),
};
let track_num = track
.track_number
.map(|n| format!("{:02}. ", n))
.unwrap_or_default();
let ext = track
.codec
.as_deref()
.map(|c| c.to_lowercase())
.unwrap_or_else(|| "flac".into());
let filename = sanitise_filename(&format!(
"{}{}{} - {}",
disc_prefix, track_num, track.artist_name, track.title
));
cache_dir
.join(artist_dir)
.join(album_dir)
.join(format!("{}.{}", filename, ext))
}
pub fn resolve_item_path(
db: &Database,
cfg: &Config,
id: i64,
track: &queries::TrackRow,
album_date: Option<&str>,
) -> (PathBuf, LoadState) {
match queries::resolve_playback_path(&db.conn, id) {
Ok(Some(queries::PlaybackSource::Local(p))) => (p, LoadState::Ready),
Ok(Some(queries::PlaybackSource::Cached(p))) => {
let state = if is_cached_audio(&p) {
LoadState::Ready
} else {
LoadState::Pending
};
(p, state)
}
Ok(Some(queries::PlaybackSource::Remote(_))) => {
let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
if dest.exists() && is_cached_audio(&dest) {
(dest, LoadState::Ready)
} else {
(dest, LoadState::Pending)
}
}
_ => {
let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
(dest, LoadState::Pending)
}
}
}
pub fn playlist_item_from_track(
track: &queries::TrackRow,
album_date: Option<&str>,
dest: PathBuf,
load_state: LoadState,
) -> PlaylistItem {
let year = album_date.and_then(|d| {
if d.len() >= 4 {
Some(d[..4].to_string())
} else {
None
}
});
PlaylistItem {
id: QueueItemId::new(),
db_id: Some(track.id),
path: dest,
title: track.title.clone(),
artist: track.artist_name.clone(),
album_artist: track.album_artist_name.clone(),
album: track.album_title.clone(),
year,
codec: track.codec.clone(),
track_number: track.track_number.map(|n| n as i64),
disc: track.disc.map(|n| n as i64),
duration_ms: track.duration_ms.map(|d| d as u64),
load_state,
}
}
pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
use std::collections::HashMap;
let cfg = Config::load().unwrap_or_default();
let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
tracks
.iter()
.map(|track| {
let album_date = match track.album_id {
Some(aid) => album_dates
.entry(aid)
.or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
.clone(),
None => None,
};
let (path, load_state) =
resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
playlist_item_from_track(track, album_date.as_deref(), path, load_state)
})
.collect()
}
pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
let album_date = track
.album_id
.and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
let cfg = Config::load().unwrap_or_default();
let (path, load_state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
let year = album_date.as_deref().and_then(|d| {
if d.len() >= 4 {
Some(d[..4].to_string())
} else {
None
}
});
PlaylistItem {
id: QueueItemId::new(),
db_id: Some(track.id),
path,
title: track.title.clone(),
artist: track.artist_name.clone(),
album_artist: track.album_artist_name.clone(),
album: track.album_title.clone(),
year,
codec: track.codec.clone(),
track_number: track.track_number.map(|n| n as i64),
disc: track.disc.map(|n| n as i64),
duration_ms: track.duration_ms.map(|d| d as u64),
load_state,
}
}
fn is_cached_audio(path: &std::path::Path) -> bool {
const MIN_PLAUSIBLE_BYTES: u64 = 4096;
match std::fs::metadata(path) {
Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
Ok(_) => {
let mut first = [0u8; 1];
match std::fs::File::open(path)
.and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
{
Ok(b) => b != b'{' && b != b'<',
Err(_) => false,
}
}
Err(_) => false,
}
}
pub fn download_track(
db_id: i64,
queue_id: QueueItemId,
tx: &crossbeam_channel::Sender<PlayerCommand>,
log_buf: &Arc<Mutex<Vec<String>>>,
state: &Arc<SharedPlayerState>,
cfg: &Config,
client: &SubsonicClient,
) {
let db = match Database::open_default() {
Ok(db) => db,
Err(e) => {
fail_track(state, tx, queue_id, format!("db error: {}", e));
return;
}
};
let track = match queries::get_track_row(&db.conn, db_id) {
Ok(Some(t)) => t,
_ => {
fail_track(state, tx, queue_id, "track not found".into());
return;
}
};
let remote_id = match &track.remote_id {
Some(rid) => rid.clone(),
None => {
if let Some(ref path) = track.path {
let p = std::path::PathBuf::from(path);
if p.exists() {
state.update_paths(&[(queue_id, p)]);
state.update_load_state(queue_id, LoadState::Ready);
if state.is_cursor(queue_id) {
tx.send(PlayerCommand::TrackReady(queue_id)).ok();
}
return;
}
}
fail_track(
state,
tx,
queue_id,
"not in the library folder, and no remote copy to fetch".into(),
);
return;
}
};
if let Some(ref local_path) = track.path {
let p = std::path::PathBuf::from(local_path);
if p.exists() {
log::info!("download_track: local file exists, using {}", p.display());
state.update_paths(&[(queue_id, p)]);
state.update_load_state(queue_id, LoadState::Ready);
if state.is_cursor(queue_id) {
tx.send(PlayerCommand::TrackReady(queue_id)).ok();
}
return;
}
}
let album_date: Option<String> = track
.album_id
.and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
if dest.exists() && !is_cached_audio(&dest) {
log::warn!(
"discarding non-audio cache entry {} (likely a stored server error)",
dest.display()
);
let _ = std::fs::remove_file(&dest);
}
if dest.exists() {
state.update_paths(&[(queue_id, dest)]);
state.update_load_state(queue_id, LoadState::Ready);
if state.is_cursor(queue_id) {
tx.send(PlayerCommand::TrackReady(queue_id)).ok();
}
return;
}
state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
let bytes_written: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
let progress_state = state.clone();
let progress_qid = queue_id;
let bytes_written_progress = bytes_written.clone();
let progress_tx = tx.clone();
let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
let stream_ready_flag = stream_ready_sent.clone();
let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
bytes_written_progress.store(downloaded, Ordering::Release);
progress_state.update_load_state(
progress_qid,
LoadState::Downloading {
downloaded,
total,
bytes_written: bytes_written_progress.clone(),
},
);
if !stream_ready_flag.load(Ordering::Relaxed)
&& downloaded >= crate::player::state::STREAM_THRESHOLD
{
stream_ready_flag.store(true, Ordering::Relaxed);
progress_tx
.send(PlayerCommand::TrackStreamReady(progress_qid))
.ok();
}
});
if let Err(e) = result {
fail_track(state, tx, queue_id, e.to_string());
push_log(log_buf, format!("x {} — {}", track.title, e));
return;
}
state.update_paths(&[(queue_id, dest.clone())]);
state.update_load_state(queue_id, LoadState::Ready);
if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
log::warn!(
"cached {} but failed to record it ({}) — it will not be evicted",
dest.display(),
e
);
}
push_log(
log_buf,
format!("+ {} — {}", track.title, track.artist_name),
);
if state.is_cursor(queue_id) {
tx.send(PlayerCommand::TrackReady(queue_id)).ok();
}
}
pub(crate) fn fail_track(
state: &Arc<SharedPlayerState>,
tx: &crossbeam_channel::Sender<PlayerCommand>,
queue_id: QueueItemId,
reason: String,
) {
state.update_load_state(queue_id, LoadState::Failed(reason));
if state.is_cursor(queue_id) {
tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
}
}
fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
match log_buf.lock() {
Ok(mut buf) => buf.push(msg),
Err(_) => log::info!("{}", msg),
}
}
pub fn remote_unavailable(cfg: &Config) -> String {
if !cfg.remote.enabled {
return "no remote server is configured".into();
}
if cfg.remote.url.is_empty() {
return "the remote server has no address".into();
}
match remote_password(cfg).1 {
PasswordSource::Missing => "no password is stored for the remote server".into(),
PasswordSource::Unreadable(why) => {
format!("the remote password is in the keychain but could not be read: {why}")
}
PasswordSource::Keychain | PasswordSource::Config => {
"the remote server could not be reached".into()
}
}
}
pub fn spawn_downloads(
pending: Vec<(i64, QueueItemId)>,
tx: crossbeam_channel::Sender<PlayerCommand>,
state: Arc<SharedPlayerState>,
) {
if pending.is_empty() {
return;
}
crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
}
#[cfg(test)]
mod rebuild_tests {
use super::*;
use crate::db::queries::sample_meta;
fn test_db() -> Database {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "on").unwrap();
crate::db::schema::create_tables(&conn).unwrap();
Database { conn }
}
#[test]
fn rebuild_drops_the_index_and_keeps_favourites() {
let db = test_db();
let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
meta.path = Some("/music/windowlicker.flac".into());
let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
db.conn
.execute(
"INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
VALUES (?1, 'test', 'la la la', 0)",
[track_id],
)
.unwrap();
let summary = rebuild_index(&db).unwrap();
assert_eq!(summary.tracks, 1);
assert_eq!(summary.albums, 1);
let tracks: i64 = db
.conn
.query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
.unwrap();
assert_eq!(tracks, 0, "the index is gone");
let favourites: i64 = db
.conn
.query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
.unwrap();
assert_eq!(favourites, 1, "favourites survive — they key on the path");
let lyrics: i64 = db
.conn
.query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
.unwrap();
assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
}
#[test]
fn rebuilding_an_empty_library_is_not_an_error() {
let db = test_db();
let summary = rebuild_index(&db).unwrap();
assert_eq!(summary.tracks, 0);
}
}
#[cfg(test)]
mod share_tests {
use super::*;
use crate::db::queries::sample_meta;
fn test_db() -> Database {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "on").unwrap();
crate::db::schema::create_tables(&conn).unwrap();
Database { conn }
}
fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
let ids: Vec<i64> = ["One", "Two", "Three"]
.iter()
.enumerate()
.map(|(i, title)| {
let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
meta.path = Some(format!("/music/geogaddi/{i}.flac"));
meta.track_number = Some(i as i32 + 1);
queries::upsert_track(&db.conn, &meta).unwrap()
})
.collect();
let album_id: i64 = db
.conn
.query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
r.get(0)
})
.unwrap();
db.conn
.execute(
"UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
[album_id],
)
.unwrap();
(album_id, ids)
}
#[test]
fn whole_album_collapses_to_the_album_link() {
let db = test_db();
let (album_id, ids) = album_of_three(&db);
assert_eq!(
album_remote_id(&db.conn, album_id, ids.len()),
Some("al-1".into())
);
}
#[test]
fn part_of_an_album_does_not() {
let db = test_db();
let (album_id, _) = album_of_three(&db);
assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
}
#[test]
fn a_local_only_album_has_no_link_to_collapse_to() {
let db = test_db();
let (album_id, ids) = album_of_three(&db);
db.conn
.execute(
"UPDATE albums SET remote_id = NULL WHERE id = ?1",
[album_id],
)
.unwrap();
assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
}
}
#[cfg(test)]
mod client_cache_tests {
use super::*;
#[test]
fn one_subsonic_client_is_shared_per_credentials() {
crate::config::isolate_config_for_tests();
let mut cfg = Config::default();
cfg.remote.enabled = true;
cfg.remote.url = "https://shared-client.invalid".into();
cfg.remote.username = "koan".into();
cfg.remote.password = "first".into();
let first = subsonic_client(&cfg).expect("a configured remote yields a client");
let again = subsonic_client(&cfg).expect("a configured remote yields a client");
assert!(
Arc::ptr_eq(&first, &again),
"rebuilding drops the connection pool and re-handshakes TLS per request"
);
cfg.remote.password = "second".into();
let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
assert!(
!Arc::ptr_eq(&first, &relogged),
"new credentials must not keep serving the client signed with the old ones"
);
}
}