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