Skip to main content

koan_core/
helpers.rs

1//! Shared helpers used by downstream crates (koan-tui, koan-server, koan-cli).
2//!
3//! These functions provide common functionality for building playlist items,
4//! resolving track paths, downloading remote tracks, and building Subsonic clients.
5
6use 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
17// ---------------------------------------------------------------------------
18// Subsonic client builder
19// ---------------------------------------------------------------------------
20
21/// Get the remote password from config, falling back to Keychain for backwards compat.
22pub fn get_remote_password(cfg: &Config) -> Option<String> {
23    if !cfg.remote.password.is_empty() {
24        return Some(cfg.remote.password.clone());
25    }
26    // Fallback to Keychain for users who set up before the config change.
27    crate::credentials::get_password(&cfg.remote.url).ok()
28}
29
30/// Keychain account holding the Subsonic API shared secret.
31pub const SUBSONIC_CREDENTIAL_ACCOUNT: &str = "koan-subsonic";
32
33/// Shared secret for koan's own Subsonic API. Config first, then the keychain.
34///
35/// Deliberately not `get_remote_password` — see `SubsonicConfig`.
36pub 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
45/// Upstream Subsonic credentials from the merged config, returning `None` if
46/// remote is disabled or has no URL configured.
47///
48/// Prefer this over `subsonic_client` when only a signed URL is needed:
49/// building a client constructs blocking `reqwest` clients, which panics from
50/// inside a tokio runtime.
51pub 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
63/// Build a `SubsonicClient` from the merged config. Never call from async code.
64pub fn subsonic_client(cfg: &Config) -> Option<SubsonicClient> {
65    subsonic_auth(cfg).map(SubsonicClient::from_auth)
66}
67
68// ---------------------------------------------------------------------------
69// Sharing
70// ---------------------------------------------------------------------------
71
72/// Why a share link could not be made. Each variant is something the user can
73/// act on, which is the point — every caller used to collapse these into
74/// "local-only tracks can't be shared" and send people looking in the wrong
75/// place.
76#[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/// A created share link, and how much of the request it covers.
89#[derive(Debug, Clone)]
90pub struct ShareOutcome {
91    pub url: String,
92    /// The server's own ID for the share, for callers that manage them.
93    pub id: String,
94    /// Tracks the server knows about, which went into the link.
95    pub shared: usize,
96    /// Tracks with no copy on the server, left out of it.
97    pub skipped: usize,
98}
99
100/// Create a public share link on the remote server for these tracks.
101///
102/// A link points at the server, so only tracks the server knows about can go in
103/// it. A mixed selection shares the part that can be shared and reports the
104/// rest rather than failing whole — half a link beats none, as long as the
105/// caller says which half.
106///
107/// Network-bound. Callers keep it off whatever thread draws.
108pub 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    // One query, not one per track: sharing an artist is thousands of tracks.
117    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    // A whole record shares as one album rather than as N tracks — the server
125    // renders it as the album it is, and the link survives the user adding to
126    // it. Only when the selection is genuinely the whole thing.
127    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    // Navidrome does not always hand back a URL, and a share with no link is
142    // useless to the caller — the ID is enough to build it.
143    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
156/// The album's own remote ID, but only when `selected` covers every track on
157/// it. Sharing an album link for half an album would hand out more than the
158/// user picked.
159fn 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
171// ---------------------------------------------------------------------------
172// Path utilities
173// ---------------------------------------------------------------------------
174
175/// Truncate a string to at most `max` bytes, cutting on a char boundary.
176pub 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
187/// Sanitise and truncate a string for use as a path component.
188/// Strips illegal chars and caps at 240 bytes (macOS 255-byte filename limit minus room for ext).
189pub 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
203/// Build a structured cache path for a track:
204///   cache_dir/Album Artist/(Year) Album [Codec]/01. Track Artist - Title.ext
205pub 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
249// ---------------------------------------------------------------------------
250// Track resolution
251// ---------------------------------------------------------------------------
252
253/// Resolve a track to its path + load state (without downloading).
254/// Returns (path, LoadState::Ready) for local/cached, (cache_path, LoadState::Pending) for remote.
255pub 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        // A cache entry is only as good as its contents. Older builds could
265        // store a Subsonic error body here, which reports Ready and then fails
266        // to decode forever; treating it as Pending sends it back through the
267        // download path, which discards it and re-fetches.
268        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            // Fallback: construct a cache path and mark pending.
286            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
287            (dest, LoadState::Pending)
288        }
289    }
290}
291
292/// Build a PlaylistItem from a TrackRow + album date + resolved path + load state.
293pub 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
323/// Build playlist items for many tracks at once.
324///
325/// `track_to_playlist_item` loads the config on every call, which means
326/// reading and parsing `config.toml` and `config.local.toml` once per track —
327/// the reason a large add crawled. This loads it once and memoises album dates,
328/// so a thousand-track add costs one config read instead of a thousand.
329pub 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
352/// Build a PlaylistItem from a TrackRow, resolving its path automatically.
353pub 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
386// ---------------------------------------------------------------------------
387// Download
388// ---------------------------------------------------------------------------
389
390/// Whether a cached file plausibly holds audio.
391///
392/// A stored Subsonic error is a few hundred bytes of JSON or XML; no real
393/// encoded track comes close to that, so the size check alone settles almost
394/// every case and the leading byte covers the rest.
395fn 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
412/// Resolve a track to a playable file, downloading from remote if needed.
413///
414/// Resolution order:
415/// 1. Local library path (DB `path` field) -- use directly if file exists
416/// 2. Cache path -- use if already downloaded
417/// 3. Download from remote to cache -- stream while downloading
418pub 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            // No remote_id -- check if the local file exists.
446            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    // 1. Check if the local library file exists.
463    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    // 2. Already cached.
483    //
484    // Older builds could write a Subsonic error body here as if it were audio,
485    // leaving a tiny JSON file that reports Ready and then fails to decode
486    // forever. Treat those as absent so they get re-fetched.
487    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    // 3. Download from remote. The queue item points at the in-progress file so
504    // the streaming pump reads bytes as they land; it flips to `dest` on success.
505    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    // Download succeeded.
542    state.update_paths(&[(queue_id, dest.clone())]);
543    state.update_load_state(queue_id, LoadState::Ready);
544    // Without this row the file is invisible to cache eviction and never reclaimed.
545    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
563/// Append to the TUI log pane, tolerating a poisoned lock — a download worker
564/// must not die because some other thread panicked while holding it.
565fn 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
572/// Spawn background downloads for remote tracks with LoadState::Pending.
573pub 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    /// Three tracks on one album; the album carries a remote ID.
612    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        // Sharing an album link for two of three tracks would hand out a track
653        // the user did not pick.
654        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}