Skip to main content

media_remote/high_level/
now_playing_perl.rs

1use std::{
2    collections::HashMap,
3    io::{BufRead, BufReader, Cursor},
4    path::PathBuf,
5    process::{Command, Stdio},
6    sync::{
7        atomic::{AtomicBool, AtomicU64, Ordering},
8        Arc, Mutex, RwLock, RwLockReadGuard,
9    },
10    thread,
11    time::{Duration, SystemTime, UNIX_EPOCH},
12};
13
14#[cfg(feature = "artwork")]
15use base64::{engine::general_purpose, Engine as _};
16use flate2::read::GzDecoder;
17#[cfg(feature = "artwork")]
18use image::ImageReader;
19use serde_json::Value;
20use tar::Archive;
21use tempfile::TempDir;
22
23use crate::{Command as MediaCommand, Controller, ListenerToken, NowPlayingInfo, Subscription};
24
25const ADAPTER_ASSET: &[u8] = include_bytes!("../../assets/mediaremote-adapter.tar.gz");
26
27pub struct NowPlayingPerl {
28    info: Arc<RwLock<Option<NowPlayingInfo>>>,
29    listeners: Arc<
30        Mutex<
31            HashMap<
32                ListenerToken,
33                Box<dyn Fn(RwLockReadGuard<'_, Option<NowPlayingInfo>>) + Send + Sync>,
34            >,
35        >,
36    >,
37    token_counter: Arc<AtomicU64>,
38    _temp_dir: Arc<TempDir>,
39    running: Arc<AtomicBool>,
40    adapter_script: PathBuf,
41    framework_path: PathBuf,
42}
43
44impl NowPlayingPerl {
45    pub fn new() -> Self {
46        let temp_dir = tempfile::Builder::new()
47            .prefix("mediaremote-adapter")
48            .tempdir()
49            .expect("Failed to create temporary directory");
50
51        let tar = GzDecoder::new(Cursor::new(ADAPTER_ASSET));
52        let mut archive = Archive::new(tar);
53        archive
54            .unpack(temp_dir.path())
55            .expect("Failed to unpack adapter assets");
56
57        let adapter_script = temp_dir.path().join("mediaremote-adapter.pl");
58        let framework_path = temp_dir.path().join("MediaRemoteAdapter.framework");
59
60        // Clones for the spawned thread, which takes ownership via `move`.
61        let adapter_script_thread = adapter_script.clone();
62        let framework_path_thread = framework_path.clone();
63
64        let info = Arc::new(RwLock::new(None));
65        let listeners = Arc::new(Mutex::new(HashMap::new()));
66        let token_counter = Arc::new(AtomicU64::new(0));
67        let running = Arc::new(AtomicBool::new(true));
68
69        let info_clone = info.clone();
70        let listeners_clone = listeners.clone();
71        let running_clone = running.clone();
72
73        // Spawn reading thread
74        thread::spawn(move || {
75            let mut command = Command::new("/usr/bin/perl");
76            command
77                .arg(&adapter_script_thread)
78                .arg(&framework_path_thread)
79                .arg("stream")
80                .arg("--no-diff");
81
82            #[cfg(not(feature = "artwork"))]
83            command.arg("--no-artwork");
84
85            let mut child = command
86                .stdout(Stdio::piped())
87                .stderr(Stdio::null())
88                .spawn()
89                .expect("Failed to start mediaremote-adapter");
90
91            let stdout = child.stdout.take().expect("Failed to capture stdout");
92            let reader = BufReader::new(stdout);
93
94            for line in reader.lines() {
95                if !running_clone.load(Ordering::Relaxed) {
96                    break;
97                }
98
99                if let Ok(line) = line {
100                    if let Ok(json) = serde_json::from_str::<Value>(&line) {
101                        if let Some(payload) = json.get("payload") {
102                            Self::update_info(&info_clone, &listeners_clone, payload);
103                        }
104                    }
105                }
106            }
107
108            let _ = child.kill();
109        });
110
111        Self {
112            info,
113            listeners,
114            token_counter,
115            _temp_dir: Arc::new(temp_dir),
116            running,
117            adapter_script,
118            framework_path,
119        }
120    }
121
122    fn update_info(
123        info: &Arc<RwLock<Option<NowPlayingInfo>>>,
124        listeners: &Arc<
125            Mutex<
126                HashMap<
127                    ListenerToken,
128                    Box<dyn Fn(RwLockReadGuard<'_, Option<NowPlayingInfo>>) + Send + Sync>,
129                >,
130            >,
131        >,
132        payload: &Value,
133    ) {
134        let mut new_info = NowPlayingInfo {
135            is_playing: payload["playing"].as_bool(),
136            title: payload["title"].as_str().map(|s| s.to_string()),
137            artist: payload["artist"].as_str().map(|s| s.to_string()),
138            album: payload["album"].as_str().map(|s| s.to_string()),
139            #[cfg(feature = "artwork")]
140            album_cover: None,
141            elapsed_time: payload["elapsedTime"].as_f64(),
142            duration: payload["duration"].as_f64(),
143            playback_rate: payload["playbackRate"].as_f64(),
144            info_update_time: payload["timestamp"]
145                .as_str()
146                .and_then(|s| speedate::DateTime::parse_str(s).ok())
147                .and_then(|dt| {
148                    u64::try_from(dt.timestamp())
149                        .ok()
150                        .and_then(|secs| UNIX_EPOCH.checked_add(Duration::from_secs(secs)))
151                })
152                .or(Some(SystemTime::now())),
153            bundle_id: {
154                let mut bid = payload["parentApplicationBundleIdentifier"].as_str();
155                if bid.is_none() {
156                    bid = payload["bundleIdentifier"].as_str();
157                }
158                bid.map(|s| s.to_string())
159            },
160            bundle_name: None,
161            #[cfg(feature = "artwork")]
162            bundle_icon: None,
163        };
164
165        // Handle artwork
166        #[cfg(feature = "artwork")]
167        if let Some(artwork_base64) = payload["artworkData"].as_str() {
168            // Clean up main string which might have newlines
169            let clean_base64 = artwork_base64.replace("\n", "");
170            if let Ok(data) = general_purpose::STANDARD.decode(&clean_base64) {
171                new_info.album_cover = ImageReader::new(Cursor::new(data))
172                    .with_guessed_format()
173                    .ok()
174                    .and_then(|img| img.decode().ok());
175            }
176        }
177
178        if let Some(bundle_id) = &new_info.bundle_id {
179            if let Some(bundle_info) = crate::get_bundle_info(bundle_id) {
180                new_info.bundle_name = Some(bundle_info.name);
181                #[cfg(feature = "artwork")]
182                {
183                    new_info.bundle_icon = Some(bundle_info.icon);
184                }
185            }
186        }
187
188        {
189            let mut info_guard = info.write().unwrap();
190            *info_guard = Some(new_info);
191        }
192
193        // Notify listeners
194        for (_, listener) in listeners.lock().unwrap().iter() {
195            listener(info.read().unwrap());
196        }
197    }
198
199    pub fn get_info(&self) -> RwLockReadGuard<'_, Option<NowPlayingInfo>> {
200        let mut info_guard = self.info.write().unwrap();
201
202        // Logic to update elapsed time estimation if playing
203        if let Some(ref mut info) = *info_guard {
204            if info.is_playing == Some(true) {
205                if let (Some(elapsed), Some(update_time)) =
206                    (info.elapsed_time, info.info_update_time)
207                {
208                    if let Ok(duration) = SystemTime::now().duration_since(update_time) {
209                        info.elapsed_time = Some(elapsed + duration.as_secs_f64());
210                        info.info_update_time = Some(SystemTime::now());
211                    }
212                }
213            }
214        }
215        drop(info_guard);
216
217        self.info.read().unwrap()
218    }
219}
220
221impl Drop for NowPlayingPerl {
222    fn drop(&mut self) {
223        self.running.store(false, Ordering::Relaxed);
224    }
225}
226
227impl NowPlayingPerl {
228    /// Runs the perl adapter with the `send` command and the given MediaRemote command id.
229    /// Returns `true` only when the script exits successfully.
230    fn run_send(&self, command: MediaCommand) -> bool {
231        let status = Command::new("/usr/bin/perl")
232            .arg(&self.adapter_script)
233            .arg(&self.framework_path)
234            .arg("send")
235            .arg((command as i32).to_string())
236            .stdin(Stdio::null())
237            .stdout(Stdio::null())
238            .stderr(Stdio::null())
239            .status();
240        matches!(status, Ok(s) if s.success())
241    }
242
243    /// Runs the perl adapter with the `seek` command. `position_micros` is in microseconds.
244    fn run_seek(&self, position_micros: u64) -> bool {
245        let status = Command::new("/usr/bin/perl")
246            .arg(&self.adapter_script)
247            .arg(&self.framework_path)
248            .arg("seek")
249            .arg(position_micros.to_string())
250            .stdin(Stdio::null())
251            .stdout(Stdio::null())
252            .stderr(Stdio::null())
253            .status();
254        matches!(status, Ok(s) if s.success())
255    }
256
257    /// Runs the perl adapter with the `speed` command.
258    fn run_speed(&self, speed: i32) -> bool {
259        let status = Command::new("/usr/bin/perl")
260            .arg(&self.adapter_script)
261            .arg(&self.framework_path)
262            .arg("speed")
263            .arg(speed.to_string())
264            .stdin(Stdio::null())
265            .stdout(Stdio::null())
266            .stderr(Stdio::null())
267            .status();
268        matches!(status, Ok(s) if s.success())
269    }
270}
271
272impl Controller for NowPlayingPerl {
273    fn is_info_some(&self) -> bool {
274        self.info.read().unwrap().as_ref().is_some()
275    }
276
277    fn toggle(&self) -> bool {
278        self.run_send(MediaCommand::TogglePlayPause)
279    }
280
281    fn play(&self) -> bool {
282        self.run_send(MediaCommand::Play)
283    }
284
285    fn pause(&self) -> bool {
286        self.run_send(MediaCommand::Pause)
287    }
288
289    fn next(&self) -> bool {
290        self.run_send(MediaCommand::NextTrack)
291    }
292
293    fn previous(&self) -> bool {
294        self.run_send(MediaCommand::PreviousTrack)
295    }
296
297    fn toggle_shuffle(&self) -> bool {
298        self.run_send(MediaCommand::ToggleShuffle)
299    }
300
301    fn toggle_repeat(&self) -> bool {
302        self.run_send(MediaCommand::ToggleRepeat)
303    }
304
305    fn start_forward_seek(&self) -> bool {
306        self.run_send(MediaCommand::StartForwardSeek)
307    }
308
309    fn end_forward_seek(&self) -> bool {
310        self.run_send(MediaCommand::EndForwardSeek)
311    }
312
313    fn start_backward_seek(&self) -> bool {
314        self.run_send(MediaCommand::StartBackwardSeek)
315    }
316
317    fn end_backward_seek(&self) -> bool {
318        self.run_send(MediaCommand::EndBackwardSeek)
319    }
320
321    fn go_back_fifteen_seconds(&self) -> bool {
322        self.run_send(MediaCommand::GoBackFifteenSeconds)
323    }
324
325    fn skip_fifteen_seconds(&self) -> bool {
326        self.run_send(MediaCommand::SkipFifteenSeconds)
327    }
328
329    fn set_playback_speed(&self, speed: i32) {
330        self.run_speed(speed);
331    }
332
333    fn set_elapsed_time(&self, elapsed_time: f64) {
334        // The perl adapter's `seek` command expects a positive integer in microseconds.
335        let position_micros = (elapsed_time.max(0.0) * 1_000_000.0) as u64;
336        self.run_seek(position_micros);
337    }
338}
339
340impl Subscription for NowPlayingPerl {
341    fn get_info(&self) -> RwLockReadGuard<'_, Option<NowPlayingInfo>> {
342        self.get_info()
343    }
344
345    fn get_token_counter(&self) -> Arc<AtomicU64> {
346        self.token_counter.clone()
347    }
348
349    fn get_listeners(
350        &self,
351    ) -> Arc<
352        Mutex<
353            HashMap<
354                crate::high_level::subscription::ListenerToken,
355                Box<dyn Fn(RwLockReadGuard<'_, Option<NowPlayingInfo>>) + Send + Sync>,
356            >,
357        >,
358    > {
359        self.listeners.clone()
360    }
361}