1use std::collections::{HashMap, VecDeque};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12use tokio::sync::{watch, Mutex};
13
14use crate::client::MonoClient;
15use crate::types::{MonoEvent, PlayStatus, QueuedTrack};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PlayerState {
20 pub current_track: Option<QueuedTrack>,
21 pub position_secs: f32,
22 pub queue: Vec<QueuedTrack>,
23 pub history: Vec<QueuedTrack>,
24 pub volume: f32,
25 #[serde(default = "default_preamp")]
26 pub preamp: f32,
27}
28
29fn default_preamp() -> f32 {
30 1.0
31}
32
33impl PlayerState {
34 fn state_path() -> PathBuf {
35 dirs::home_dir()
36 .unwrap_or_else(|| PathBuf::from("."))
37 .join(".plexus/monochrome/player/state.json")
38 }
39
40 pub fn load() -> Option<Self> {
41 let path = Self::state_path();
42 let data = std::fs::read_to_string(&path).ok()?;
43 serde_json::from_str(&data).ok()
44 }
45
46 pub fn save(&self) {
47 let path = Self::state_path();
48 if let Some(parent) = path.parent() {
49 let _ = std::fs::create_dir_all(parent);
50 }
51 if let Ok(json) = serde_json::to_string_pretty(self) {
52 let _ = std::fs::write(&path, json);
53 }
54 }
55}
56
57trait ReadSeekSend: std::io::Read + std::io::Seek + Send + Sync {}
61impl<T: std::io::Read + std::io::Seek + Send + Sync> ReadSeekSend for T {}
62
63#[derive(Debug, Clone)]
65pub struct NowPlaying {
66 pub track_id: Option<u64>,
67 pub title: Option<String>,
68 pub artist: Option<String>,
69 pub album: Option<String>,
70 pub status: PlayStatus,
71 pub position_secs: f32,
72 pub duration_secs: f32,
73 pub volume: f32,
74 pub preamp: f32,
75 pub queue_length: usize,
76 pub url: Option<String>,
77}
78
79impl Default for NowPlaying {
80 fn default() -> Self {
81 Self {
82 track_id: None,
83 title: None,
84 artist: None,
85 album: None,
86 status: PlayStatus::Idle,
87 position_secs: 0.0,
88 duration_secs: 0.0,
89 volume: 1.0,
90 preamp: 1.0,
91 queue_length: 0,
92 url: None,
93 }
94 }
95}
96
97struct PlayerInner {
98 queue: VecDeque<QueuedTrack>,
99 current_track: Option<QueuedTrack>,
100 status: PlayStatus,
101 volume: f32,
102 preamp: f32,
103 history: Vec<QueuedTrack>,
104 prefetched: HashMap<u64, Box<dyn ReadSeekSend>>,
108}
109
110pub struct Player {
112 sink: Arc<rodio::Sink>,
113 inner: Mutex<PlayerInner>,
114 now_playing_tx: watch::Sender<NowPlaying>,
115 now_playing_rx: watch::Receiver<NowPlaying>,
116 client: Arc<MonoClient>,
117 _shutdown_tx: std::sync::mpsc::Sender<()>,
119}
120
121impl Player {
122 pub async fn new(client: Arc<MonoClient>) -> Arc<Self> {
124 let (sink_tx, sink_rx) = std::sync::mpsc::channel();
125 let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
126
127 std::thread::spawn(move || {
128 let (_stream, handle) = rodio::OutputStream::try_default()
129 .expect("failed to open default audio output device");
130 let sink = rodio::Sink::try_new(&handle)
131 .expect("failed to create audio sink");
132 let _ = sink_tx.send(sink);
133 let _ = shutdown_rx.recv();
135 });
136
137 let sink = Arc::new(sink_rx.recv().expect("audio thread failed to initialize"));
138 sink.pause(); let (now_playing_tx, now_playing_rx) = watch::channel(NowPlaying::default());
141
142 let player = Arc::new(Self {
143 sink,
144 inner: Mutex::new(PlayerInner {
145 queue: VecDeque::new(),
146 current_track: None,
147 status: PlayStatus::Idle,
148 volume: 1.0,
149 preamp: 1.0,
150 history: Vec::new(),
151 prefetched: HashMap::new(),
152 }),
153 now_playing_tx,
154 now_playing_rx,
155 client,
156 _shutdown_tx: shutdown_tx,
157 });
158
159 let weak = Arc::downgrade(&player);
161 tokio::spawn(async move {
162 loop {
163 tokio::time::sleep(Duration::from_secs(1)).await;
164 let Some(this) = weak.upgrade() else { break };
165 let is_playing = {
166 let inner = this.inner.lock().await;
167 matches!(inner.status, PlayStatus::Playing)
168 };
169 if is_playing {
170 this.broadcast_now_playing().await;
171 }
172 }
173 });
174
175 let weak = Arc::downgrade(&player);
177 tokio::spawn(async move {
178 loop {
179 tokio::time::sleep(Duration::from_millis(250)).await;
180 let Some(this) = weak.upgrade() else { break };
181 if !this.sink.empty() {
182 continue;
183 }
184 let mut inner = this.inner.lock().await;
185 if matches!(inner.status, PlayStatus::Playing) {
186 if let Some(current) = inner.current_track.take() {
188 inner.history.push(current);
189 }
190 if let Some(next) = inner.queue.pop_front() {
191 inner.status = PlayStatus::Buffering;
192 drop(inner);
193 if let Err(e) = this.start_playback(next).await {
194 tracing::error!("auto-advance failed: {e}");
195 let mut inner = this.inner.lock().await;
196 inner.status = PlayStatus::Idle;
197 inner.current_track = None;
198 drop(inner);
199 this.broadcast_now_playing().await;
200 }
201 this.save_state().await;
202 } else {
203 inner.status = PlayStatus::Idle;
204 inner.current_track = None;
205 drop(inner);
206 this.broadcast_now_playing().await;
207 this.save_state().await;
208 }
209 }
210 }
211 });
212
213 let weak = Arc::downgrade(&player);
215 tokio::spawn(async move {
216 let mut last_track_id: Option<u64> = None;
217 loop {
218 tokio::time::sleep(Duration::from_secs(2)).await;
219 let Some(this) = weak.upgrade() else { break };
220 let current_id = {
221 let inner = this.inner.lock().await;
222 if !matches!(inner.status, PlayStatus::Playing) {
223 continue;
224 }
225 inner.current_track.as_ref().map(|t| t.id)
226 };
227 if current_id != last_track_id {
229 last_track_id = current_id;
230 this.prefetch_queue().await;
231 }
232 }
233 });
234
235 player.setup_media_controls();
237
238 player.restore_state().await;
240
241 player
242 }
243
244 fn setup_media_controls(self: &Arc<Self>) {
248 use souvlaki::{
249 MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition,
250 PlatformConfig,
251 };
252
253 let tokio_handle = tokio::runtime::Handle::current();
254 let weak = Arc::downgrade(self);
255 let mut np_rx = self.subscribe_now_playing();
256
257 std::thread::Builder::new()
258 .name("media-controls".into())
259 .spawn(move || {
260 let config = PlatformConfig {
261 dbus_name: "plexus_mono",
262 display_name: "Plexus Mono",
263 hwnd: None,
264 };
265 let mut controls = match MediaControls::new(config) {
266 Ok(c) => c,
267 Err(e) => {
268 tracing::warn!("media controls unavailable: {e:?}");
269 return;
270 }
271 };
272
273 let weak2 = weak.clone();
275 let handle = tokio_handle.clone();
276 if let Err(e) = controls.attach(move |event: MediaControlEvent| {
277 let Some(player) = weak2.upgrade() else {
278 return;
279 };
280 let player = player.clone();
281 handle.spawn(async move {
282 match event {
283 MediaControlEvent::Play => player.resume().await,
284 MediaControlEvent::Pause => player.pause().await,
285 MediaControlEvent::Toggle => {
286 let is_playing = {
287 let inner = player.inner.lock().await;
288 matches!(inner.status, PlayStatus::Playing)
289 };
290 if is_playing {
291 player.pause().await;
292 } else {
293 player.resume().await;
294 }
295 }
296 MediaControlEvent::Next => {
297 let _ = player.next().await;
298 }
299 MediaControlEvent::Previous => {
300 let _ = player.previous().await;
301 }
302 _ => {}
303 }
304 });
305 }) {
306 tracing::warn!("failed to attach media controls: {e:?}");
307 return;
308 }
309
310 tracing::info!("media controls active (Now Playing + media keys)");
311
312 let _ = controls.set_metadata(MediaMetadata {
314 title: Some("Plexus Mono"),
315 artist: None,
316 album: None,
317 duration: None,
318 cover_url: None,
319 });
320 let _ = controls.set_playback(MediaPlayback::Paused { progress: None });
321
322 loop {
324 std::thread::sleep(Duration::from_millis(500));
325
326 if !np_rx.has_changed().unwrap_or(false) {
327 if weak.upgrade().is_none() {
329 break;
330 }
331 continue;
332 }
333
334 let np = np_rx.borrow_and_update().clone();
335
336 let cover_url = np.title.as_ref().and_then(|_| {
338 None::<String>
340 });
341
342 let _ = controls.set_metadata(MediaMetadata {
343 title: np.title.as_deref(),
344 artist: np.artist.as_deref(),
345 album: np.album.as_deref(),
346 duration: if np.duration_secs > 0.0 {
347 Some(Duration::from_secs_f32(np.duration_secs))
348 } else {
349 None
350 },
351 cover_url: cover_url.as_deref(),
352 });
353
354 let playback = match np.status {
355 PlayStatus::Playing => MediaPlayback::Playing {
356 progress: Some(MediaPosition(Duration::from_secs_f32(
357 np.position_secs,
358 ))),
359 },
360 PlayStatus::Paused => MediaPlayback::Paused {
361 progress: Some(MediaPosition(Duration::from_secs_f32(
362 np.position_secs,
363 ))),
364 },
365 _ => MediaPlayback::Stopped,
366 };
367 let _ = controls.set_playback(playback);
368 }
369 })
370 .expect("failed to spawn media-controls thread");
371 }
372
373 async fn broadcast_now_playing(&self) {
375 let inner = self.inner.lock().await;
376 let np = NowPlaying {
377 track_id: inner.current_track.as_ref().map(|t| t.id),
378 title: inner.current_track.as_ref().map(|t| t.title.clone()),
379 artist: inner.current_track.as_ref().map(|t| t.artist.clone()),
380 album: inner.current_track.as_ref().map(|t| t.album.clone()),
381 status: inner.status.clone(),
382 position_secs: self.sink.get_pos().as_secs_f32(),
383 duration_secs: inner
384 .current_track
385 .as_ref()
386 .map(|t| t.duration_secs as f32)
387 .unwrap_or(0.0),
388 volume: inner.volume,
389 preamp: inner.preamp,
390 queue_length: inner.queue.len(),
391 url: inner.current_track.as_ref().map(|t| format!("https://monochrome.tf/track/t/{}", t.id)),
392 };
393 let _ = self.now_playing_tx.send(np);
394 }
395
396 async fn start_playback(&self, track: QueuedTrack) -> Result<(), String> {
398 {
399 let mut inner = self.inner.lock().await;
400 inner.current_track = Some(track.clone());
401 inner.status = PlayStatus::Buffering;
402 }
403 self.broadcast_now_playing().await;
404
405 let prefetched: Option<Box<dyn ReadSeekSend>> = {
407 let mut inner = self.inner.lock().await;
408 inner.prefetched.remove(&track.id)
409 };
410
411 let reader: Box<dyn ReadSeekSend> = if let Some(r) = prefetched {
412 tracing::debug!("using prefetched audio for track {}", track.id);
413 r
414 } else {
415 let manifest = self.client.stream_manifest(track.id, &track.quality).await?;
417 let url = match &manifest {
418 MonoEvent::StreamManifest { url, .. } => url.clone(),
419 _ => return Err("unexpected manifest type".to_string()),
420 };
421
422 let r = stream_download::StreamDownload::new_http(
424 url.parse::<reqwest::Url>()
425 .map_err(|e| format!("bad stream url: {e}"))?,
426 stream_download::storage::temp::TempStorageProvider::new(),
427 stream_download::Settings::default(),
428 )
429 .await
430 .map_err(|e| format!("stream download error: {e}"))?;
431 Box::new(r)
432 };
433
434 let source = tokio::task::spawn_blocking(move || rodio::Decoder::new(reader))
436 .await
437 .map_err(|e| format!("decoder task panicked: {e}"))?
438 .map_err(|e| format!("audio decode error: {e}"))?;
439
440 self.sink.stop();
442 self.sink.append(source);
443 self.sink.play();
444
445 {
446 let mut inner = self.inner.lock().await;
447 inner.status = PlayStatus::Playing;
448 }
449 self.broadcast_now_playing().await;
450
451 Ok(())
452 }
453
454 pub async fn play_track(&self, id: u64, quality: &str) -> Result<(), String> {
456 let track_info = self.client.track_info(id).await.ok();
457 let queued = make_queued_track(id, quality, track_info);
458
459 {
461 let mut inner = self.inner.lock().await;
462 if let Some(current) = inner.current_track.take() {
463 inner.history.push(current);
464 }
465 }
466
467 self.start_playback(queued).await
468 }
469
470 pub async fn pause(&self) {
472 self.sink.pause();
473 let mut inner = self.inner.lock().await;
474 if matches!(inner.status, PlayStatus::Playing | PlayStatus::Buffering) {
475 inner.status = PlayStatus::Paused;
476 }
477 drop(inner);
478 self.broadcast_now_playing().await;
479 }
480
481 pub async fn resume(&self) {
483 self.sink.play();
484 let mut inner = self.inner.lock().await;
485 if matches!(inner.status, PlayStatus::Paused) {
486 inner.status = PlayStatus::Playing;
487 }
488 drop(inner);
489 self.broadcast_now_playing().await;
490 }
491
492 pub async fn stop(&self) {
494 self.sink.stop();
495 let mut inner = self.inner.lock().await;
496 if let Some(current) = inner.current_track.take() {
497 inner.history.push(current);
498 }
499 inner.status = PlayStatus::Stopped;
500 inner.prefetched.clear(); drop(inner);
502 self.broadcast_now_playing().await;
503 self.save_state().await;
504 }
505
506 pub async fn next(&self) -> Result<(), String> {
508 self.sink.stop();
509 let next = {
510 let mut inner = self.inner.lock().await;
511 if let Some(current) = inner.current_track.take() {
512 inner.history.push(current);
513 }
514 inner.queue.pop_front()
515 };
516
517 if let Some(track) = next {
518 self.start_playback(track).await
519 } else {
520 let mut inner = self.inner.lock().await;
521 inner.status = PlayStatus::Idle;
522 drop(inner);
523 self.broadcast_now_playing().await;
524 Err("queue is empty".to_string())
525 }
526 }
527
528 pub async fn previous(&self) -> Result<(), String> {
530 if self.sink.get_pos().as_secs_f32() > 5.0 {
532 let track = {
533 let inner = self.inner.lock().await;
534 inner.current_track.clone()
535 };
536 if let Some(track) = track {
537 return self.start_playback(track).await;
538 }
539 }
540
541 self.sink.stop();
542 let prev = {
543 let mut inner = self.inner.lock().await;
544 if let Some(current) = inner.current_track.take() {
546 inner.queue.push_front(current);
547 }
548 inner.history.pop()
549 };
550
551 if let Some(track) = prev {
552 self.start_playback(track).await
553 } else {
554 Err("no previous track".to_string())
555 }
556 }
557
558 fn apply_volume(&self, inner: &PlayerInner) {
560 self.sink.set_volume(inner.preamp * inner.volume);
561 }
562
563 pub async fn set_volume(&self, level: f32) {
565 let level = level.clamp(0.0, 1.0);
566 let mut inner = self.inner.lock().await;
567 inner.volume = level;
568 self.apply_volume(&inner);
569 drop(inner);
570 self.broadcast_now_playing().await;
571 self.save_state().await;
572 }
573
574 pub async fn set_preamp(&self, level: f32) {
576 let level = level.clamp(0.0, 4.0);
577 let mut inner = self.inner.lock().await;
578 inner.preamp = level;
579 self.apply_volume(&inner);
580 drop(inner);
581 self.broadcast_now_playing().await;
582 self.save_state().await;
583 }
584
585 pub async fn queue_add(&self, id: u64, quality: &str) -> Result<(), String> {
587 let track_info = self.client.track_info(id).await.ok();
588 let queued = make_queued_track(id, quality, track_info);
589
590 let should_start = {
591 let mut inner = self.inner.lock().await;
592 let idle = matches!(inner.status, PlayStatus::Idle | PlayStatus::Stopped);
593 if idle {
594 true
596 } else {
597 inner.queue.push_back(queued.clone());
598 false
599 }
600 };
601
602 let result = if should_start {
603 self.start_playback(queued).await
604 } else {
605 self.broadcast_now_playing().await;
606 Ok(())
607 };
608 self.save_state().await;
609 result
610 }
611
612 pub async fn queue_album(&self, album_id: u64, quality: &str) -> Result<Vec<QueuedTrack>, String> {
614 let (_album_event, track_events) = self.client.album(album_id).await?;
615
616 let mut queued_tracks = Vec::new();
617 for event in &track_events {
618 if let MonoEvent::AlbumTrack { id, title, artist, duration_secs, .. } = event {
619 queued_tracks.push(QueuedTrack {
620 id: *id,
621 title: title.clone(),
622 artist: artist.clone(),
623 album: String::new(), duration_secs: *duration_secs,
625 quality: quality.to_string(),
626 cover_id: None,
627 });
628 }
629 }
630
631 let album_name = if let MonoEvent::Album { title, cover_id, .. } = &_album_event {
633 for t in &mut queued_tracks {
634 t.album = title.clone();
635 t.cover_id = cover_id.clone();
636 }
637 title.clone()
638 } else {
639 format!("Album {album_id}")
640 };
641
642 if queued_tracks.is_empty() {
643 return Err(format!("no tracks found in album {album_name}"));
644 }
645
646 let should_start = {
647 let mut inner = self.inner.lock().await;
648 let idle = matches!(inner.status, PlayStatus::Idle | PlayStatus::Stopped);
649 if idle {
650 for t in queued_tracks.iter().skip(1) {
652 inner.queue.push_back(t.clone());
653 }
654 true
655 } else {
656 for t in &queued_tracks {
657 inner.queue.push_back(t.clone());
658 }
659 false
660 }
661 };
662
663 if should_start {
664 self.start_playback(queued_tracks[0].clone()).await?;
665 } else {
666 self.broadcast_now_playing().await;
667 }
668
669 Ok(queued_tracks)
670 }
671
672 pub async fn queue_batch(&self, ids: &[u64], quality: &str) -> Result<Vec<QueuedTrack>, String> {
674 if ids.is_empty() {
675 return Err("no track IDs provided".into());
676 }
677
678 let futs: Vec<_> = ids.iter().map(|&id| {
680 let client = self.client.clone();
681 let q = quality.to_string();
682 async move {
683 let info = client.track_info(id).await.ok();
684 make_queued_track(id, &q, info)
685 }
686 }).collect();
687 let tracks: Vec<QueuedTrack> = futures::future::join_all(futs).await;
688
689 let should_start = {
690 let mut inner = self.inner.lock().await;
691 let idle = matches!(inner.status, PlayStatus::Idle | PlayStatus::Stopped);
692 if idle {
693 for t in tracks.iter().skip(1) {
695 inner.queue.push_back(t.clone());
696 }
697 true
698 } else {
699 for t in &tracks {
700 inner.queue.push_back(t.clone());
701 }
702 false
703 }
704 };
705
706 if should_start {
707 self.start_playback(tracks[0].clone()).await?;
708 } else {
709 self.broadcast_now_playing().await;
710 }
711
712 self.save_state().await;
713 Ok(tracks)
714 }
715
716 pub async fn queue_clear(&self) {
718 let mut inner = self.inner.lock().await;
719 inner.queue.clear();
720 inner.prefetched.clear(); drop(inner);
722 self.broadcast_now_playing().await;
723 }
724
725 pub async fn queue_get(&self) -> (Option<QueuedTrack>, Vec<QueuedTrack>) {
727 let inner = self.inner.lock().await;
728 (
729 inner.current_track.clone(),
730 inner.queue.iter().cloned().collect(),
731 )
732 }
733
734 pub async fn queue_reorder(&self, from: usize, to: usize) -> Result<(), String> {
736 let mut inner = self.inner.lock().await;
737 if from >= inner.queue.len() || to >= inner.queue.len() {
738 return Err(format!(
739 "index out of bounds (queue has {} tracks)",
740 inner.queue.len()
741 ));
742 }
743 let track = inner.queue.remove(from).unwrap();
744 inner.queue.insert(to, track);
745 Ok(())
746 }
747
748 async fn prefetch_queue(&self) {
751 let tracks: Vec<QueuedTrack> = {
752 let inner = self.inner.lock().await;
753 inner
754 .queue
755 .iter()
756 .filter(|t| !inner.prefetched.contains_key(&t.id))
757 .take(10)
758 .cloned()
759 .collect()
760 };
761
762 for track in tracks {
763 let manifest = match self.client.stream_manifest(track.id, &track.quality).await {
765 Ok(m) => m,
766 Err(e) => {
767 tracing::debug!("prefetch manifest failed for {}: {e}", track.id);
768 continue;
769 }
770 };
771 let url = match &manifest {
772 MonoEvent::StreamManifest { url, .. } => url.clone(),
773 _ => continue,
774 };
775 let parsed = match url.parse::<reqwest::Url>() {
776 Ok(u) => u,
777 Err(_) => continue,
778 };
779
780 let reader = match stream_download::StreamDownload::new_http(
782 parsed,
783 stream_download::storage::temp::TempStorageProvider::new(),
784 stream_download::Settings::default(),
785 )
786 .await
787 {
788 Ok(r) => r,
789 Err(e) => {
790 tracing::debug!("prefetch download failed for {}: {e}", track.id);
791 continue;
792 }
793 };
794
795 tracing::debug!("prefetched track {} ({})", track.id, track.title);
796 let mut inner = self.inner.lock().await;
797 inner.prefetched.insert(track.id, Box::new(reader));
798 }
799 }
800
801 pub fn subscribe_now_playing(&self) -> watch::Receiver<NowPlaying> {
803 self.now_playing_rx.clone()
804 }
805
806 pub async fn get_state(&self) -> PlayerState {
808 let inner = self.inner.lock().await;
809 PlayerState {
810 current_track: inner.current_track.clone(),
811 position_secs: self.sink.get_pos().as_secs_f32(),
812 queue: inner.queue.iter().cloned().collect(),
813 history: inner.history.clone(),
814 volume: inner.volume,
815 preamp: inner.preamp,
816 }
817 }
818
819 pub async fn save_state(&self) {
821 let state = self.get_state().await;
822 state.save();
823 }
824
825 pub async fn restore_state(&self) {
827 if let Some(state) = PlayerState::load() {
828 let resume_track = state.current_track.clone();
829 let resume_pos = state.position_secs;
830
831 {
832 let mut inner = self.inner.lock().await;
833 inner.queue = state.queue.into_iter().collect();
834 inner.history = state.history;
835 inner.volume = state.volume;
836 inner.preamp = state.preamp;
837 self.apply_volume(&inner);
838 }
839
840 if let Some(track) = resume_track {
842 tracing::info!(
843 "resuming '{}' at {:.0}s",
844 track.title,
845 resume_pos
846 );
847 match self.start_playback(track).await {
848 Ok(()) => {
849 self.sink.pause();
851 let mut inner = self.inner.lock().await;
852 inner.status = PlayStatus::Paused;
853 drop(inner);
854 self.broadcast_now_playing().await;
855 }
856 Err(e) => {
857 tracing::error!("failed to resume track: {e}");
858 }
859 }
860 } else {
861 self.broadcast_now_playing().await;
862 }
863
864 tracing::info!("restored player state from disk");
865 }
866 }
867}
868
869fn make_queued_track(id: u64, quality: &str, info: Option<MonoEvent>) -> QueuedTrack {
871 match info {
872 Some(MonoEvent::Track {
873 title,
874 artist,
875 album,
876 duration_secs,
877 cover_id,
878 ..
879 }) => QueuedTrack {
880 id,
881 title,
882 artist,
883 album,
884 duration_secs,
885 quality: quality.to_string(),
886 cover_id,
887 },
888 _ => QueuedTrack {
889 id,
890 title: format!("Track {id}"),
891 artist: String::new(),
892 album: String::new(),
893 duration_secs: 0,
894 quality: quality.to_string(),
895 cover_id: None,
896 },
897 }
898}