From 5807493a4a63cc8ce282b8b4d1c47d3befb641d9 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 12 Oct 2026 22:22:25 +0200 Subject: [PATCH] feat(core,app,android,ios): look at shares again on their own, and say when they were last looked at No client crawled a share unless someone pressed Refresh, so a new episode stayed out of the library until then, and nothing said how stale the library was. Catalog schema v11 adds share_crawls: when each share's last crawl finished and was attempted, how many files it stored and how many were new, and why the last attempt failed. A failure keeps the last good crawl's numbers. Every client now crawls, quietly, any share not looked at for six hours: the desktop at launch and every half hour while open, the phones at launch and on their existing catch-up timer. A quiet crawl says only news ("anime: 3 new files") and leaves failures on the share's row rather than in a toast. Share rows on all three show "120 files - crawled 2 h ago", or the last failure until a crawl succeeds. --- .../io/narl/protonstream/ui/AppViewModel.kt | 16 +- .../io/narl/protonstream/ui/SharesScreen.kt | 48 ++++- .../io/narl/protonstream/ui/ScreenshotTest.kt | 12 +- .../app/src/test/screenshots/shares-phone.png | Bin 39615 -> 54848 bytes crates/pstr-android/src/lib.rs | 97 ++++++--- crates/pstr-app/src/app.rs | 65 +++++- crates/pstr-app/src/engine.rs | 103 +++++++++- crates/pstr-app/src/ui/account.rs | 8 +- crates/pstr-app/src/ui/mod.rs | 13 ++ crates/pstr-app/src/ui/shares.rs | 30 ++- crates/pstr-core/src/catalog.rs | 194 +++++++++++++++++- ios/ProtonStream/App/AppModel.swift | 14 +- ios/ProtonStream/UI/SharesView.swift | 27 ++- 13 files changed, 552 insertions(+), 75 deletions(-) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index 26f7376..433b6ca 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -297,7 +297,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie } } reloadNow() - if (failed.isNotEmpty()) { + // A catch-up is the app's doing, not the viewer's: a failure goes on + // the share's row rather than in their face, and only news is said. + if (failed.isNotEmpty() && announce) { mutableState.update { it.copy(message = failed.joinToString("\n")) } return } @@ -305,13 +307,19 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie if (announce) { val added = (mutableState.value.titles.map { it.key }.toSet() - before).size mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") } + } else { + val files = mutableState.value.shares.filter { it.id in ids }.sumOf { it.crawlAdded ?: 0uL } + if (files > 0uL) { + mutableState.update { it.copy(message = if (files == 1uL) "1 new file" else "$files new files") } + } } } /** - * Crawl shares the catalog has nothing of: brought by a sync that ran in - * [io.narl.protonstream.sync.WatchSyncWorker], or an add whose crawl never - * finished. + * Crawl shares due a look: ones the catalog has nothing of — brought by a + * sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add + * whose crawl never finished — and ones not crawled for six hours, so new + * episodes turn up without a pull to refresh. */ fun catchUp() { if (catchingUp) return diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index aa152e9..1abad13 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.window.DialogProperties @@ -167,6 +168,25 @@ internal fun SharesScreen( * Three differently styled controls beside the name squeezed it into a column * a word wide; one icon leaves the row its width. */ +/** "120 files · crawled 2 h ago", or that it has not been yet. */ +internal fun crawlLine(share: ShareRecord, now: Long = System.currentTimeMillis() / 1_000): String { + val at = share.crawledAt ?: return "Not crawled yet" + val files = share.crawlFiles?.toLong()?.let { if (it == 1L) "1 file" else "$it files" } + return listOfNotNull(files, "crawled ${ago(at, now)}").joinToString(" · ") +} + +/** "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. */ +internal fun ago(at: Long, now: Long): String { + val seconds = (now - at).coerceAtLeast(0) + return when { + seconds < 60 -> "just now" + seconds < 3_600 -> "${seconds / 60} min ago" + seconds < 86_400 -> "${seconds / 3_600} h ago" + seconds < 172_800 -> "1 day ago" + else -> "${seconds / 86_400} days ago" + } +} + @Composable private fun ShareRow( share: ShareRecord, @@ -178,13 +198,27 @@ private fun ShareRow( ListItem( headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) }, supportingContent = { - Text( - when { - share.fromAccount -> "Folder of your Drive" - share.hasCustomPassword -> "Link and custom password, stored securely" - else -> "Public link" - }, - ) + Column { + Text( + when { + share.fromAccount -> "Folder of your Drive" + share.hasCustomPassword -> "Link and custom password" + else -> "Public link" + }, + ) + Text(crawlLine(share), style = MaterialTheme.typography.bodySmall) + // Until a crawl succeeds: a link revoked upstream otherwise + // just stops growing. + share.crawlError?.let { + Text( + "Last crawl failed: $it", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } }, leadingContent = { Icon( diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index eb9b83d..d075425 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -309,9 +309,17 @@ class ScreenshotTest { }, shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))), ), + // Crawled relative to the clock, so the row reads "2 h ago" on + // whatever day the test runs. shares = listOf( - ShareRecord("anime", "anime", true), - ShareRecord("films", "films", false), + ShareRecord( + "anime", + "anime", + true, + crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600, + crawlFiles = 120uL, + ), + ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"), ), storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL), ) diff --git a/android/app/src/test/screenshots/shares-phone.png b/android/app/src/test/screenshots/shares-phone.png index be4d525aae5eefc41b46ebc09d8679073be70e82..edc2ffe843860880141dfce2922206116337cb3c 100644 GIT binary patch literal 54848 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

|(@?01bx~s4V@v|r)-%`R5)@M?ZQ)zd+@df} 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 diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index a39fc2b..b84e53d 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -140,6 +140,18 @@ pub struct ShareRecord { /// A folder of the signed-in account rather than a public link. #[uniffi(default = false)] pub from_account: bool, + /// When the last crawl here finished, Unix seconds. `None` before one has. + #[uniffi(default = None)] + pub crawled_at: Option, + /// Playable files that crawl stored. + #[uniffi(default = None)] + pub crawl_files: Option, + /// Of those, how many no earlier crawl had seen. + #[uniffi(default = None)] + pub crawl_added: Option, + /// Why the last attempt failed, until one succeeds. + #[uniffi(default = None)] + pub crawl_error: Option, } fn share_record(share: Share) -> ShareRecord { @@ -148,6 +160,10 @@ fn share_record(share: Share) -> ShareRecord { id: share.id, name: share.name, has_custom_password: share.has_custom_password, + crawled_at: None, + crawl_files: None, + crawl_added: None, + crawl_error: None, } } @@ -1259,31 +1275,52 @@ impl AndroidEngine { Ok(share_record(share)) } - /// Configured shares with nothing in the catalog: brought in by sync, - /// possibly while the app was in the background, or added here and never - /// crawled to the end. Crawl each. An empty share is crawled every time, - /// which costs one listing. + /// Configured shares due a crawl: never crawled here — brought in by sync, + /// possibly while the app was in the background, or added and never + /// crawled to the end — or not looked at for + /// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`], so a share grows + /// without anyone pressing Refresh. Crawl each. pub fn uncrawled_shares(&self) -> Result, BridgeError> { let shares = self.store.list().map_err(BridgeError::from_display)?; - let catalog = self.catalog.lock(); - let mut uncrawled = Vec::new(); - for share in shares { - if catalog - .files(&share.id) - .map_err(BridgeError::from_display)? - .is_empty() - { - uncrawled.push(share.id); - } - } - Ok(uncrawled) + let crawls = self + .catalog + .lock() + .crawls() + .map_err(BridgeError::from_display)?; + let now = now(); + Ok(shares + .into_iter() + .filter(|share| { + crawls.get(&share.id).is_none_or(|record| { + record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS) + }) + }) + .map(|share| share.id) + .collect()) } + /// Every configured share, with what its last crawl here came to. pub fn shares(&self) -> Result, BridgeError> { - self.store - .list() - .map_err(BridgeError::from_display) - .map(|shares| shares.into_iter().map(share_record).collect()) + let shares = self.store.list().map_err(BridgeError::from_display)?; + let crawls = self + .catalog + .lock() + .crawls() + .map_err(BridgeError::from_display)?; + Ok(shares + .into_iter() + .map(|share| { + let crawl = crawls.get(&share.id).cloned(); + let mut record = share_record(share); + if let Some(crawl) = crawl { + record.crawled_at = (crawl.crawled_at > 0).then_some(crawl.crawled_at); + record.crawl_files = (crawl.crawled_at > 0).then_some(crawl.files); + record.crawl_added = (crawl.crawled_at > 0).then_some(crawl.added); + record.crawl_error = crawl.error; + } + record + }) + .collect()) } /// Add a public link, once it has opened with the password given. A link @@ -2498,11 +2535,21 @@ impl AndroidEngine { .lock() .all_offline_files() .map_err(BridgeError::from_display)?; - let nodes = connection - .library - .crawl(&id) - .await - .map_err(BridgeError::from_display)?; + let nodes = match connection.library.crawl(&id).await { + Ok(nodes) => nodes, + Err(error) => { + // On the share's row until a crawl succeeds: a link revoked + // upstream otherwise just stops growing. + if let Err(error) = self + .catalog + .lock() + .record_crawl_failure(&id, &error.to_string()) + { + log::warn!("record a failed crawl: {error}"); + } + return Err(BridgeError::from_display(error)); + } + }; let rows = build_rows(&id, &nodes); self.catalog .lock() diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 5dd34b7..cc0f624 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -95,6 +95,10 @@ pub enum Page { Player, } +/// How often, in seconds, an open window looks for shares due a crawl. What +/// counts as due is [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`]. +const STALE_CHECK_EVERY: f64 = 30.0 * 60.0; + /// The pages in the top bar, in order. Ctrl+1 to Ctrl+5 go by position here. const NAV: [Page; 5] = [ Page::Library, @@ -530,6 +534,10 @@ pub struct App { pub page: Page, pub library: Library, pub shares: Vec, + /// What the last crawl of each share came to, for its row. + pub crawls: HashMap, + /// When, on egui's clock, to next look for shares due a crawl. + next_stale_check: f64, /// Proton's own per-file thumbnails. pub thumbs: ImageCache, /// Artwork from a metadata provider, keyed by title key. @@ -631,7 +639,9 @@ impl App { engine.load_library(); engine.forget_orphaned_shares(); engine.load_metadata(); + engine.load_crawls(); engine.connect(); + engine.crawl_stale(); let settings = engine.metadata_config(); Ok(Self { @@ -641,6 +651,8 @@ impl App { page: Page::Library, library: Library::default(), shares: Vec::new(), + crawls: HashMap::new(), + next_stale_check: STALE_CHECK_EVERY, thumbs: ImageCache::default(), posters: ImageCache::default(), metadata: HashMap::new(), @@ -761,20 +773,35 @@ impl App { share_id, nodes, files, + added, seconds, + quiet, } => { let name = self.share_name(&share_id); self.crawl_progress.remove(&name); - self.note( - ctx, - format!("{name}: {files} playable of {nodes} nodes in {seconds:.0}s"), - false, - ); - // Only a crawl long enough to have walked away from. - if seconds >= 30.0 { - self.notify(ctx, &format!("Crawled {name}"), &format!("{files} files")); + if quiet { + // Looked at by the app, not the viewer: only news. + if added > 0 { + let text = format!( + "{name}: {}", + ui::library::plural(added as usize, "new file") + ); + self.note(ctx, &text, false); + self.notify(ctx, &format!("New in {name}"), &text); + } + } else { + self.note( + ctx, + format!("{name}: {files} playable of {nodes} nodes in {seconds:.0}s"), + false, + ); + // Only a crawl long enough to have walked away from. + if seconds >= 30.0 { + self.notify(ctx, &format!("Crawled {name}"), &format!("{files} files")); + } } } + Event::Crawls(crawls) => self.crawls = crawls, Event::CrawlFinished => { self.crawling = false; self.crawl_progress.clear(); @@ -1807,6 +1834,15 @@ impl eframe::App for App { let ctx = ui.ctx().clone(); let ctx = &ctx; self.pump(ctx, frame); + // A window left open for days still picks up what was added: the + // launch check alone would only ever run once. + let now = ctx.input(|input| input.time); + if now >= self.next_stale_check { + self.next_stale_check = now + STALE_CHECK_EVERY; + if now > 0.0 && !self.crawling { + self.engine.crawl_stale(); + } + } self.warm_next(); self.auto_skip(ctx); self.window.observe(ctx); @@ -2000,6 +2036,7 @@ impl eframe::App for App { page, library, shares, + crawls, thumbs, posters, metadata, @@ -2107,9 +2144,15 @@ impl eframe::App for App { }, &mut actions, ), - Page::Shares => { - ui::shares::show(ui, shares, library, form, account, &mut actions) - } + Page::Shares => ui::shares::show( + ui, + shares, + crawls, + library, + form, + account, + &mut actions, + ), Page::Settings => { ui::settings::show(ui, settings, prefs, api_key, &mut actions) } diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 9573077..474513d 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -193,8 +193,15 @@ pub enum Event { share_id: String, nodes: usize, files: usize, + /// Files no earlier crawl had seen. + added: u64, seconds: f64, + /// Started by the app rather than the viewer, so only news is worth + /// saying. + quiet: bool, }, + /// What the last crawl of each share came to, by share id. + Crawls(HashMap), /// A crawl is under way: which share, and how many nodes so far. CrawlProgress { share: String, found: usize }, /// Every requested crawl is done. @@ -402,6 +409,14 @@ const DOWNLOAD_CONCURRENCY: usize = 3; /// launch and whenever a player stops. const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60); +/// Unix seconds, now. +pub fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_secs() as i64) + .unwrap_or(0) +} + /// Enrichment as currently configured. struct Enrichment { config: MetadataConfig, @@ -663,7 +678,7 @@ impl Engine { // The open connection predates the share, and crawling it through // that would find it "not open". engine.connection.lock().take(); - engine.connect_and_crawl(Some(share.id)).await; + engine.connect_and_crawl(Some(vec![share.id]), false).await; }); } @@ -682,7 +697,7 @@ impl Engine { engine.emit(Event::ShareAdded(share.name.clone())); engine.load_shares(); engine.connection.lock().take(); - engine.connect_and_crawl(Some(share.id)).await; + engine.connect_and_crawl(Some(vec![share.id]), false).await; }); } @@ -944,7 +959,7 @@ impl Engine { self.load_shares(); self.load_library(); for id in profile.shares_added.iter().chain(&profile.shares_changed) { - self.connect_and_crawl(Some(id.clone())).await; + self.connect_and_crawl(Some(vec![id.clone()]), false).await; } } @@ -1277,18 +1292,65 @@ impl Engine { pub fn crawl(&self, share: Option) { let engine = self.clone(); self.runtime.spawn(async move { - engine.connect_and_crawl(share).await; + engine + .connect_and_crawl(share.map(|id| vec![id]), false) + .await; }); } - async fn connect_and_crawl(&self, share: Option) { + /// Crawl, quietly, every share no crawl has looked at for + /// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`] — or ever. + /// + /// Without this a share only grew when someone thought to press Refresh, + /// so the episode that came out yesterday was not in the library until + /// then. Quiet: a failure is logged and recorded on the share's row rather + /// than toasted, and a crawl that found nothing new says nothing. + pub fn crawl_stale(&self) { + let engine = self.clone(); + self.runtime.spawn(async move { + let stale = { + let crawls = match engine.catalog.lock().crawls() { + Ok(crawls) => crawls, + Err(error) => return tracing::warn!(%error, "read crawl records"), + }; + let Ok(shares) = engine.store.list() else { + return; + }; + let now = unix_now(); + shares + .into_iter() + .filter(|share| { + crawls.get(&share.id).is_none_or(|record| { + record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS) + }) + }) + .map(|share| share.id) + .collect::>() + }; + if !stale.is_empty() { + engine.connect_and_crawl(Some(stale), true).await; + } + }); + } + + /// Read what the last crawl of each share came to, for the shares page. + pub fn load_crawls(&self) { + let engine = self.clone(); + self.runtime + .spawn_blocking(move || match engine.catalog.lock().crawls() { + Ok(crawls) => engine.emit(Event::Crawls(crawls)), + Err(error) => tracing::warn!(%error, "read crawl records"), + }); + } + + async fn connect_and_crawl(&self, shares: Option>, quiet: bool) { let Some((library, _)) = self.ensure_connected().await else { self.emit(Event::CrawlFinished); return; }; - let targets: Vec = match share { - Some(id) => vec![id], + let targets: Vec = match shares { + Some(ids) => ids, None => library.share_ids().map(str::to_string).collect(), }; @@ -1298,7 +1360,7 @@ impl Engine { futures::future::join_all( targets .into_iter() - .map(|share_id| self.crawl_share(&library, share_id, cancel.clone())), + .map(|share_id| self.crawl_share(&library, share_id, cancel.clone(), quiet)), ) .await; self.emit(Event::CrawlFinished); @@ -1342,6 +1404,7 @@ impl Engine { library: &SharedLibrary, share_id: String, mut cancel: tokio::sync::watch::Receiver, + quiet: bool, ) { let started = std::time::Instant::now(); let name = self @@ -1376,7 +1439,19 @@ impl Engine { }) => match result { Ok(nodes) => nodes, Err(error) => { - self.fail(&format!("crawl {share_id}"), error); + if let Err(error) = self + .catalog + .lock() + .record_crawl_failure(&share_id, &error.to_string()) + { + tracing::warn!(%error, "record a failed crawl"); + } + self.load_crawls(); + if quiet { + tracing::warn!(%error, "crawl {name}"); + } else { + self.fail(&format!("crawl {name}"), error); + } return; } }, @@ -1455,12 +1530,22 @@ impl Engine { return; } + let added = self + .catalog + .lock() + .crawls() + .ok() + .and_then(|crawls| crawls.get(&share_id).map(|record| record.added)) + .unwrap_or(0); self.emit(Event::Crawled { share_id, nodes: nodes.len(), files, + added, seconds: started.elapsed().as_secs_f64(), + quiet, }); + self.load_crawls(); self.load_library(); } diff --git a/crates/pstr-app/src/ui/account.rs b/crates/pstr-app/src/ui/account.rs index fd56fc9..7e4d81e 100644 --- a/crates/pstr-app/src/ui/account.rs +++ b/crates/pstr-app/src/ui/account.rs @@ -315,13 +315,7 @@ fn signed_in( /// "Synced 3 min ago · 2 positions from other devices". fn sync_line(at: i64, report: &SyncReport, now: i64) -> String { - let ago = (now - at).max(0); - let when = match ago { - 0..60 => "just now".to_owned(), - 60..3600 => format!("{} min ago", ago / 60), - _ => format!("{} h ago", ago / 3600), - }; - let mut line = format!("Synced {when}"); + let mut line = format!("Synced {}", ui::ago(at, now)); if report.applied > 0 { line.push_str(&format!( " · {} from other devices", diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index fb41be1..4588dae 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -172,6 +172,19 @@ pub fn wide_key(title_key: &str) -> String { format!("wide:{title_key}") } +/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now` +/// the Unix time `at` was. +pub fn ago(at: i64, now: i64) -> String { + let ago = (now - at).max(0); + match ago { + 0..60 => "just now".to_owned(), + 60..3600 => format!("{} min ago", ago / 60), + 3600..86_400 => format!("{} h ago", ago / 3600), + 86_400..172_800 => "1 day ago".to_owned(), + _ => format!("{} days ago", ago / 86_400), + } +} + /// `1:03:47`, or `4:12` for anything under an hour. pub fn format_time(seconds: f64) -> String { if !seconds.is_finite() || seconds < 0.0 { diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs index aadb466..8f1419a 100644 --- a/crates/pstr-app/src/ui/shares.rs +++ b/crates/pstr-app/src/ui/shares.rs @@ -6,7 +6,10 @@ //! below is the only place a link is ever visible, and only while it is being //! typed. +use std::collections::HashMap; + use pstr_core::Share; +use pstr_core::catalog::CrawlRecord; use pstr_core::library::Library; use crate::app::{Action, ShareForm}; @@ -16,6 +19,7 @@ use crate::ui; pub fn show( ui: &mut egui::Ui, shares: &[Share], + crawls: &HashMap, library: &Library, form: &mut ShareForm, account: &mut ui::account::AccountPanel, @@ -36,7 +40,7 @@ pub fn show( .iter() .filter(|title| title.share_ids.contains(&share.id)) .count(); - share_row(ui, share, titles, actions); + share_row(ui, share, titles, crawls.get(&share.id), actions); } ui.add_space(theme::space::XXL); @@ -48,7 +52,13 @@ pub fn show( }); } -fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec) { +fn share_row( + ui: &mut egui::Ui, + share: &Share, + titles: usize, + crawl: Option<&CrawlRecord>, + actions: &mut Vec, +) { egui::Frame::new() .fill(theme::card()) .corner_radius(egui::CornerRadius::same(theme::radius::MD)) @@ -67,7 +77,23 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec< if share.has_custom_password { detail.push_str(" · password protected"); } + match crawl { + Some(crawl) if crawl.crawled_at > 0 => { + detail.push_str(" · crawled "); + detail.push_str(&ui::ago(crawl.crawled_at, crate::engine::unix_now())); + } + _ => detail.push_str(" · not crawled yet"), + } ui.label(ui::muted(detail)).on_hover_text(&share.id); + // Why the last look failed, until one succeeds: a link + // revoked upstream otherwise just stops growing. + if let Some(error) = crawl.and_then(|crawl| crawl.error.as_deref()) { + ui.label( + theme::Role::Caption + .rich(format!("Last crawl failed: {error}")) + .color(theme::danger()), + ); + } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 31b8ea9..097521a 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -24,7 +24,7 @@ use crate::metadata::{ use crate::naming::{self, ParsedName}; /// Bump when adding a migration. -pub const SCHEMA_VERSION: i64 = 10; +pub const SCHEMA_VERSION: i64 = 11; const MIGRATION_V1: &str = r#" CREATE TABLE nodes ( @@ -207,6 +207,51 @@ INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at) UPDATE title_metadata SET enriched_at = NULL; "#; +/// What the last crawl of each share came to. +/// +/// Per installation, like the rest of the catalog: when this device last +/// looked at a share is nothing another device needs. `crawled_at` is the last +/// crawl that finished, `attempted_at` the last that was tried, so a share that +/// keeps failing is not retried on every launch. `added` is how many files the +/// last finished crawl found that had never been seen before. +const MIGRATION_V11: &str = r#" +CREATE TABLE share_crawls ( + share_id TEXT PRIMARY KEY, + crawled_at INTEGER NOT NULL, + attempted_at INTEGER NOT NULL, + files INTEGER NOT NULL, + added INTEGER NOT NULL, + error TEXT +); +"#; + +/// How old a share's last crawl may be before an app looks again on its own. +/// Long enough that a launch rarely costs a crawl; short enough that a new +/// episode turns up the same day. +pub const CRAWL_STALE_AFTER_SECS: i64 = 6 * 60 * 60; + +/// One share's row of `share_crawls`. See [`MIGRATION_V11`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrawlRecord { + /// Unix seconds; `0` for a share no crawl of has finished. + pub crawled_at: i64, + pub attempted_at: i64, + /// Playable files the last finished crawl stored. + pub files: u64, + /// Of those, how many no earlier crawl had seen. + pub added: u64, + /// Why the last attempt failed, when it did. + pub error: Option, +} + +impl CrawlRecord { + /// Whether the share is due another look: nothing has been tried within + /// `age_secs` of `now`. + pub fn is_stale(&self, now: i64, age_secs: i64) -> bool { + now - self.crawled_at.max(self.attempted_at) >= age_secs + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct OfflineFile { pub revision_id: String, @@ -408,6 +453,12 @@ impl Catalog { tx.pragma_update(None, "user_version", 10)?; tx.commit()?; } + if version < 11 { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATION_V11)?; + tx.pragma_update(None, "user_version", 11)?; + tx.commit()?; + } Ok(()) } @@ -469,10 +520,23 @@ impl Catalog { } // Only files: a folder appearing is not something anyone watches. - tx.execute( + let now = unix_now(); + let added = tx.execute( "INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at) SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0", - params![share_id, unix_now()], + params![share_id, now], + )?; + let files = nodes.iter().filter(|node| !node.is_folder).count(); + tx.execute( + "INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error) + VALUES (?1, ?2, ?2, ?3, ?4, NULL) + ON CONFLICT (share_id) DO UPDATE SET + crawled_at = excluded.crawled_at, + attempted_at = excluded.attempted_at, + files = excluded.files, + added = excluded.added, + error = NULL", + params![share_id, now, files as i64, added as i64], )?; if !retain_offline { @@ -483,6 +547,41 @@ impl Catalog { Ok(()) } + /// Record that a crawl of `share_id` failed, keeping what the last + /// finished one stored. + pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> { + self.conn.execute( + "INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error) + VALUES (?1, 0, ?2, 0, 0, ?3) + ON CONFLICT (share_id) DO UPDATE SET + attempted_at = excluded.attempted_at, + error = excluded.error", + params![share_id, unix_now(), error], + )?; + Ok(()) + } + + /// What the last crawl of each share came to, by share id. A share never + /// crawled has no entry. + pub fn crawls(&self) -> Result> { + let mut statement = self.conn.prepare( + "SELECT share_id, crawled_at, attempted_at, files, added, error FROM share_crawls", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + CrawlRecord { + crawled_at: row.get(1)?, + attempted_at: row.get(2)?, + files: row.get::<_, i64>(3)?.max(0) as u64, + added: row.get::<_, i64>(4)?.max(0) as u64, + error: row.get(5)?, + }, + )) + })?; + Ok(rows.collect::>()?) + } + /// Every file in a share, folders excluded, ordered for display. pub fn files(&self, share_id: &str) -> Result> { let mut statement = self.conn.prepare( @@ -579,6 +678,10 @@ impl Catalog { "DELETE FROM node_first_seen WHERE share_id = ?1", params![share_id], )?; + tx.execute( + "DELETE FROM share_crawls WHERE share_id = ?1", + params![share_id], + )?; tx.commit()?; Ok(()) } @@ -2133,6 +2236,91 @@ mod tests { assert!(catalog.enrichment_ages().expect("ages").is_empty()); } + #[test] + fn a_crawl_records_when_it_finished_and_what_it_added() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .replace_share("s", &[file("s", "a", "Show S01E01.mkv")]) + .expect("crawl"); + catalog + .replace_share( + "s", + &[ + file("s", "a", "Show S01E01.mkv"), + file("s", "b", "Show S01E02.mkv"), + ], + ) + .expect("recrawl"); + let record = &catalog.crawls().expect("read")["s"]; + assert!(record.crawled_at > 0); + assert_eq!(record.attempted_at, record.crawled_at); + assert_eq!((record.files, record.added), (2, 1)); + assert_eq!(record.error, None); + } + + /// A failure says why without forgetting what the last good crawl found, + /// and the next good crawl clears it. + #[test] + fn a_failed_crawl_keeps_the_last_good_one_until_the_next() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .replace_share("s", &[file("s", "a", "Show S01E01.mkv")]) + .expect("crawl"); + catalog + .conn + .execute( + "UPDATE share_crawls SET crawled_at = 5, attempted_at = 5", + [], + ) + .expect("age it"); + catalog + .record_crawl_failure("s", "link expired") + .expect("fail"); + let record = catalog.crawls().expect("read")["s"].clone(); + assert_eq!((record.crawled_at, record.files), (5, 1)); + assert!(record.attempted_at > 5); + assert_eq!(record.error.as_deref(), Some("link expired")); + assert!(!record.is_stale(record.attempted_at + 10, 60)); + + catalog + .replace_share("s", &[file("s", "a", "Show S01E01.mkv")]) + .expect("recrawl"); + assert_eq!(catalog.crawls().expect("read")["s"].error, None); + } + + #[test] + fn a_removed_share_forgets_its_crawls() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .replace_share("s", &[file("s", "a", "Show S01E01.mkv")]) + .expect("crawl"); + catalog.remove_share("s").expect("remove"); + assert!(catalog.crawls().expect("read").is_empty()); + } + + #[test] + fn migration_v11_opens_a_v10_catalog() { + let conn = Connection::open_in_memory().expect("open"); + for migration in [ + MIGRATION_V1, + MIGRATION_V2, + MIGRATION_V3, + MIGRATION_V4, + MIGRATION_V5, + MIGRATION_V6, + MIGRATION_V7, + MIGRATION_V8, + MIGRATION_V9, + MIGRATION_V10, + ] { + conn.execute_batch(migration).expect("migrate"); + } + conn.pragma_update(None, "user_version", 10) + .expect("set version"); + let catalog = Catalog::from_connection(conn).expect("upgrade"); + assert!(catalog.crawls().expect("read").is_empty()); + } + /// A file keeps the moment it first appeared across recrawls, and one /// that was there before this was recorded reads as 0. #[test] diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index b01b9d3..a48fbb8 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -242,7 +242,9 @@ final class AppModel { } } await reloadNow() - if !failed.isEmpty { + // A catch-up is the app's doing, not the viewer's: a failure goes on + // the share's row rather than in their face, and only news is said. + if !failed.isEmpty, announce { message = failed.joined(separator: "\n") return } @@ -250,6 +252,9 @@ final class AppModel { if announce { let added = Set(titles.map(\.key)).subtracting(before).count message = added == 1 ? "1 title added" : "\(added) titles added" + } else { + let files = shares.filter { ids.contains($0.id) }.reduce(UInt64(0)) { $0 + ($1.crawlAdded ?? 0) } + if files > 0 { message = files == 1 ? "1 new file" : "\(files) new files" } } } @@ -430,9 +435,10 @@ final class AppModel { } } - /// On launch and back on screen: crawl shares the catalog has nothing of — - /// brought by a sync that ran in the background, or an add whose crawl - /// never finished. + /// On launch and back on screen: crawl shares due a look — ones the + /// catalog has nothing of, brought by a sync that ran in the background or + /// an add whose crawl never finished, and ones not crawled for six hours, + /// so new episodes turn up without a pull to refresh. func enteredForeground() { guard !catchingUp else { return } catchingUp = true diff --git a/ios/ProtonStream/UI/SharesView.swift b/ios/ProtonStream/UI/SharesView.swift index 2453c6e..064909b 100644 --- a/ios/ProtonStream/UI/SharesView.swift +++ b/ios/ProtonStream/UI/SharesView.swift @@ -97,6 +97,25 @@ struct SharesView: View { extension ShareRecord: Identifiable {} /// One share: its name, how it is unlocked, and its actions behind a menu. +/// "120 files · crawled 2 h ago", or that it has not been yet. +func crawlLine(_ share: ShareRecord, now: Int64 = Int64(Date.now.timeIntervalSince1970)) -> String { + guard let at = share.crawledAt else { return "Not crawled yet" } + let files = share.crawlFiles.map { $0 == 1 ? "1 file" : "\($0) files" } + return [files, "crawled \(ago(at, now: now))"].compactMap { $0 }.joined(separator: " · ") +} + +/// "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. +func ago(_ at: Int64, now: Int64) -> String { + let seconds = max(now - at, 0) + switch seconds { + case ..<60: return "just now" + case ..<3_600: return "\(seconds / 60) min ago" + case ..<86_400: return "\(seconds / 3_600) h ago" + case ..<172_800: return "1 day ago" + default: return "\(seconds / 86_400) days ago" + } +} + private struct ShareRow: View { let share: ShareRecord let onRefresh: () -> Void @@ -110,8 +129,14 @@ private struct ShareRow: View { VStack(alignment: .leading, spacing: 2) { Text(share.name).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface) Text(share.fromAccount ? "Folder of your Drive" - : share.hasCustomPassword ? "Link and custom password, stored securely" : "Public link") + : share.hasCustomPassword ? "Link and custom password" : "Public link") .textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant) + Text(crawlLine(share)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant) + // Until a crawl succeeds: a link revoked upstream otherwise + // just stops growing. + if let error = share.crawlError { + Text("Last crawl failed: \(error)").textStyle(.bodySmall).foregroundStyle(scheme.error).lineLimit(2) + } } Spacer(minLength: 0) Menu {