1#[cfg(feature = "runtime")]
2use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
3use std::time::Duration;
4#[cfg(feature = "runtime")]
5use std::time::Instant;
6
7#[cfg(feature = "runtime")]
8use anyhow::Result;
9use serde_json::Value as J;
10#[cfg(feature = "runtime")]
11use serde_json::json;
12
13use crate::EngineConfig;
14#[cfg(feature = "runtime")]
15use crate::{Engine, redact};
16
17#[cfg(feature = "runtime")]
18pub(crate) const PLAYER: &str = include_str!("../js/player.js");
19#[cfg(feature = "runtime")]
20const MUSICKIT_URL: &str = "https://js-cdn.music.apple.com/musickit/v3/musickit.js";
21#[cfg(feature = "runtime")]
22const PLAYER_STACK: usize = 8 * 1024 * 1024;
23const COMMAND_WAIT_MS: u64 = 5000;
24const STOREFRONT_DEADLINE_MS: u64 = 10_000;
25pub const STOREFRONT_DEADLINE: Duration = Duration::from_millis(STOREFRONT_DEADLINE_MS);
26pub const STOREFRONT_BACKOFF: [Duration; 2] =
27 [Duration::from_millis(250), Duration::from_millis(750)];
28pub const QUEUE_SHOWN: usize = 50;
29pub const COMMAND_WAIT: Duration = Duration::from_millis(COMMAND_WAIT_MS);
30#[cfg(all(feature = "runtime", feature = "diagnostics"))]
31const ENGINE_STATUS_EVERY: Duration = Duration::from_secs(1);
32
33pub struct Tokens {
34 pub developer: String,
35 pub user: String,
36}
37
38#[derive(Clone)]
39#[non_exhaustive]
40pub struct PlayerConfig {
41 pub engine: EngineConfig,
42 pub snapshot_interval: Duration,
43 #[cfg(feature = "sabotage")]
44 pub truncate_musickit: bool,
45}
46
47impl Default for PlayerConfig {
48 fn default() -> Self {
49 PlayerConfig {
50 engine: EngineConfig::default(),
51 snapshot_interval: Duration::from_millis(250),
52 #[cfg(feature = "sabotage")]
53 truncate_musickit: false,
54 }
55 }
56}
57
58impl std::fmt::Debug for PlayerConfig {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.debug_struct("PlayerConfig")
61 .field("engine", &self.engine)
62 .field("snapshot_interval", &self.snapshot_interval)
63 .finish_non_exhaustive()
64 }
65}
66
67#[derive(Clone, Debug, PartialEq)]
68#[non_exhaustive]
69pub enum Queue {
70 Songs(Vec<String>),
71 Album(String),
72 Playlist(String),
73}
74
75#[derive(Clone, Copy, Debug, Default, PartialEq)]
76pub enum Repeat {
77 #[default]
78 Off,
79 All,
80 One,
81}
82
83impl Repeat {
84 pub fn as_str(self) -> &'static str {
85 match self {
86 Repeat::Off => "off",
87 Repeat::All => "all",
88 Repeat::One => "one",
89 }
90 }
91
92 pub fn parse(word: &str) -> Option<Repeat> {
93 [Repeat::Off, Repeat::All, Repeat::One]
94 .into_iter()
95 .find(|r| r.as_str() == word)
96 }
97
98 pub fn next(self) -> Repeat {
99 match self {
100 Repeat::Off => Repeat::All,
101 Repeat::All => Repeat::One,
102 Repeat::One => Repeat::Off,
103 }
104 }
105
106 #[cfg(feature = "runtime")]
107 fn musickit(self) -> &'static str {
108 match self {
109 Repeat::Off => "none",
110 Repeat::All => "all",
111 Repeat::One => "one",
112 }
113 }
114}
115
116#[derive(Clone, Debug, PartialEq)]
117#[non_exhaustive]
118pub enum Command {
119 SetQueue { queue: Queue, start: usize },
120 Play,
121 Pause,
122 Seek(f64),
123 Next,
124 Previous,
125 Volume(f32),
126 PlayLater(Queue),
127 Shuffle(bool),
128 Repeat(Repeat),
129 TopUp(Queue),
130 PlayNext(Queue),
131 JumpTo { index: usize, id: String },
132 Shutdown,
133}
134
135#[derive(Clone, Copy, Debug, Default, PartialEq)]
136#[non_exhaustive]
137pub enum PlayState {
138 #[default]
139 Stopped,
140 Loading,
141 Playing,
142 Paused,
143}
144
145#[derive(Clone, Debug, PartialEq)]
146#[non_exhaustive]
147pub enum PlayerError {
148 SignInNeeded(String),
149 Playback(String),
150 LoadFailed(String),
151}
152
153#[cfg(feature = "diagnostics")]
154#[derive(Clone, Debug, PartialEq)]
155pub struct Diagnostics {
156 pub js: J,
157 pub engine: Option<J>,
158}
159
160#[derive(Clone, Debug, Default, PartialEq)]
161pub struct QueueItem {
162 pub id: String,
163 pub title: Option<String>,
164 pub duration: Option<f64>,
165}
166
167#[derive(Clone, Debug, Default, PartialEq)]
168#[non_exhaustive]
169pub struct Snapshot {
170 pub item_id: Option<String>,
171 pub title: Option<String>,
172 pub artist: Option<String>,
173 pub position: Option<f64>,
174 pub duration: Option<f64>,
175 pub state: PlayState,
176 pub error: Option<PlayerError>,
177 pub preview_only: bool,
178 pub applied: u64,
179 pub drains_acked: u64,
180 pub drain_timeouts: u64,
181 pub sent_us: u64,
182 pub skipped_us: u64,
183 pub decrypted_us: u64,
184 pub readiness_dips: u64,
185 pub decrypt_fails: u64,
186 pub output_underflows: u64,
187 pub queue: Vec<QueueItem>,
188 pub queue_position: Option<usize>,
189 pub queue_length: usize,
190 pub shuffle: bool,
191 pub repeat: Repeat,
192 pub background_done: u64,
193 #[cfg(feature = "diagnostics")]
194 pub diagnostics: Option<Diagnostics>,
195}
196
197impl Snapshot {
198 pub fn http_statuses(&self, path: &str) -> J {
199 #[cfg(feature = "diagnostics")]
200 if let Some(J::Object(seen)) = self
201 .diagnostics
202 .as_ref()
203 .and_then(|d| d.engine.as_ref())
204 .map(|e| &e["platform"]["xhr"]["endpoints"])
205 {
206 let ours = seen.iter().filter(|(key, _)| key.contains(path));
207 return ours
208 .map(|(key, v)| (key.clone(), v["statuses"].clone()))
209 .collect();
210 }
211 let _ = path;
212 J::Null
213 }
214}
215
216#[cfg(feature = "runtime")]
217pub(crate) trait Backend {
218 fn tick(&mut self) -> Duration;
219 fn eval_script(&self, name: &str, source: &str) -> Result<()>;
220 fn eval_json(&self, expression: &str) -> Result<J>;
221 fn applied(&self) -> u64;
222 fn item_time(&self) -> Option<f64>;
223 fn output_failed(&self) -> bool;
224 fn output_counts(&self) -> crate::runtime::OutputCounts;
225 #[cfg(feature = "diagnostics")]
226 fn status(&self) -> J;
227}
228
229#[cfg(feature = "runtime")]
230impl Backend for Engine {
231 fn tick(&mut self) -> Duration {
232 Engine::tick(self)
233 }
234 fn eval_script(&self, name: &str, source: &str) -> Result<()> {
235 Engine::eval_script(self, name, source)
236 }
237 fn eval_json(&self, expression: &str) -> Result<J> {
238 Engine::eval_json(self, expression)
239 }
240 fn applied(&self) -> u64 {
241 Engine::global_number(self, "__player", "applied").map_or(0, |n| n as u64)
242 }
243 fn item_time(&self) -> Option<f64> {
244 Engine::item_time(self)
245 }
246 fn output_failed(&self) -> bool {
247 Engine::output_failed(self)
248 }
249 fn output_counts(&self) -> crate::runtime::OutputCounts {
250 Engine::output_counts(self)
251 }
252 #[cfg(feature = "diagnostics")]
253 fn status(&self) -> J {
254 Engine::status(self)
255 }
256}
257
258#[cfg(feature = "runtime")]
259pub struct Player;
260
261#[cfg(feature = "runtime")]
262impl Player {
263 pub fn spawn(
264 mut cfg: PlayerConfig,
265 tokens: Tokens,
266 waker: impl Fn() + Send + 'static,
267 ) -> Result<(Sender<Command>, Receiver<Snapshot>)> {
268 cfg.engine
269 .redact
270 .extend([tokens.developer.clone(), tokens.user.clone()]);
271 let secrets = cfg.engine.redact.clone();
272 let every = cfg.snapshot_interval;
273 let setup = json!({"developerToken": tokens.developer, "userToken": tokens.user, "musickitUrl": MUSICKIT_URL, "build": env!("CARGO_PKG_VERSION"), "commandWaitMs": COMMAND_WAIT_MS, "storefrontDeadlineMs": STOREFRONT_DEADLINE_MS, "storefrontBackoffMs": STOREFRONT_BACKOFF.map(|wait| wait.as_millis() as u64), "queueShown": QUEUE_SHOWN});
274 spawn_with(
275 move || {
276 let engine = Engine::new(cfg.engine)?;
277 #[cfg(feature = "diagnostics")]
278 engine.enable_diagnostics()?;
279 #[cfg(feature = "sabotage")]
280 if cfg.truncate_musickit {
281 engine.sabotage_truncate_scripts();
282 }
283 Ok(engine)
284 },
285 format!("globalThis.__bramley_player = {setup};"),
286 secrets,
287 every,
288 START_STALL,
289 waker,
290 )
291 }
292}
293
294#[cfg(feature = "runtime")]
295struct Exit<W: Fn()> {
296 snapshots: Sender<Snapshot>,
297 waker: W,
298 applied: u64,
299}
300
301#[cfg(feature = "runtime")]
302impl<W: Fn()> Exit<W> {
303 fn send(&self, snap: Snapshot) -> bool {
304 let sent = self.snapshots.send(snap).is_ok();
305 (self.waker)();
306 sent
307 }
308}
309
310#[cfg(feature = "runtime")]
311impl<W: Fn()> Drop for Exit<W> {
312 fn drop(&mut self) {
313 if std::thread::panicking() {
314 self.send(Snapshot {
315 error: Some(PlayerError::LoadFailed("the player thread panicked".into())),
316 applied: self.applied,
317 ..Snapshot::default()
318 });
319 }
320 }
321}
322
323#[cfg(feature = "runtime")]
324pub(crate) fn spawn_with<B: Backend>(
325 make: impl FnOnce() -> Result<B> + Send + 'static,
326 setup: String,
327 secrets: Vec<String>,
328 every: Duration,
329 stall_after: Duration,
330 waker: impl Fn() + Send + 'static,
331) -> Result<(Sender<Command>, Receiver<Snapshot>)> {
332 let (command_tx, commands) = channel();
333 let (snapshots, snapshot_rx) = channel();
334 std::thread::Builder::new()
335 .name("bramley-player".into())
336 .stack_size(PLAYER_STACK)
337 .spawn(move || {
338 let mut exit = Exit {
339 snapshots,
340 waker,
341 applied: 0,
342 };
343 let started = make().and_then(|b| {
344 b.eval_script("config.js", &setup)?;
345 b.eval_script("player.js", PLAYER)?;
346 Ok(b)
347 });
348 match started {
349 Ok(b) => run(b, &commands, &mut exit, every, stall_after, &secrets),
350 Err(e) => {
351 exit.send(Snapshot {
352 error: Some(PlayerError::LoadFailed(redact(&format!("{e:#}"), &secrets))),
353 ..Snapshot::default()
354 });
355 }
356 }
357 })?;
358 Ok((command_tx, snapshot_rx))
359}
360
361#[cfg(feature = "runtime")]
362fn run<B: Backend, W: Fn()>(
363 mut b: B,
364 commands: &Receiver<Command>,
365 exit: &mut Exit<W>,
366 every: Duration,
367 stall_after: Duration,
368 secrets: &[String],
369) {
370 let mut last: Option<Snapshot> = None;
371 let mut watch = StartWatch::default();
372 let mut due = Instant::now();
373 #[cfg(feature = "diagnostics")]
374 let mut engine_due = Instant::now();
375 loop {
376 let wait = b.tick().min(due.saturating_duration_since(Instant::now()));
377 match commands.recv_timeout(wait) {
378 Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => return,
379 Ok(command) => {
380 watch.command(&command);
381 if let Err(e) = b.eval_script("command", &command_js(&command)) {
382 eprintln!("player command failed: {}", redact(&e.to_string(), secrets));
383 }
384 due = Instant::now();
385 continue;
386 }
387 Err(RecvTimeoutError::Timeout) => {}
388 }
389 if b.applied() > exit.applied {
390 due = Instant::now();
391 }
392 if Instant::now() < due {
393 continue;
394 }
395 due = Instant::now() + every;
396 let mut snap = snapshot(&b, secrets);
397 snap.applied = snap.applied.max(exit.applied);
398 exit.applied = snap.applied;
399 watch.observe(&mut snap, stall_after);
400 #[cfg(feature = "diagnostics")]
401 if let Some(d) = snap.diagnostics.as_mut()
402 && Instant::now() >= engine_due
403 {
404 engine_due = Instant::now() + ENGINE_STATUS_EVERY;
405 d.engine = Some(b.status());
406 }
407 if last.as_ref() != Some(&snap) {
408 if !exit.send(snap.clone()) {
409 return;
410 }
411 last = Some(snap);
412 }
413 }
414}
415
416#[cfg(feature = "runtime")]
417pub const OUTPUT_FAILED: &str = "the audio output could not be opened";
418#[cfg(feature = "runtime")]
419pub const START_STALL: Duration = Duration::from_secs(10);
420#[cfg(feature = "runtime")]
421pub const START_STALLED: &str =
422 "playback did not start within 10 s; the license or the network may have failed";
423
424#[cfg(feature = "runtime")]
425#[derive(Default)]
426struct StartWatch {
427 armed: Option<Instant>,
428 last: Option<f64>,
429}
430
431#[cfg(feature = "runtime")]
432impl StartWatch {
433 fn command(&mut self, command: &Command) {
434 match command {
435 Command::SetQueue { .. }
436 | Command::Play
437 | Command::Next
438 | Command::Previous
439 | Command::JumpTo { .. } => self.arm(),
440 Command::Seek(_) if self.armed.is_some() => self.arm(),
441 Command::Pause => self.armed = None,
442 _ => {}
443 }
444 }
445
446 fn arm(&mut self) {
447 self.armed = Some(Instant::now());
448 self.last = None;
449 }
450
451 fn observe(&mut self, snap: &mut Snapshot, limit: Duration) {
452 let Some(armed) = self.armed else { return };
453 if snap.state == PlayState::Playing {
454 if let (Some(now), Some(before)) = (snap.position, self.last)
455 && now > before + 0.01
456 {
457 self.armed = None;
458 return;
459 }
460 self.last = snap.position.or(self.last);
461 }
462 let waiting = matches!(snap.state, PlayState::Playing | PlayState::Loading);
463 if waiting && snap.error.is_none() && armed.elapsed() >= limit {
464 snap.error = Some(PlayerError::Playback(START_STALLED.into()));
465 }
466 }
467}
468
469#[cfg(feature = "runtime")]
470pub(crate) fn command_js(command: &Command) -> String {
471 let (method, arg) = match command {
472 Command::SetQueue { queue, start } => {
473 let mut options = queue_options(queue);
474 options["startWith"] = json!(start);
475 ("setQueue", options)
476 }
477 Command::Play => ("play", J::Null),
478 Command::Pause => ("pause", J::Null),
479 Command::Seek(t) => ("seek", json!(t)),
480 Command::Next => ("next", J::Null),
481 Command::Previous => ("previous", J::Null),
482 Command::Volume(v) => ("volume", json!(v)),
483 Command::PlayLater(queue) => ("playLater", queue_options(queue)),
484 Command::Shuffle(on) => ("shuffle", json!(on)),
485 Command::Repeat(mode) => ("repeat", json!(mode.musickit())),
486 Command::TopUp(queue) => ("topUp", queue_options(queue)),
487 Command::PlayNext(queue) => ("playNext", queue_options(queue)),
488 Command::JumpTo { index, id } => ("jumpTo", json!([index, id])),
489 Command::Shutdown => return String::new(),
490 };
491 format!("__player.{method}({arg});")
492}
493
494#[cfg(feature = "runtime")]
495fn queue_options(queue: &Queue) -> J {
496 match queue {
497 Queue::Songs(ids) => json!({"songs": ids}),
498 Queue::Album(id) => json!({"album": id}),
499 Queue::Playlist(id) => json!({"playlist": id}),
500 }
501}
502
503#[cfg(feature = "runtime")]
504fn snapshot<B: Backend>(b: &B, secrets: &[String]) -> Snapshot {
505 let js = match b.eval_json("__player.state()") {
506 Ok(js) => serde_json::from_str(&redact(&js.to_string(), secrets)).unwrap_or(J::Null),
507 Err(e) => {
508 return Snapshot {
509 error: Some(PlayerError::Playback(redact(&e.to_string(), secrets))),
510 ..Snapshot::default()
511 };
512 }
513 };
514 let text = |v: &J| v.as_str().map(str::to_owned);
515 let seconds = |v: &J| v.as_f64().map(|ms| ms / 1000.0);
516 let queue = &js["queue"];
517 let shown = queue["items"].as_array().map_or(&[][..], Vec::as_slice);
518 let shown = shown.iter().take(QUEUE_SHOWN).map(|item| QueueItem {
519 id: text(&item["id"]).unwrap_or_default(),
520 title: text(&item["title"]),
521 duration: seconds(&item["durationInMillis"]),
522 });
523 let error = if let Some(reason) = text(&js["signInNeeded"]) {
524 Some(PlayerError::SignInNeeded(reason))
525 } else if let Some(phase @ ("musickit-load-failed" | "configure-failed")) = js["phase"].as_str()
526 {
527 Some(PlayerError::LoadFailed(phase.into()))
528 } else if let Some(reason) = text(&js["loadFailed"]) {
529 Some(PlayerError::LoadFailed(reason))
530 } else if b.output_failed() {
531 Some(PlayerError::Playback(OUTPUT_FAILED.into()))
532 } else {
533 text(&js["playbackError"]).map(PlayerError::Playback)
534 };
535 let [
536 drains_acked,
537 drain_timeouts,
538 sent_us,
539 skipped_us,
540 decrypted_us,
541 readiness_dips,
542 decrypt_fails,
543 output_underflows,
544 ] = b.output_counts();
545 Snapshot {
546 item_id: text(&js["item"]["id"]),
547 title: text(&js["item"]["title"]),
548 artist: text(&js["item"]["artistName"]),
549 position: b.item_time(),
550 duration: seconds(&js["item"]["durationInMillis"]),
551 state: match js["playbackState"].as_u64() {
552 Some(1 | 6 | 8 | 9) => PlayState::Loading,
553 Some(2) => PlayState::Playing,
554 Some(3) => PlayState::Paused,
555 _ => PlayState::Stopped,
556 },
557 error,
558 preview_only: js["previewOnly"] == true,
559 applied: js["applied"].as_u64().unwrap_or(0),
560 drains_acked,
561 drain_timeouts,
562 sent_us,
563 skipped_us,
564 decrypted_us,
565 readiness_dips,
566 decrypt_fails,
567 output_underflows,
568 queue: shown.collect(),
569 queue_position: queue["position"].as_u64().map(|at| at as usize),
570 queue_length: queue["length"].as_u64().unwrap_or(0) as usize,
571 shuffle: js["shuffleMode"] == 1,
572 repeat: match js["repeatMode"].as_u64() {
573 Some(1) => Repeat::One,
574 Some(2) => Repeat::All,
575 _ => Repeat::Off,
576 },
577 background_done: js["background"].as_u64().unwrap_or(0),
578 #[cfg(feature = "diagnostics")]
579 diagnostics: Some(Diagnostics {
580 js: serde_json::from_str(&without_queue(&js)).unwrap_or(J::Null),
581 engine: None,
582 }),
583 }
584}
585
586#[cfg(feature = "runtime")]
587#[cfg(feature = "diagnostics")]
588fn without_queue(js: &J) -> String {
589 let mut js = js.clone();
590 if let Some(state) = js.as_object_mut() {
591 state.remove("queue");
592 }
593 js.to_string()
594}