aede_core/
user_playback.rs

1//! Owner-private native-player checkpoints in the optional personal-store table.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs::Metadata;
5
6use super::{EntityRef, Merge, UserData, UserRef};
7use crate::json::Json;
8use crate::model::{AudioFile, Catalog, EntityKind};
9use crate::playback::normalization::Mode;
10use crate::store::StoreError;
11
12/// Maximum native-player checkpoints in one personal store.
13pub const PLAYBACK_STATE_LIMIT: usize = 512;
14/// Maximum named player profiles belonging to one immutable owner.
15pub const PLAYBACK_PROFILE_LIMIT: usize = 16;
16/// Maximum ordered occurrences retained by one player profile.
17pub const PLAYBACK_ENTRY_LIMIT: usize = 64;
18
19const MAX_EXACT_INTEGER: u64 = (1u64 << 53) - 1;
20const MAX_DATE: u64 = 253_402_300_799;
21
22/// File evidence captured when selecting a resumable source.
23///
24/// This is a size/timestamp identity guard, not a checksum. No relocation is
25/// inferred: the accompanying track reference must still name the same path.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PlaybackSource {
28    /// Original encoded-file size in bytes.
29    pub size: u64,
30    /// Modification timestamp, whole Unix seconds.
31    pub mtime: u64,
32    /// Nanosecond fraction, when the source/catalog supplies precise evidence.
33    pub mtime_ns: Option<u32>,
34}
35
36impl PlaybackSource {
37    /// Capture catalog evidence without reading or changing an audio file.
38    pub fn from_catalog(catalog: &Catalog, file: &AudioFile) -> Self {
39        Self {
40            size: file.size,
41            mtime: file.mtime,
42            mtime_ns: catalog.file_mtime_subseconds.get(&file.path).copied(),
43        }
44    }
45
46    /// Capture precise evidence from an already opened source descriptor.
47    ///
48    /// Unavailable or pre-epoch timestamps are refused rather than turned into
49    /// an ambiguous zero identity. The caller must open/validate the source.
50    pub fn from_metadata(metadata: &Metadata) -> Result<Self, StoreError> {
51        if !metadata.is_file() {
52            return Err(StoreError::Invalid(
53                "playback source must be a regular file",
54            ));
55        }
56        let modified = metadata
57            .modified()?
58            .duration_since(std::time::UNIX_EPOCH)
59            .map_err(|_| StoreError::Invalid("playback source timestamp predates the epoch"))?;
60        let source = Self {
61            size: metadata.len(),
62            mtime: modified.as_secs(),
63            mtime_ns: Some(modified.subsec_nanos()),
64        };
65        if !source.valid() {
66            return Err(StoreError::Invalid("invalid playback source identity"));
67        }
68        Ok(source)
69    }
70
71    /// Whether the same current catalog row still carries matching evidence.
72    pub fn matches_catalog(&self, catalog: &Catalog, file: &AudioFile) -> bool {
73        self.valid()
74            && self.size == file.size
75            && self.mtime == file.mtime
76            && self.mtime_ns.is_none_or(|fraction| {
77                catalog.file_mtime_subseconds.get(&file.path) == Some(&fraction)
78            })
79    }
80
81    /// Recheck the already opened file before starting a resumed decode.
82    pub fn matches_metadata(&self, metadata: &Metadata) -> bool {
83        Self::from_metadata(metadata).is_ok_and(|current| {
84            self.valid()
85                && self.size == current.size
86                && self.mtime == current.mtime
87                && self
88                    .mtime_ns
89                    .is_none_or(|fraction| current.mtime_ns == Some(fraction))
90        })
91    }
92
93    fn valid(&self) -> bool {
94        self.size <= MAX_EXACT_INTEGER
95            && self.mtime <= MAX_DATE
96            && self
97                .mtime_ns
98                .is_none_or(|fraction| fraction < 1_000_000_000)
99    }
100}
101
102/// One stable queue occurrence; repeating a track uses another occurrence ID.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct PlaybackEntry {
105    /// Positive, exact-JSON integer identifying this occurrence within its profile.
106    pub occurrence: u64,
107    /// Path-based track reference, independent of renumbered catalog IDs.
108    pub track: EntityRef,
109    /// Evidence that prevents resuming a changed source as the old occurrence.
110    pub source: PlaybackSource,
111}
112
113/// Processing settings restored explicitly with a native-player profile.
114#[derive(Debug, Clone, PartialEq)]
115pub struct PlaybackSettings {
116    /// Loudness scope; these settings never modify encoded source files.
117    pub normalize: Mode,
118    /// Requested PCM output rate, or the source-selected default.
119    pub sample_rate: Option<u32>,
120    /// Broad bass shelf in decibels, -12 through +12.
121    pub bass_db: f32,
122    /// Broad treble shelf in decibels, -12 through +12.
123    pub treble_db: f32,
124}
125
126impl Default for PlaybackSettings {
127    fn default() -> Self {
128        Self {
129            normalize: Mode::Track,
130            sample_rate: None,
131            bass_db: 0.0,
132            treble_db: 0.0,
133        }
134    }
135}
136
137/// One private named checkpoint, resumed only by an explicit client request.
138///
139/// Persistence does not create a listen or restart playback. The server owns
140/// authorization, writer locking and compare-before-write of session/revision;
141/// a stale socket must not overwrite a newer session with the same profile.
142#[derive(Debug, Clone, PartialEq)]
143pub struct PlaybackState {
144    /// Immutable account owner; mutable account login names are never used.
145    pub owner: UserRef,
146    /// Case-sensitive 1–64 ASCII letters, digits, underscores or hyphens.
147    pub profile: String,
148    /// Opaque server-created session identity, independent of queue contents.
149    pub session_id: String,
150    /// Positive exact-JSON revision used to reject stale updates.
151    pub revision: u64,
152    /// Finite ordered selection, including intentional repeated tracks.
153    pub entries: Vec<PlaybackEntry>,
154    /// Selected occurrence, or no selection after natural queue completion.
155    pub current_occurrence: Option<u64>,
156    /// Acknowledged source position for the selected occurrence, in milliseconds.
157    pub position_ms: u64,
158    /// Processing settings for a deliberate resume.
159    pub settings: PlaybackSettings,
160    /// Last successful checkpoint publication, Unix seconds.
161    pub updated_at: u64,
162}
163
164fn identifier_valid(value: &str, limit: usize) -> bool {
165    !value.is_empty()
166        && value.len() <= limit
167        && value
168            .bytes()
169            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
170}
171
172/// Whether a native-player profile uses the bounded case-sensitive spelling.
173pub fn valid_playback_profile(profile: &str) -> bool {
174    identifier_valid(profile, 64)
175}
176
177fn state_valid(state: &PlaybackState) -> bool {
178    let mut occurrences = BTreeSet::new();
179    !state.owner.trim().is_empty()
180        && state.owner.len() <= 512
181        && !state.owner.contains('\0')
182        && valid_playback_profile(&state.profile)
183        && identifier_valid(&state.session_id, 128)
184        && (1..=MAX_EXACT_INTEGER).contains(&state.revision)
185        && state.entries.len() <= PLAYBACK_ENTRY_LIMIT
186        && state.entries.iter().all(|entry| {
187            (1..=MAX_EXACT_INTEGER).contains(&entry.occurrence)
188                && occurrences.insert(entry.occurrence)
189                && entry.track.kind == EntityKind::Track
190                && !entry.track.key.trim().is_empty()
191                && entry.track.key.len() <= 16384
192                && !entry.track.key.contains('\0')
193                && entry.source.valid()
194        })
195        && state
196            .current_occurrence
197            .is_none_or(|occurrence| occurrences.contains(&occurrence))
198        && (state.current_occurrence.is_some() || state.position_ms == 0)
199        && state.position_ms <= MAX_EXACT_INTEGER
200        && state.updated_at <= MAX_DATE
201        && state
202            .settings
203            .sample_rate
204            .is_none_or(|rate| (8000..=192_000).contains(&rate))
205        && state.settings.bass_db.is_finite()
206        && (-12.0..=12.0).contains(&state.settings.bass_db)
207        && state.settings.treble_db.is_finite()
208        && (-12.0..=12.0).contains(&state.settings.treble_db)
209}
210
211pub(super) fn validate(data: &UserData) -> Result<(), StoreError> {
212    if data.playback_states.len() > PLAYBACK_STATE_LIMIT {
213        return Err(StoreError::Invalid(
214            "personal playback state limit exceeded",
215        ));
216    }
217    let mut owners = BTreeMap::new();
218    let mut profiles = BTreeSet::new();
219    for state in &data.playback_states {
220        let count = owners.entry(&state.owner).or_insert(0usize);
221        *count += 1;
222        if *count > PLAYBACK_PROFILE_LIMIT
223            || !profiles.insert((&state.owner, &state.profile))
224            || !state_valid(state)
225        {
226            return Err(StoreError::Invalid(
227                "invalid, duplicate or oversized personal playback state",
228            ));
229        }
230    }
231    Ok(())
232}
233
234impl UserData {
235    /// Read only one owner's named checkpoint; another owner cannot select it.
236    pub fn playback_state(&self, owner: &str, profile: &str) -> Option<&PlaybackState> {
237        self.playback_states
238            .iter()
239            .find(|state| state.owner == owner && state.profile == profile)
240    }
241
242    /// Add or replace exactly one owner's named checkpoint, validating before mutation.
243    ///
244    /// Callers must authorize the supplied owner and compare the existing
245    /// session/revision while holding the installation's writer lock. This
246    /// domain operation does not implicitly acquire a second lock or save.
247    pub fn set_playback_state(&mut self, state: PlaybackState) -> Result<(), StoreError> {
248        validate(self)?;
249        if !state_valid(&state) {
250            return Err(StoreError::Invalid("invalid personal playback state"));
251        }
252        if let Some(existing) = self
253            .playback_states
254            .iter_mut()
255            .find(|existing| existing.owner == state.owner && existing.profile == state.profile)
256        {
257            *existing = state;
258        } else {
259            if self.playback_states.len() >= PLAYBACK_STATE_LIMIT
260                || self
261                    .playback_states
262                    .iter()
263                    .filter(|existing| existing.owner == state.owner)
264                    .count()
265                    >= PLAYBACK_PROFILE_LIMIT
266            {
267                return Err(StoreError::Invalid(
268                    "personal playback state limit exceeded",
269                ));
270            }
271            self.playback_states.push(state);
272        }
273        Ok(())
274    }
275
276    /// Forget one profile for this owner, leaving other owners/profiles untouched.
277    pub fn clear_playback_state(&mut self, owner: &str, profile: &str) -> bool {
278        let previous = self.playback_states.len();
279        self.playback_states
280            .retain(|state| state.owner != owner || state.profile != profile);
281        self.playback_states.len() != previous
282    }
283}
284
285pub(super) fn write_table(data: &UserData, root: &mut Json) {
286    if data.playback_states.is_empty() {
287        return;
288    }
289    root.set(
290        "playback_states",
291        Json::Arr(data.playback_states.iter().map(to_json).collect()),
292    );
293}
294
295fn to_json(state: &PlaybackState) -> Json {
296    let mut row = Json::obj();
297    row.set("owner", state.owner.as_str().into());
298    row.set("profile", state.profile.as_str().into());
299    row.set("session_id", state.session_id.as_str().into());
300    row.set("revision", state.revision.into());
301    row.set("updated_at", state.updated_at.into());
302    row.set("position_ms", state.position_ms.into());
303    if let Some(occurrence) = state.current_occurrence {
304        row.set("current_occurrence", occurrence.into());
305    }
306    let mut settings = Json::obj();
307    settings.set(
308        "normalize",
309        match state.settings.normalize {
310            Mode::Off => "off",
311            Mode::Track => "track",
312            Mode::Album => "album",
313        }
314        .into(),
315    );
316    if let Some(rate) = state.settings.sample_rate {
317        settings.set("sample_rate", rate.into());
318    }
319    settings.set("bass_db", f64::from(state.settings.bass_db).into());
320    settings.set("treble_db", f64::from(state.settings.treble_db).into());
321    row.set("settings", settings);
322    row.set(
323        "entries",
324        Json::Arr(
325            state
326                .entries
327                .iter()
328                .map(|entry| {
329                    let mut item = Json::obj();
330                    item.set("occurrence", entry.occurrence.into());
331                    item.set("track", entry.track.to_token().into());
332                    item.set("size", entry.source.size.into());
333                    item.set("mtime", entry.source.mtime.into());
334                    if let Some(fraction) = entry.source.mtime_ns {
335                        item.set("mtime_ns", fraction.into());
336                    }
337                    item
338                })
339                .collect(),
340        ),
341    );
342    row
343}
344
345fn required_text(row: &Json, name: &str) -> Result<String, StoreError> {
346    row.field_str(name).ok_or(StoreError::Invalid(
347        "missing or malformed playback state field",
348    ))
349}
350
351fn required_number(row: &Json, name: &str) -> Result<u64, StoreError> {
352    row.field_u64(name).ok_or(StoreError::Invalid(
353        "missing or malformed playback state number",
354    ))
355}
356
357fn optional_number(row: &Json, name: &str) -> Result<Option<u64>, StoreError> {
358    match row.get(name) {
359        None => Ok(None),
360        Some(value) => value.as_u64().map(Some).ok_or(StoreError::Invalid(
361            "malformed optional playback state number",
362        )),
363    }
364}
365
366fn optional_u32(row: &Json, name: &str) -> Result<Option<u32>, StoreError> {
367    optional_number(row, name)?
368        .map(|value| {
369            u32::try_from(value)
370                .map_err(|_| StoreError::Invalid("playback state number exceeds u32"))
371        })
372        .transpose()
373}
374
375pub(super) fn read_states(root: &Json) -> Result<Vec<PlaybackState>, StoreError> {
376    let rows = root
377        .get("playback_states")
378        .and_then(Json::as_arr)
379        .unwrap_or(&[]);
380    if rows.len() > PLAYBACK_STATE_LIMIT {
381        return Err(StoreError::Invalid(
382            "personal playback state limit exceeded",
383        ));
384    }
385    let mut data = UserData::default();
386    for row in rows {
387        let settings = row
388            .get("settings")
389            .ok_or(StoreError::Invalid("missing playback settings"))?;
390        let normalize = match settings.get("normalize").and_then(Json::as_str) {
391            Some("off") => Mode::Off,
392            Some("track") => Mode::Track,
393            Some("album") => Mode::Album,
394            _ => return Err(StoreError::Invalid("invalid playback normalization scope")),
395        };
396        let tone = |name| {
397            settings
398                .field_f64(name)
399                .filter(|value| value.is_finite() && (-12.0..=12.0).contains(value))
400                .map(|value| value as f32)
401                .ok_or(StoreError::Invalid("invalid playback tone setting"))
402        };
403        let entries = row
404            .get("entries")
405            .and_then(Json::as_arr)
406            .filter(|entries| entries.len() <= PLAYBACK_ENTRY_LIMIT)
407            .ok_or(StoreError::Invalid("invalid personal playback entries"))?
408            .iter()
409            .map(|entry| {
410                Ok(PlaybackEntry {
411                    occurrence: required_number(entry, "occurrence")?,
412                    track: EntityRef::parse_token(&required_text(entry, "track")?)
413                        .ok_or(StoreError::Invalid("invalid playback track reference"))?,
414                    source: PlaybackSource {
415                        size: required_number(entry, "size")?,
416                        mtime: required_number(entry, "mtime")?,
417                        mtime_ns: optional_u32(entry, "mtime_ns")?,
418                    },
419                })
420            })
421            .collect::<Result<Vec<_>, StoreError>>()?;
422        data.playback_states.push(PlaybackState {
423            owner: required_text(row, "owner")?,
424            profile: required_text(row, "profile")?,
425            session_id: required_text(row, "session_id")?,
426            revision: required_number(row, "revision")?,
427            entries,
428            current_occurrence: optional_number(row, "current_occurrence")?,
429            position_ms: required_number(row, "position_ms")?,
430            settings: PlaybackSettings {
431                normalize,
432                sample_rate: optional_u32(settings, "sample_rate")?,
433                bass_db: tone("bass_db")?,
434                treble_db: tone("treble_db")?,
435            },
436            updated_at: required_number(row, "updated_at")?,
437        });
438    }
439    validate(&data)?;
440    Ok(data.playback_states)
441}
442
443pub(super) fn merge(into: &mut UserData, incoming: Vec<PlaybackState>, report: &mut Merge) {
444    // An import must not rewind a current device or resurrect an older queue.
445    // Full backup restoration still replaces this nested store explicitly.
446    for state in incoming {
447        if into.playback_state(&state.owner, &state.profile).is_some() {
448            report.kept += 1;
449        } else {
450            into.playback_states.push(state);
451            report.added += 1;
452        }
453    }
454}
455
456#[cfg(test)]
457#[path = "user_playback_tests.rs"]
458mod tests;