Skip to main content

romm_cli/tui/app/background/
tasks.rs

1//! Background task spawn and poll helpers for [`super::App`].
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::commands::library_scan::ScanCacheInvalidate;
7use crate::core::cache::RomCacheKey;
8use crate::core::startup_library_snapshot;
9use crate::types::SaveMetadata;
10
11use super::super::event::{AppEvent, BackgroundAction};
12use super::super::AppScreen;
13use super::types::{
14    CoverLoadDone, LibraryMetadataRefreshDone, LibraryUploadComplete, SaveListDone,
15};
16
17impl super::super::App {
18    pub(in crate::tui::app) fn spawn_library_metadata_refresh(&mut self) {
19        self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
20        let gen = self.library_metadata_refresh_gen;
21        let client = self.client.clone();
22        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
23        self.library_metadata_rx = Some(rx);
24        tokio::spawn(async move {
25            let fetch = startup_library_snapshot::fetch_merged_library_metadata(&client).await;
26            let _ = tx.send(LibraryMetadataRefreshDone {
27                gen,
28                platforms: fetch.platforms,
29                collections: fetch.collections,
30                collection_digest: fetch.collection_digest,
31                warnings: fetch.warnings,
32            });
33        });
34    }
35
36    /// Drain background channels into [`AppEvent`]s. Safe to call each frame.
37    pub(crate) fn drain_background_events(&mut self) -> Vec<AppEvent> {
38        let mut events = Vec::new();
39        events.extend(self.drain_library_metadata_refresh());
40        events.extend(self.drain_rom_load_results());
41        events.extend(self.drain_collection_prefetch_results());
42        events.extend(self.drain_search_load_results());
43        events.extend(self.drain_cover_load_results());
44        events.extend(self.drain_save_results());
45        events.extend(self.drain_settings_results());
46        events.extend(self.drain_library_upload());
47        events.extend(self.drain_library_scan());
48        self.drive_collection_prefetch_scheduler();
49        events.push(AppEvent::Background(BackgroundAction::DrivePrefetch));
50        events.push(AppEvent::Background(BackgroundAction::PollFooterClear));
51        events
52    }
53
54    /// Legacy test entry: drain background events and apply synchronously.
55    pub fn poll_background_tasks(&mut self) {
56        let events = self.drain_background_events();
57        for event in events {
58            if let AppEvent::Background(bg) = event {
59                let action = super::super::event::map_background(bg);
60                self.apply_background(action);
61            }
62        }
63    }
64
65    pub(in crate::tui::app) fn spawn_library_rescan_worker(
66        &mut self,
67        cache_on_success: ScanCacheInvalidate,
68    ) {
69        if self.library_scan_inflight {
70            return;
71        }
72        self.library_scan_inflight = true;
73        self.library_scan_pending_invalidate = Some(cache_on_success);
74        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
75            lib.set_metadata_footer(Some("Server library scan running…".into()));
76        }
77        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
78        self.library_scan_rx = Some(rx);
79        let client = self.client.clone();
80        tokio::spawn(async move {
81            let result = async {
82                let start =
83                    crate::commands::library_scan::start_scan_library(&client, None).await?;
84                crate::commands::library_scan::wait_for_task_terminal(
85                    &client,
86                    &start.task_id,
87                    Duration::from_secs(3600),
88                    None,
89                    |_| {},
90                )
91                .await?;
92                Ok::<(), anyhow::Error>(())
93            }
94            .await
95            .map_err(|e| e.to_string());
96            let _ = tx.send(result);
97        });
98    }
99
100    fn drain_library_scan(&mut self) -> Vec<AppEvent> {
101        let Some(rx) = &mut self.library_scan_rx else {
102            return Vec::new();
103        };
104        match rx.try_recv() {
105            Ok(result) => {
106                vec![AppEvent::Background(BackgroundAction::LibraryScanDone(
107                    result,
108                ))]
109            }
110            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => Vec::new(),
111            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
112                self.library_scan_rx = None;
113                self.library_scan_inflight = false;
114                self.library_scan_pending_invalidate = None;
115                Vec::new()
116            }
117        }
118    }
119
120    pub(in crate::tui::app) fn apply_library_scan_cache_invalidate(
121        &mut self,
122        inv: &ScanCacheInvalidate,
123    ) {
124        match inv {
125            ScanCacheInvalidate::None => {}
126            ScanCacheInvalidate::Platform(pid) => {
127                self.rom_cache.remove(&RomCacheKey::Platform(*pid));
128            }
129            ScanCacheInvalidate::AllPlatforms => {
130                self.rom_cache.remove_all_platform_entries();
131                if let AppScreen::LibraryBrowse(lib) = &self.screen {
132                    if let Some(ref k) = lib.cache_key() {
133                        if !matches!(k, RomCacheKey::Platform(_)) {
134                            self.rom_cache.remove(k);
135                        }
136                    }
137                }
138            }
139        }
140    }
141
142    pub(in crate::tui::app) fn on_library_scan_completed_success(&mut self) {
143        let inv = self
144            .library_scan_pending_invalidate
145            .take()
146            .unwrap_or(ScanCacheInvalidate::AllPlatforms);
147        self.apply_library_scan_cache_invalidate(&inv);
148        if matches!(self.screen, AppScreen::LibraryBrowse(_)) {
149            self.force_rom_reload_after_metadata = true;
150            self.spawn_library_metadata_refresh();
151        }
152    }
153
154    pub(in crate::tui::app) fn format_upload_bytes(n: u64) -> String {
155        const KB: u64 = 1024;
156        const MB: u64 = KB * 1024;
157        const GB: u64 = MB * 1024;
158        if n >= GB {
159            format!("{:.2} GiB", n as f64 / GB as f64)
160        } else if n >= MB {
161            format!("{:.2} MiB", n as f64 / MB as f64)
162        } else if n >= KB {
163            format!("{:.1} KiB", n as f64 / KB as f64)
164        } else {
165            format!("{n} B")
166        }
167    }
168
169    pub(in crate::tui::app) fn spawn_library_upload_worker(
170        &mut self,
171        platform_id: u64,
172        path: PathBuf,
173        scan_after: bool,
174    ) {
175        if self.library_upload_inflight || self.library_scan_inflight {
176            return;
177        }
178        self.library_upload_inflight = true;
179        self.library_upload_progress_rx = None;
180        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
181            lib.set_metadata_footer(Some("Preparing upload…".into()));
182        }
183        let (prog_tx, prog_rx) = tokio::sync::mpsc::unbounded_channel();
184        let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
185        self.library_upload_progress_rx = Some(prog_rx);
186        self.library_upload_done_rx = Some(done_rx);
187        let client = self.client.clone();
188        tokio::spawn(async move {
189            let result: Result<LibraryUploadComplete, String> = async {
190                client
191                    .upload_rom(platform_id, &path, move |uploaded, total| {
192                        let _ = prog_tx.send((uploaded, total));
193                    })
194                    .await
195                    .map_err(|e| e.to_string())?;
196                Ok(LibraryUploadComplete {
197                    platform_id,
198                    scan_after,
199                })
200            }
201            .await;
202            let _ = done_tx.send(result);
203        });
204    }
205
206    fn drain_library_upload(&mut self) -> Vec<AppEvent> {
207        let mut events = Vec::new();
208        if let Some(rx) = &mut self.library_upload_progress_rx {
209            while let Ok((up, tot)) = rx.try_recv() {
210                events.push(AppEvent::Background(
211                    BackgroundAction::LibraryUploadProgress {
212                        uploaded: up,
213                        total: tot,
214                    },
215                ));
216            }
217        }
218
219        let Some(rx) = &mut self.library_upload_done_rx else {
220            return events;
221        };
222        match rx.try_recv() {
223            Ok(result) => {
224                events.push(AppEvent::Background(BackgroundAction::LibraryUploadDone(
225                    result,
226                )));
227            }
228            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
229            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
230                self.library_upload_done_rx = None;
231                self.library_upload_progress_rx = None;
232                self.library_upload_inflight = false;
233            }
234        }
235        events
236    }
237
238    fn drain_search_load_results(&mut self) -> Vec<AppEvent> {
239        let mut events = Vec::new();
240        loop {
241            match self.search_load_rx.try_recv() {
242                Ok(done) => events.push(AppEvent::Background(BackgroundAction::SearchLoad(done))),
243                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
244                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
245            }
246        }
247        events
248    }
249
250    pub(in crate::tui::app) fn spawn_cover_load_worker(&mut self, rom_id: u64, url: String) {
251        if let Some(task) = self.cover_load_task.take() {
252            task.abort();
253        }
254        let tx = self.cover_load_tx.clone();
255        self.cover_load_task = Some(tokio::spawn(async move {
256            let result = async {
257                let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
258                let status = response.status();
259                if !status.is_success() {
260                    return Err(format!("HTTP {}", status.as_u16()));
261                }
262                let bytes = response.bytes().await.map_err(|e| e.to_string())?;
263                image::load_from_memory(&bytes).map_err(|e| e.to_string())
264            }
265            .await;
266            let _ = tx.send(CoverLoadDone { rom_id, result });
267        }));
268    }
269
270    fn drain_cover_load_results(&mut self) -> Vec<AppEvent> {
271        let mut events = Vec::new();
272        loop {
273            match self.cover_load_rx.try_recv() {
274                Ok(done) => events.push(AppEvent::Background(BackgroundAction::CoverLoad(done))),
275                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
276                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
277            }
278        }
279        events
280    }
281
282    pub(in crate::tui::app) fn maybe_start_game_detail_cover_load(&mut self) {
283        let (rom_id, url) = match &mut self.screen {
284            AppScreen::GameDetail(detail) => {
285                if !detail.should_request_cover_load() {
286                    return;
287                }
288                detail.set_cover_loading();
289                let Some(url) = detail.cover_last_url.clone() else {
290                    return;
291                };
292                (detail.rom.id, url)
293            }
294            _ => return,
295        };
296        self.spawn_cover_load_worker(rom_id, url);
297    }
298
299    pub(in crate::tui::app) fn spawn_save_list_worker(&mut self, rom_id: u64) {
300        if let AppScreen::GameDetail(detail) = &mut self.screen {
301            detail.set_saves_loading();
302        }
303        let client = self.client.clone();
304        let tx = self.save_list_tx.clone();
305        tokio::spawn(async move {
306            let result = async {
307                let value = client
308                    .request_json(
309                        "GET",
310                        "/api/saves",
311                        &[("rom_id".to_string(), rom_id.to_string())],
312                        None,
313                    )
314                    .await?;
315                SaveMetadata::from_api_value(value)
316            }
317            .await
318            .map_err(|e| format!("{e:#}"));
319            let _ = tx.send(SaveListDone { rom_id, result });
320        });
321    }
322
323    pub(in crate::tui::app) fn refresh_current_game_saves(&mut self) {
324        if let AppScreen::GameDetail(detail) = &self.screen {
325            self.spawn_save_list_worker(detail.rom.id);
326        }
327    }
328
329    fn drain_save_results(&mut self) -> Vec<AppEvent> {
330        let mut events = Vec::new();
331        while let Ok(done) = self.save_list_rx.try_recv() {
332            events.push(AppEvent::Background(BackgroundAction::SaveList(done)));
333        }
334        while let Ok(done) = self.save_upload_rx.try_recv() {
335            events.push(AppEvent::Background(BackgroundAction::SaveUpload(done)));
336        }
337        while let Ok(done) = self.save_download_rx.try_recv() {
338            events.push(AppEvent::Background(BackgroundAction::SaveDownload(done)));
339        }
340        events
341    }
342
343    fn drain_settings_results(&mut self) -> Vec<AppEvent> {
344        let mut events = Vec::new();
345        while let Ok(done) = self.device_list_rx.try_recv() {
346            events.push(AppEvent::Background(BackgroundAction::DeviceList(done)));
347        }
348        while let Ok(done) = self.platform_list_rx.try_recv() {
349            events.push(AppEvent::Background(BackgroundAction::PlatformList(done)));
350        }
351        while let Ok(done) = self.sync_push_pull_rx.try_recv() {
352            events.push(AppEvent::Background(BackgroundAction::SyncPushPull(done)));
353        }
354        events
355    }
356
357    fn drain_rom_load_results(&mut self) -> Vec<AppEvent> {
358        let mut events = Vec::new();
359        loop {
360            match self.rom_load_rx.try_recv() {
361                Ok(done) => events.push(AppEvent::Background(BackgroundAction::RomLoad(done))),
362                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
363                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
364            }
365        }
366        events
367    }
368
369    fn drain_library_metadata_refresh(&mut self) -> Vec<AppEvent> {
370        let mut events = Vec::new();
371        let mut disconnected = false;
372        if let Some(rx) = &mut self.library_metadata_rx {
373            loop {
374                match rx.try_recv() {
375                    Ok(msg) => events.push(AppEvent::Background(
376                        BackgroundAction::LibraryMetadataRefresh(msg),
377                    )),
378                    Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
379                    Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
380                        disconnected = true;
381                        break;
382                    }
383                }
384            }
385        }
386        if disconnected {
387            self.library_metadata_rx = None;
388        }
389        events
390    }
391
392    pub(in crate::tui::app) fn apply_library_metadata_refresh(
393        &mut self,
394        msg: LibraryMetadataRefreshDone,
395    ) {
396        if msg.gen != self.library_metadata_refresh_gen {
397            return;
398        }
399
400        let (selection_reload, post_scan_reload) = {
401            let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
402                return;
403            };
404
405            let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
406            let live_empty = msg.collections.is_empty();
407            if live_empty && had_cached_lists && !msg.warnings.is_empty() {
408                lib.set_temporary_metadata_footer(
409                    "Could not refresh library metadata (keeping cached list).".into(),
410                    std::time::Duration::from_secs(3),
411                );
412                self.force_rom_reload_after_metadata = false;
413                return;
414            }
415
416            let old_digest = startup_library_snapshot::build_collection_digest_from_collections(
417                &lib.collections,
418            );
419            let digest_changed = old_digest != msg.collection_digest;
420            let update_platforms = !msg.platforms.is_empty();
421            let selection_changed = lib.replace_metadata_preserving_selection(
422                msg.platforms,
423                msg.collections,
424                update_platforms,
425                true,
426            );
427            startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
428
429            let footer = if msg.warnings.is_empty() {
430                if digest_changed {
431                    Some("Collection metadata updated.".into())
432                } else {
433                    None
434                }
435            } else {
436                let w = msg.warnings.join(" | ");
437                let short: String = if w.chars().count() > 160 {
438                    let prefix: String = w.chars().take(157).collect();
439                    format!("{prefix}…")
440                } else {
441                    w
442                };
443                Some(format!("Partial refresh: {}", short))
444            };
445            lib.set_metadata_footer(footer);
446
447            let force_reload = std::mem::take(&mut self.force_rom_reload_after_metadata);
448            let selection_reload = if selection_changed && lib.list_len() > 0 {
449                lib.clear_roms();
450                let key = lib.cache_key();
451                let expected = lib.expected_rom_count();
452                let req = Self::selected_rom_request_for_library(lib);
453                lib.set_rom_loading(expected > 0);
454                Some((key, req, expected, "refresh_selection"))
455            } else {
456                None
457            };
458            let post_scan_reload = if force_reload && lib.list_len() > 0 && !selection_changed {
459                lib.clear_roms();
460                let key = lib.cache_key();
461                let expected = lib.expected_rom_count();
462                let req = Self::selected_rom_request_for_library(lib);
463                lib.set_rom_loading(expected > 0);
464                Some((key, req, expected, "post_scan_reload"))
465            } else {
466                None
467            };
468            (selection_reload, post_scan_reload)
469        };
470
471        if let Some((key, req, expected, context)) = selection_reload {
472            self.queue_primary_rom_load(key, req, expected, context);
473        } else if let Some((key, req, expected, context)) = post_scan_reload {
474            self.queue_primary_rom_load(key, req, expected, context);
475        }
476
477        self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
478    }
479
480    fn drain_collection_prefetch_results(&mut self) -> Vec<AppEvent> {
481        let mut events = Vec::new();
482        loop {
483            match self.collection_prefetch_rx.try_recv() {
484                Ok(done) => events.push(AppEvent::Background(
485                    BackgroundAction::CollectionPrefetch(done),
486                )),
487                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
488                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
489            }
490        }
491        events
492    }
493}