1use 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
12pub const PLAYBACK_STATE_LIMIT: usize = 512;
14pub const PLAYBACK_PROFILE_LIMIT: usize = 16;
16pub 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PlaybackSource {
28 pub size: u64,
30 pub mtime: u64,
32 pub mtime_ns: Option<u32>,
34}
35
36impl PlaybackSource {
37 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct PlaybackEntry {
105 pub occurrence: u64,
107 pub track: EntityRef,
109 pub source: PlaybackSource,
111}
112
113#[derive(Debug, Clone, PartialEq)]
115pub struct PlaybackSettings {
116 pub normalize: Mode,
118 pub sample_rate: Option<u32>,
120 pub bass_db: f32,
122 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#[derive(Debug, Clone, PartialEq)]
143pub struct PlaybackState {
144 pub owner: UserRef,
146 pub profile: String,
148 pub session_id: String,
150 pub revision: u64,
152 pub entries: Vec<PlaybackEntry>,
154 pub current_occurrence: Option<u64>,
156 pub position_ms: u64,
158 pub settings: PlaybackSettings,
160 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
172pub 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 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 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 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 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;