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