koan-core 0.64.1

Core library for koan — bit-perfect music player. Audio engine, player, database, format strings.
Documentation
//! Library tracks as queue items.

use std::path::{Path, PathBuf};

use crate::config::Config;
use crate::db::connection::Database;
use crate::db::queries;
use crate::player::state::{ItemState, PlaylistItem, QueueItemId};

use super::*;

/// Resolve a track to its path + load state (without downloading).
/// Returns (path, `ItemState::Ready`) for local/cached, (cache path, `ItemState::Pending`)
/// for remote — a track with no copy here yet has to be fetched before it plays.
/// A copy found in the cache that the row does not name is added to `found`,
/// for the caller to record.
fn resolve_item_path(
    found: &mut Vec<(i64, PathBuf)>,
    cfg: &Config,
    track: &queries::TrackRow,
    remote_url: Option<&str>,
    album_date: Option<&str>,
) -> (PathBuf, ItemState) {
    match queries::choose_playback_source(
        track.path.as_deref(),
        track.cached_path.as_deref(),
        remote_url,
    ) {
        Some(queries::PlaybackSource::Local(p)) => (p, ItemState::Ready),
        // A cache entry is only as good as its contents. Older builds could
        // store a Subsonic error body here, which reports Ready and then fails
        // to decode forever; treating it as Pending sends it back through the
        // download path, which discards it and re-fetches.
        Some(queries::PlaybackSource::Cached(p)) => {
            let state = if is_cached_audio(&p) {
                ItemState::Ready
            } else {
                ItemState::Pending
            };
            (p, state)
        }
        Some(queries::PlaybackSource::Remote(_)) => {
            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
            if dest.exists() && is_cached_audio(&dest) {
                if track.cached_path.as_deref() != Some(&*dest.to_string_lossy()) {
                    found.push((track.id, dest.clone()));
                }
                (dest, ItemState::Ready)
            } else {
                (dest, ItemState::Pending)
            }
        }
        _ => {
            // Fallback: construct a cache path and mark pending.
            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
            (dest, ItemState::Pending)
        }
    }
}

/// Build a PlaylistItem from a TrackRow + album date + resolved path + load state.
pub fn playlist_item_from_track(
    track: &queries::TrackRow,
    album_date: Option<&str>,
    dest: PathBuf,
    state: ItemState,
) -> PlaylistItem {
    let year = album_date.and_then(year_of).map(str::to_string);
    PlaylistItem {
        playlist_entry_id: None,
        id: QueueItemId::new(),
        db_id: Some(track.id),
        uid: None,
        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),
        state,
        played: false,
    }
}

/// Where the queue's tracks are in the library now, for the items whose row
/// is not the one their uid names: a row deleted, or its id since given to
/// another track. A uid no row holds is looked for by the item's file, which
/// finds a local track merged into another or indexed again under a new uid;
/// failing that the item is unlinked, and plays and is drawn from its own
/// metadata until a sync brings the track back. One query for the uids, and
/// one per item no uid finds.
pub fn queue_relinks(
    db: &Database,
    state: &crate::player::state::SharedPlayerState,
) -> Vec<(crate::player::state::QueueItemId, Option<i64>)> {
    let items = state.queue_uids();
    if items.is_empty() {
        return Vec::new();
    }
    let Ok(rows) = queries::uids::ids_for_uids(
        &db.conn,
        queries::uids::UidKind::Track,
        items.iter().map(|(_, _, uid, _)| uid.as_str()),
    ) else {
        return Vec::new();
    };
    items
        .into_iter()
        .filter_map(|(id, held, uid, path)| {
            let now = rows.get(&uid).copied().or_else(|| {
                queries::track_id_by_path(&db.conn, &path.to_string_lossy())
                    .ok()
                    .flatten()
            });
            (now != held).then_some((id, now))
        })
        .collect()
}

/// Build playlist items for many tracks at once.
///
/// One config read and one query for the whole batch, whatever its size: the
/// rows already say where each track's file and download are, and what they
/// leave out — the stream URL and the album's date — is read for all of them
/// together.
pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
    playlist_items_with(db, &Config::load().unwrap_or_default(), tracks)
}

pub(super) fn playlist_items_with(
    db: &Database,
    cfg: &Config,
    tracks: &[queries::TrackRow],
) -> Vec<PlaylistItem> {
    let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
    let extras = queries::queue_item_extras(&db.conn, &ids).unwrap_or_default();

    let uids =
        queries::uids::uids_for(&db.conn, queries::uids::UidKind::Track, ids.iter().copied())
            .unwrap_or_default();

    let mut found = Vec::new();
    let items = tracks
        .iter()
        .map(|track| {
            let extra = extras.get(&track.id);
            let remote_url = extra.and_then(|e| e.remote_url.as_deref());
            let album_date = extra.and_then(|e| e.album_date.as_deref());
            let (path, state) = resolve_item_path(&mut found, cfg, track, remote_url, album_date);
            PlaylistItem {
                uid: uids.get(&track.id).cloned(),
                ..playlist_item_from_track(track, album_date, path, state)
            }
        })
        .collect();
    // Bookkeeping an enqueue should not wait behind a scan for: skipped while
    // a writer holds the lock, and found again the next time the track is
    // queued, or at launch (`adopt_cached_files`).
    if !found.is_empty() {
        let recorded = crate::db::connection::without_waiting(&db.conn, |conn| {
            queries::atomically(conn, || {
                found.iter().try_for_each(|(id, path)| {
                    queries::set_cached_path(conn, *id, &path.to_string_lossy())
                })
            })
        });
        if let Err(e) = recorded {
            log::debug!(
                "left {} cached file(s) unrecorded for now: {e}",
                found.len()
            );
        }
    }
    items
}

/// Record a file found already in the cache as the track's download, unless
/// it already is. A file can outlive its record — a row re-derived or merged
/// without it, a crash between the rename and the write — and an unrecorded
/// one reads as not on this machine and is never evicted.
pub(super) fn adopt_cached(conn: &rusqlite::Connection, track: &queries::TrackRow, dest: &Path) {
    let dest = dest.to_string_lossy();
    if track.cached_path.as_deref() == Some(&*dest) {
        return;
    }
    if let Err(e) = queries::set_cached_path(conn, track.id, &dest) {
        log::warn!("found {dest} in the cache but failed to record it ({e})");
    }
}