Skip to main content

phemios_cd/
lib.rs

1//! Read-only physical CD and CUE audio-image sources, isolated from Tauri, accounts, network and audio output.
2#![warn(missing_docs)]
3mod access;
4mod ape;
5mod cue;
6mod decoded;
7mod discovery;
8mod eject;
9mod image;
10mod process;
11mod stream;
12use cd_da_reader::{AudioSectorReader, CdReader, Toc};
13pub use discovery::{Drive, Library};
14use std::sync::{Arc, Mutex};
15pub use stream::{Chunk, TrackReader};
16
17/// CD-DA stereo sample frames per second.
18pub const SAMPLE_RATE: u32 = 44_100;
19/// Stereo frames carried by one audio sector.
20pub const FRAMES_PER_SECTOR: u64 = 588;
21/// One audio track; disc numbers need not start at one or be contiguous.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Track {
24    /// Physical number from the disc's table of contents.
25    pub number: u8,
26    /// Exact stereo sample frame count, excluding a CD-Extra data-session gap.
27    pub frames: u64,
28    start: u32,
29}
30impl Track {
31    /// Whole milliseconds; source frame positions retain the sub-millisecond remainder.
32    pub fn duration_ms(&self) -> u64 {
33        self.frames * 1000 / u64::from(SAMPLE_RATE)
34    }
35}
36/// Session-only selection. Its token is not a catalogue identity or metadata assertion.
37#[derive(Clone)]
38pub struct Disc {
39    /// Opaque in-process identity, never a device path.
40    pub token: String,
41    /// Audio tracks only; data tracks never enter the playback queue.
42    pub tracks: Vec<Track>,
43    pub(crate) path: String,
44    layout: Vec<(u8, u32, bool)>,
45    leadout: u32,
46    gate: Arc<Mutex<()>>,
47    image: Option<Arc<image::Image>>,
48}
49impl Disc {
50    fn inspect(
51        reader: &impl AudioSectorReader,
52        toc: &Toc,
53        path: String,
54        token: String,
55        gate: Arc<Mutex<()>>,
56    ) -> Result<Self, String> {
57        if toc.tracks.is_empty()
58            || toc.tracks.len() > 99
59            || toc.leadout_lba > 540_000
60            || toc.first_track == 0
61            || toc.last_track > 99
62            || toc.first_track > toc.last_track
63            || toc
64                .tracks
65                .first()
66                .is_none_or(|t| t.number != toc.first_track)
67            || toc.tracks.last().is_none_or(|t| t.number != toc.last_track)
68            || toc.tracks.iter().any(|t| t.start_lba >= toc.leadout_lba)
69            || toc
70                .tracks
71                .windows(2)
72                .any(|w| w[0].number >= w[1].number || w[0].start_lba >= w[1].start_lba)
73        {
74            return Err("Table des pistes du CD invalide.".into());
75        }
76        let mut tracks = Vec::new();
77        for track in toc.tracks.iter().filter(|t| t.is_audio) {
78            // The upstream sector-source API derives CD-Extra bounds without reading audio.
79            let stream = cd_da_reader::open_track_stream(reader, toc, track.number)
80                .map_err(|_| "Limites des pistes du CD invalides.")?;
81            let frames = u64::from(stream.total_sectors()) * FRAMES_PER_SECTOR;
82            if frames == 0 {
83                return Err("Une piste du CD est vide.".into());
84            }
85            tracks.push(Track {
86                number: track.number,
87                frames,
88                start: track.start_lba,
89            });
90        }
91        if tracks.is_empty() {
92            return Err("Aucun CD audio dans ce lecteur.".into());
93        }
94        Ok(Self {
95            token,
96            tracks,
97            path,
98            layout: toc
99                .tracks
100                .iter()
101                .map(|t| (t.number, t.start_lba, t.is_audio))
102                .collect(),
103            leadout: toc.leadout_lba,
104            gate,
105            image: None,
106        })
107    }
108    fn matches(&self, toc: &Toc) -> bool {
109        self.leadout == toc.leadout_lba
110            && self.layout.iter().copied().eq(toc
111                .tracks
112                .iter()
113                .map(|t| (t.number, t.start_lba, t.is_audio)))
114    }
115    pub(crate) fn same_image(&self, other: &Self) -> bool {
116        self.tracks == other.tracks
117            && self
118                .image
119                .as_ref()
120                .zip(other.image.as_ref())
121                .is_some_and(|(a, b)| a.same(b))
122    }
123    /// Selected image title; physical-disc metadata remains unidentified.
124    pub fn title(&self) -> Option<&str> {
125        self.image.as_ref()?.title.as_deref()
126    }
127    /// Explicit CUE performer, never a resolved catalogue/account identity.
128    pub fn performer(&self) -> Option<&str> {
129        self.image.as_ref()?.performer.as_deref()
130    }
131    /// CUE track title and performer when supplied.
132    pub fn track_names(&self, number: u8) -> (Option<&str>, Option<&str>) {
133        self.image
134            .as_ref()
135            .and_then(|image| image.names.get(&number))
136            .map(|(title, performer)| (title.as_deref(), performer.as_deref()))
137            .unwrap_or_default()
138    }
139    /// Stable physical position within this process-only disc selection.
140    pub fn reference(&self, number: u8) -> String {
141        format!("track:cd/{}/{number}", self.token)
142    }
143    /// Resolve only this selection's audio tracks, never a file or another device.
144    pub fn track(&self, reference: &str) -> Result<&Track, String> {
145        self.tracks
146            .iter()
147            .find(|t| self.reference(t.number) == reference)
148            .ok_or_else(|| "La sélection du CD a changé.".into())
149    }
150    /// Hold one source operation at a time; recheck an optical TOC or read retained image handles.
151    /// The callback runs on the caller's I/O worker, never on the audio callback.
152    pub fn with_stream<T>(
153        &self,
154        number: u8,
155        current: impl Fn() -> bool,
156        work: impl FnOnce(&mut TrackReader<'_>) -> Result<T, String>,
157    ) -> Result<T, String> {
158        let _guard = access::lock(&self.gate, current)?;
159        let track = self
160            .tracks
161            .iter()
162            .find(|t| t.number == number)
163            .ok_or("Piste absente du CD.")?;
164        if let Some(image) = &self.image {
165            let reader = image.reader();
166            return work(&mut TrackReader::new(&reader, track));
167        }
168        let reader = CdReader::open_path(&self.path)
169            .map_err(|_| "Le lecteur CD est absent, occupé ou inaccessible.")?;
170        let toc = reader
171            .read_toc()
172            .map_err(|_| "Le CD a été retiré ou ne peut pas être lu.")?;
173        if !self.matches(&toc) {
174            return Err("Le CD a changé. Actualisez la liste des lecteurs.".into());
175        }
176        let track = self
177            .tracks
178            .iter()
179            .find(|t| t.number == number)
180            .ok_or("Piste absente du CD.")?;
181        work(&mut TrackReader::new(&stream::NativeReader(&reader), track))
182    }
183}
184
185#[cfg(test)]
186#[path = "disc_tests.rs"]
187mod tests;