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