1use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9
10use crate::config::Config;
11use crate::db::connection::Database;
12use crate::db::queries;
13use crate::player::commands::PlayerCommand;
14use crate::player::state::{LoadState, PlaylistItem, QueueItemId, SharedPlayerState};
15use crate::remote::client::{SubsonicAuth, SubsonicClient};
16
17pub fn get_remote_password(cfg: &Config) -> Option<String> {
23 if !cfg.remote.password.is_empty() {
24 return Some(cfg.remote.password.clone());
25 }
26 crate::credentials::get_password(&cfg.remote.url).ok()
28}
29
30pub const SUBSONIC_CREDENTIAL_ACCOUNT: &str = "koan-subsonic";
32
33pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
37 if !cfg.subsonic.password.is_empty() {
38 return Some(cfg.subsonic.password.clone());
39 }
40 crate::credentials::get_password(SUBSONIC_CREDENTIAL_ACCOUNT)
41 .ok()
42 .filter(|p| !p.is_empty())
43}
44
45pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
52 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
53 return None;
54 }
55 let password = get_remote_password(cfg)?;
56 Some(SubsonicAuth::new(
57 &cfg.remote.url,
58 &cfg.remote.username,
59 &password,
60 ))
61}
62
63pub fn subsonic_client(cfg: &Config) -> Option<SubsonicClient> {
65 subsonic_auth(cfg).map(SubsonicClient::from_auth)
66}
67
68#[derive(Debug, thiserror::Error)]
77pub enum ShareError {
78 #[error("no remote server is configured")]
79 NoRemote,
80 #[error("none of these tracks are on the server, so a link has nothing to point at")]
81 NothingRemote,
82 #[error("the server refused to share these: {0}")]
83 Server(#[from] crate::remote::client::SubsonicError),
84 #[error(transparent)]
85 Database(#[from] crate::db::connection::DbError),
86}
87
88#[derive(Debug, Clone)]
90pub struct ShareOutcome {
91 pub url: String,
92 pub id: String,
94 pub shared: usize,
96 pub skipped: usize,
98}
99
100pub fn create_share(
109 db: &Database,
110 cfg: &Config,
111 track_ids: &[i64],
112 description: Option<&str>,
113) -> Result<ShareOutcome, ShareError> {
114 let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
115
116 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
118
119 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
120 if shared == 0 {
121 return Err(ShareError::NothingRemote);
122 }
123
124 let one_album = rows
128 .first()
129 .and_then(|f| f.album_id)
130 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
131 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
132
133 let remote_ids: Vec<String> = match one_album {
134 Some(rid) => vec![rid],
135 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
136 };
137
138 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
139 let share = client.create_share(&refs, description)?;
140
141 let url = share
144 .url
145 .clone()
146 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
147
148 Ok(ShareOutcome {
149 url,
150 id: share.id,
151 shared,
152 skipped: track_ids.len().saturating_sub(shared),
153 })
154}
155
156fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
160 let (remote_id, total): (Option<String>, i64) = conn
161 .query_row(
162 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
163 FROM albums al WHERE al.id = ?1",
164 [album_id],
165 |row| Ok((row.get(0)?, row.get(1)?)),
166 )
167 .ok()?;
168 (total == selected as i64).then_some(remote_id).flatten()
169}
170
171pub fn truncate_bytes(s: &str, max: usize) -> &str {
177 if s.len() <= max {
178 return s;
179 }
180 let mut end = max;
181 while end > 0 && !s.is_char_boundary(end) {
182 end -= 1;
183 }
184 &s[..end]
185}
186
187pub fn sanitise_filename(s: &str) -> String {
190 let cleaned: String = s
191 .chars()
192 .map(|c| match c {
193 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
194 _ => c,
195 })
196 .collect::<String>()
197 .trim()
198 .to_string();
199
200 truncate_bytes(&cleaned, 240).trim_end().to_string()
201}
202
203pub fn cache_path_for_track(
206 cache_dir: &Path,
207 track: &queries::TrackRow,
208 album_date: Option<&str>,
209) -> PathBuf {
210 let artist_dir = sanitise_filename(&track.artist_name);
211
212 let year = album_date
213 .and_then(|d| if d.len() >= 4 { Some(&d[..4]) } else { None })
214 .map(|y| format!("({}) ", y))
215 .unwrap_or_default();
216 let codec = track
217 .codec
218 .as_deref()
219 .map(|c| format!(" [{}]", c))
220 .unwrap_or_default();
221 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
222
223 let disc_prefix = match track.disc {
224 Some(d) if d > 1 => format!("{}-", d),
225 _ => String::new(),
226 };
227 let track_num = track
228 .track_number
229 .map(|n| format!("{:02}. ", n))
230 .unwrap_or_default();
231
232 let ext = track
233 .codec
234 .as_deref()
235 .map(|c| c.to_lowercase())
236 .unwrap_or_else(|| "flac".into());
237
238 let filename = sanitise_filename(&format!(
239 "{}{}{} - {}",
240 disc_prefix, track_num, track.artist_name, track.title
241 ));
242
243 cache_dir
244 .join(artist_dir)
245 .join(album_dir)
246 .join(format!("{}.{}", filename, ext))
247}
248
249pub fn resolve_item_path(
256 db: &Database,
257 cfg: &Config,
258 id: i64,
259 track: &queries::TrackRow,
260 album_date: Option<&str>,
261) -> (PathBuf, LoadState) {
262 match queries::resolve_playback_path(&db.conn, id) {
263 Ok(Some(queries::PlaybackSource::Local(p))) => (p, LoadState::Ready),
264 Ok(Some(queries::PlaybackSource::Cached(p))) => {
269 let state = if is_cached_audio(&p) {
270 LoadState::Ready
271 } else {
272 LoadState::Pending
273 };
274 (p, state)
275 }
276 Ok(Some(queries::PlaybackSource::Remote(_))) => {
277 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
278 if dest.exists() && is_cached_audio(&dest) {
279 (dest, LoadState::Ready)
280 } else {
281 (dest, LoadState::Pending)
282 }
283 }
284 _ => {
285 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
287 (dest, LoadState::Pending)
288 }
289 }
290}
291
292pub fn playlist_item_from_track(
294 track: &queries::TrackRow,
295 album_date: Option<&str>,
296 dest: PathBuf,
297 load_state: LoadState,
298) -> PlaylistItem {
299 let year = album_date.and_then(|d| {
300 if d.len() >= 4 {
301 Some(d[..4].to_string())
302 } else {
303 None
304 }
305 });
306 PlaylistItem {
307 id: QueueItemId::new(),
308 db_id: Some(track.id),
309 path: dest,
310 title: track.title.clone(),
311 artist: track.artist_name.clone(),
312 album_artist: track.album_artist_name.clone(),
313 album: track.album_title.clone(),
314 year,
315 codec: track.codec.clone(),
316 track_number: track.track_number.map(|n| n as i64),
317 disc: track.disc.map(|n| n as i64),
318 duration_ms: track.duration_ms.map(|d| d as u64),
319 load_state,
320 }
321}
322
323pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
330 use std::collections::HashMap;
331
332 let cfg = Config::load().unwrap_or_default();
333 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
334
335 tracks
336 .iter()
337 .map(|track| {
338 let album_date = match track.album_id {
339 Some(aid) => album_dates
340 .entry(aid)
341 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
342 .clone(),
343 None => None,
344 };
345 let (path, load_state) =
346 resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
347 playlist_item_from_track(track, album_date.as_deref(), path, load_state)
348 })
349 .collect()
350}
351
352pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
354 let album_date = track
355 .album_id
356 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
357
358 let cfg = Config::load().unwrap_or_default();
359 let (path, load_state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
360
361 let year = album_date.as_deref().and_then(|d| {
362 if d.len() >= 4 {
363 Some(d[..4].to_string())
364 } else {
365 None
366 }
367 });
368
369 PlaylistItem {
370 id: QueueItemId::new(),
371 db_id: Some(track.id),
372 path,
373 title: track.title.clone(),
374 artist: track.artist_name.clone(),
375 album_artist: track.album_artist_name.clone(),
376 album: track.album_title.clone(),
377 year,
378 codec: track.codec.clone(),
379 track_number: track.track_number.map(|n| n as i64),
380 disc: track.disc.map(|n| n as i64),
381 duration_ms: track.duration_ms.map(|d| d as u64),
382 load_state,
383 }
384}
385
386fn is_cached_audio(path: &std::path::Path) -> bool {
396 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
397 match std::fs::metadata(path) {
398 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
399 Ok(_) => {
400 let mut first = [0u8; 1];
401 match std::fs::File::open(path)
402 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
403 {
404 Ok(b) => b != b'{' && b != b'<',
405 Err(_) => false,
406 }
407 }
408 Err(_) => false,
409 }
410}
411
412pub fn download_track(
419 db_id: i64,
420 queue_id: QueueItemId,
421 tx: &crossbeam_channel::Sender<PlayerCommand>,
422 log_buf: &Arc<Mutex<Vec<String>>>,
423 state: &Arc<SharedPlayerState>,
424 cfg: &Config,
425 client: &SubsonicClient,
426) {
427 let db = match Database::open_default() {
428 Ok(db) => db,
429 Err(e) => {
430 state.update_load_state(queue_id, LoadState::Failed(format!("db error: {}", e)));
431 return;
432 }
433 };
434 let track = match queries::get_track_row(&db.conn, db_id) {
435 Ok(Some(t)) => t,
436 _ => {
437 state.update_load_state(queue_id, LoadState::Failed("track not found".into()));
438 return;
439 }
440 };
441
442 let remote_id = match &track.remote_id {
443 Some(rid) => rid.clone(),
444 None => {
445 if let Some(ref path) = track.path {
447 let p = std::path::PathBuf::from(path);
448 if p.exists() {
449 state.update_paths(&[(queue_id, p)]);
450 state.update_load_state(queue_id, LoadState::Ready);
451 if state.is_cursor(queue_id) {
452 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
453 }
454 return;
455 }
456 }
457 state.update_load_state(queue_id, LoadState::Failed("no remote_id".into()));
458 return;
459 }
460 };
461
462 if let Some(ref local_path) = track.path {
464 let p = std::path::PathBuf::from(local_path);
465 if p.exists() {
466 log::info!("download_track: local file exists, using {}", p.display());
467 state.update_paths(&[(queue_id, p)]);
468 state.update_load_state(queue_id, LoadState::Ready);
469 if state.is_cursor(queue_id) {
470 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
471 }
472 return;
473 }
474 }
475
476 let album_date: Option<String> = track
477 .album_id
478 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
479
480 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
481
482 if dest.exists() && !is_cached_audio(&dest) {
488 log::warn!(
489 "discarding non-audio cache entry {} (likely a stored server error)",
490 dest.display()
491 );
492 let _ = std::fs::remove_file(&dest);
493 }
494 if dest.exists() {
495 state.update_paths(&[(queue_id, dest)]);
496 state.update_load_state(queue_id, LoadState::Ready);
497 if state.is_cursor(queue_id) {
498 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
499 }
500 return;
501 }
502
503 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
506
507 let bytes_written: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
508
509 let progress_state = state.clone();
510 let progress_qid = queue_id;
511 let bytes_written_progress = bytes_written.clone();
512 let progress_tx = tx.clone();
513 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
514 let stream_ready_flag = stream_ready_sent.clone();
515 let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
516 bytes_written_progress.store(downloaded, Ordering::Release);
517 progress_state.update_load_state(
518 progress_qid,
519 LoadState::Downloading {
520 downloaded,
521 total,
522 bytes_written: bytes_written_progress.clone(),
523 },
524 );
525 if !stream_ready_flag.load(Ordering::Relaxed)
526 && downloaded >= crate::player::state::STREAM_THRESHOLD
527 {
528 stream_ready_flag.store(true, Ordering::Relaxed);
529 progress_tx
530 .send(PlayerCommand::TrackStreamReady(progress_qid))
531 .ok();
532 }
533 });
534
535 if let Err(e) = result {
536 state.update_load_state(queue_id, LoadState::Failed(e.to_string()));
537 push_log(log_buf, format!("x {} — {}", track.title, e));
538 return;
539 }
540
541 state.update_paths(&[(queue_id, dest.clone())]);
543 state.update_load_state(queue_id, LoadState::Ready);
544 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
546 log::warn!(
547 "cached {} but failed to record it ({}) — it will not be evicted",
548 dest.display(),
549 e
550 );
551 }
552
553 push_log(
554 log_buf,
555 format!("+ {} — {}", track.title, track.artist_name),
556 );
557
558 if state.is_cursor(queue_id) {
559 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
560 }
561}
562
563fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
566 match log_buf.lock() {
567 Ok(mut buf) => buf.push(msg),
568 Err(_) => log::info!("{}", msg),
569 }
570}
571
572pub fn spawn_downloads(
574 pending: Vec<(i64, QueueItemId)>,
575 tx: crossbeam_channel::Sender<PlayerCommand>,
576 state: Arc<SharedPlayerState>,
577) {
578 let log_buf: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
579 if let Err(e) = std::thread::Builder::new()
580 .name("koan-download".into())
581 .spawn(move || {
582 let cfg = Config::load().unwrap_or_default();
583 let Some(client) = subsonic_client(&cfg) else {
584 log::warn!(
585 "remote not configured -- skipping {} downloads",
586 pending.len()
587 );
588 return;
589 };
590 for (db_id, queue_id) in pending {
591 download_track(db_id, queue_id, &tx, &log_buf, &state, &cfg, &client);
592 }
593 })
594 {
595 log::error!("failed to spawn download thread: {}", e);
596 }
597}
598
599#[cfg(test)]
600mod share_tests {
601 use super::*;
602 use crate::db::queries::sample_meta;
603
604 fn test_db() -> Database {
605 let conn = rusqlite::Connection::open_in_memory().unwrap();
606 conn.pragma_update(None, "foreign_keys", "on").unwrap();
607 crate::db::schema::create_tables(&conn).unwrap();
608 Database { conn }
609 }
610
611 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
613 let ids: Vec<i64> = ["One", "Two", "Three"]
614 .iter()
615 .enumerate()
616 .map(|(i, title)| {
617 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
618 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
619 meta.track_number = Some(i as i32 + 1);
620 queries::upsert_track(&db.conn, &meta).unwrap()
621 })
622 .collect();
623 let album_id: i64 = db
624 .conn
625 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
626 r.get(0)
627 })
628 .unwrap();
629 db.conn
630 .execute(
631 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
632 [album_id],
633 )
634 .unwrap();
635 (album_id, ids)
636 }
637
638 #[test]
639 fn whole_album_collapses_to_the_album_link() {
640 let db = test_db();
641 let (album_id, ids) = album_of_three(&db);
642 assert_eq!(
643 album_remote_id(&db.conn, album_id, ids.len()),
644 Some("al-1".into())
645 );
646 }
647
648 #[test]
649 fn part_of_an_album_does_not() {
650 let db = test_db();
651 let (album_id, _) = album_of_three(&db);
652 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
655 }
656
657 #[test]
658 fn a_local_only_album_has_no_link_to_collapse_to() {
659 let db = test_db();
660 let (album_id, ids) = album_of_three(&db);
661 db.conn
662 .execute(
663 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
664 [album_id],
665 )
666 .unwrap();
667 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
668 }
669}