video streaming

This commit is contained in:
nvrl committed 2026-07-18 12:08:34 +02:00
1 parent c555315998
commit 38aca3cd48
6 files changed
+1073 -104

No files matched your search

+6 -1
View File
@@ -1056,7 +1056,12 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
}
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
match control_request(CtlRequest::PhotosTimeline { offset, limit })? {
match control_request(CtlRequest::PhotosTimeline {
offset,
limit,
kind: None,
range: None,
})? {
CtlResponse::Photos {
available: false, ..
} => {
+171 -1
View File
@@ -48,7 +48,28 @@ pub enum Request {
/// a thumbnail path comes back only for photos already in the cache, so the
/// reply never waits on the network. Front-ends ask for the thumbnails they
/// actually display with [`Request::PhotoThumbs`].
PhotosTimeline { offset: usize, limit: usize },
PhotosTimeline {
offset: usize,
limit: usize,
/// Restrict the page to one kind (Photos / Videos / Raw). `None` (the
/// default, and what older front-ends send) returns everything. The
/// offset is relative to the filtered timeline, so paging a single tab
/// doesn't have to walk past the other kinds.
#[serde(default)]
kind: Option<PhotoKind>,
/// Restrict the page to a `[from, to)` capture-time window (epoch
/// seconds) — the date scrubber's jump to a month. `None` spans the whole
/// timeline. Like `kind`, the offset is relative to the filtered set.
#[serde(default)]
range: Option<(i64, i64)>,
},
/// The months the timeline spans (newest first, with per-month counts) so a
/// front-end can build a date scrubber without paging the whole library.
/// `kind` scopes the counts to one tab when set.
PhotoMonths {
#[serde(default)]
kind: Option<PhotoKind>,
},
/// Fetch thumbnails for the given photo uids, downloading the ones not
/// already cached (one batched round-trip) and replying with their on-disk
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
@@ -578,6 +599,104 @@ pub struct LocalHit {
pub modified: i64,
}
/// What kind of media a timeline entry is, so the Photos page can split into
/// Photos / Videos / Raw tabs. Derived from a photo's media type when the daemon
/// has resolved it, falling back to its file-name extension (see
/// [`PhotoKind::classify`]).
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PhotoKind {
/// A normal, directly viewable still image (JPEG, PNG, HEIC, …).
Photo,
/// A video clip (mp4, mkv, mov, …).
Video,
/// A camera raw file (CR2, NEF, ARW, DNG, …) — an image, but one that needs
/// developing and is worth separating from ready-to-view photos.
Raw,
}
/// File-name extensions that denote a video, matched case-insensitively.
const VIDEO_EXTS: &[&str] = &[
"mkv", "mp4", "mov", "avi", "webm", "m4v", "flv", "wmv", "mpg", "mpeg", "ts", "3gp", "m2ts",
"mts", "ogv",
];
/// File-name extensions that denote a camera raw. The server media type for
/// these is frequently a generic `application/octet-stream`, so the extension is
/// the authoritative signal.
const RAW_EXTS: &[&str] = &[
"cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "dng", "raf", "orf", "rw2", "srw", "pef",
"raw", "rwl", "iiq", "3fr", "dcr", "kdc", "mrw", "x3f",
];
/// File-name extensions that denote a ready-to-view still photo. Listed so a
/// known image name classifies as a photo outright, without deferring to a media
/// type that might disagree.
const PHOTO_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "avif", "bmp", "tif", "tiff",
];
impl PhotoKind {
/// Classify a photo from what the daemon knows about it. A recognised
/// file-name extension is authoritative — it is reliable even before the
/// node's media type is resolved, and for raw files the media type is often a
/// useless generic — so the media type is only consulted for names that carry
/// no extension we know. Anything still unresolved is a still photo.
pub fn classify(name: Option<&str>, media_type: Option<&str>) -> PhotoKind {
if let Some(ext) = name
.and_then(|n| n.rsplit_once('.'))
.map(|(_, e)| e.to_ascii_lowercase())
{
if RAW_EXTS.contains(&ext.as_str()) {
return PhotoKind::Raw;
}
if VIDEO_EXTS.contains(&ext.as_str()) {
return PhotoKind::Video;
}
if PHOTO_EXTS.contains(&ext.as_str()) {
return PhotoKind::Photo;
}
}
if let Some(mt) = media_type {
if mt.starts_with("video/") {
return PhotoKind::Video;
}
}
PhotoKind::Photo
}
/// The stable integer this kind is persisted as (see the `kind` column of the
/// `photos` table). Chosen once and never reordered.
pub fn as_i64(self) -> i64 {
match self {
PhotoKind::Photo => 0,
PhotoKind::Video => 1,
PhotoKind::Raw => 2,
}
}
/// Inverse of [`PhotoKind::as_i64`]; any unrecognised value reads as a still
/// photo, the safe default for a tab that would otherwise show nothing.
pub fn from_i64(v: i64) -> PhotoKind {
match v {
1 => PhotoKind::Video,
2 => PhotoKind::Raw,
_ => PhotoKind::Photo,
}
}
}
/// One month the timeline spans, with how many photos it holds — a tick on the
/// date scrubber (reply to [`Request::PhotoMonths`]).
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct PhotoMonth {
/// Local-time calendar year, e.g. `2026`.
pub year: i32,
/// Local-time month, `1..=12`.
pub month: i32,
/// Photos captured in that month (within the requested kind, if any).
pub count: usize,
}
/// One photo in a [`Request::PhotosTimeline`] page.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoItem {
@@ -599,6 +718,16 @@ pub struct PhotoItem {
/// and its bytes could not be decoded locally. The tile shows a placeholder
/// rather than waiting for an image that will never come.
pub no_thumb: bool,
/// Which Photos-page tab this entry belongs to. Older daemons that predate
/// the split omit it; a front-end then treats everything as a still photo.
#[serde(default = "default_photo_kind")]
pub kind: PhotoKind,
}
/// A daemon too old to classify a timeline entry is assumed to have served a
/// still photo — that was the only kind the Photos page showed before the split.
fn default_photo_kind() -> PhotoKind {
PhotoKind::Photo
}
/// One thumbnail in a [`Response::Thumbs`] batch.
@@ -694,7 +823,16 @@ pub enum Response {
Photos {
available: bool,
items: Vec<PhotoItem>,
/// Whole-timeline tab counts `(photos, videos, raw)`, so a front-end can
/// label its Photos / Videos / Raw filter without paging the library.
/// The counts describe the *whole* timeline regardless of the page's own
/// `kind` filter. Older daemons omit it; a front-end then shows no counts.
#[serde(default)]
counts: Option<(usize, usize, usize)>,
},
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
/// newest first.
PhotoMonths { months: Vec<PhotoMonth> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// An on-disk path the front-end can open (e.g. a downloaded photo).
@@ -762,6 +900,38 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
mod tests {
use super::*;
#[test]
fn photo_kind_classifies_by_extension_then_mime() {
use PhotoKind::*;
// Raw is extension-driven: the server media type is often generic.
assert_eq!(PhotoKind::classify(Some("IMG_1.CR2"), None), Raw);
assert_eq!(
PhotoKind::classify(Some("shot.dng"), Some("application/octet-stream")),
Raw
);
// Video by extension or by mime when the name has no useful extension.
assert_eq!(PhotoKind::classify(Some("anime.mkv"), None), Video);
assert_eq!(PhotoKind::classify(Some("clip.MP4"), None), Video);
assert_eq!(PhotoKind::classify(None, Some("video/quicktime")), Video);
// Everything else is a still photo, including a mismatched raw mime whose
// name says it is a normal JPEG.
assert_eq!(PhotoKind::classify(Some("pic.jpg"), None), Photo);
assert_eq!(PhotoKind::classify(Some("pic.heic"), None), Photo);
assert_eq!(PhotoKind::classify(None, None), Photo);
// Extension wins over mime: a name ending .jpg is a photo even if the mime
// is nonsense.
assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo);
}
#[test]
fn photo_kind_i64_round_trips() {
for k in [PhotoKind::Photo, PhotoKind::Video, PhotoKind::Raw] {
assert_eq!(PhotoKind::from_i64(k.as_i64()), k);
}
// An unknown persisted value degrades to a still photo.
assert_eq!(PhotoKind::from_i64(99), PhotoKind::Photo);
}
/// The whole point of the split counts: a queued `mkdir` is work, but it is
/// not an upload and must never be reported as one.
#[test]
+194 -42
View File
@@ -23,7 +23,7 @@ use crate::control::{ActivityEntry, ActivityKind};
use crate::localindex::LocalEntry;
/// Current schema version. Bump on every forward migration added below.
const SCHEMA_VERSION: i64 = 12;
const SCHEMA_VERSION: i64 = 13;
/// How many activity rows to keep. Older rows are pruned on insert, so the feed
/// stays a bounded "recent history" rather than growing without limit.
@@ -196,6 +196,9 @@ pub struct StoredPhoto {
pub ratio: Option<f64>,
/// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`].
pub thumb_state: i64,
/// Which Photos-page tab this entry belongs to, derived from its name and
/// media type when the timeline was last replaced.
pub kind: crate::control::PhotoKind,
}
/// One trashed node, as persisted for the Trash page. Trashed nodes live outside
@@ -360,6 +363,9 @@ impl Db {
if current < 12 {
tx.execute_batch(MIGRATION_V12)?;
}
if current < 13 {
tx.execute_batch(MIGRATION_V13)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -866,32 +872,55 @@ impl Db {
///
/// Photos no longer in the timeline are dropped, so a deletion on another
/// client doesn't leave a ghost tile behind.
pub fn photos_replace(&self, items: &[(String, i64, Option<String>)]) -> Result<()> {
pub fn photos_replace(
&self,
items: &[(String, i64, Option<String>, Option<String>)],
) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let learned: HashMap<String, (Option<f64>, i64)> = {
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state FROM photos")?;
let rows =
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?))))?;
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
// timeline DTO carries only the uid and capture time, so the daemon may
// not know a photo's media type yet when it replaces the timeline. Keep
// any previously learned value so the Photos/Videos/Raw split survives a
// refresh instead of collapsing back to name-extension guesses.
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
let mut stmt =
tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
tx.execute("DELETE FROM photos", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO photos (uid, capture_time, name, ratio, thumb_state, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
for (seq, (uid, capture_time, name)) in items.iter().enumerate() {
let (ratio, thumb_state) =
learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN));
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media) = learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let media_type = media_type.clone().or(learned_media);
// The tab this photo lands in is derived here, once, so a page or
// count query is a plain indexed `WHERE kind = ?` rather than a
// reclassification of every row.
let kind = crate::control::PhotoKind::classify(
name.as_deref(),
media_type.as_deref(),
);
stmt.execute(params![
uid,
capture_time,
name,
ratio,
thumb_state,
seq as i64
seq as i64,
media_type,
kind.as_i64(),
])?;
}
}
@@ -899,27 +928,105 @@ impl Db {
Ok(())
}
/// One page of the persisted timeline, newest first.
pub fn photos_page(&self, offset: usize, limit: usize) -> Result<Vec<StoredPhoto>> {
/// One page of the persisted timeline, newest first. `kind`, when set,
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
pub fn photos_page(
&self,
offset: usize,
limit: usize,
kind: Option<crate::control::PhotoKind>,
range: Option<(i64, i64)>,
) -> Result<Vec<StoredPhoto>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT uid, capture_time, name, ratio, thumb_state FROM photos
ORDER BY seq LIMIT ?1 OFFSET ?2",
)?;
let rows = stmt.query_map(params![limit as i64, offset as i64], |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
})
})?;
let mut photos = Vec::new();
for row in rows {
photos.push(row?);
// Built up so any combination of the optional filters is one indexed
// query rather than a statement per case.
let mut sql =
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
let mut binds: Vec<i64> = Vec::new();
let mut conds: Vec<String> = Vec::new();
if let Some(k) = kind {
binds.push(k.as_i64());
conds.push(format!("kind = ?{}", binds.len()));
}
Ok(photos)
if let Some((from, to)) = range {
binds.push(from);
conds.push(format!("capture_time >= ?{}", binds.len()));
binds.push(to);
conds.push(format!("capture_time < ?{}", binds.len()));
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conds.join(" AND "));
}
binds.push(limit as i64);
let limit_pos = binds.len();
binds.push(offset as i64);
let offset_pos = binds.len();
sql.push_str(&format!(" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"));
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds), |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// The months the timeline spans, newest first, each with how many photos it
/// holds — the data behind the date scrubber. Buckets are local-time
/// `(year, month)` so they line up with the day headings the gallery draws
/// and with the boundaries a front-end computes when it jumps to one. `kind`
/// scopes the counts to one tab when set.
pub fn photos_months(
&self,
kind: Option<crate::control::PhotoKind>,
) -> Result<Vec<(i32, i32, usize)>> {
let conn = self.conn.lock().unwrap();
let (filter, binds): (&str, Vec<i64>) = match kind {
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
None => ("", Vec::new()),
};
let sql = format!(
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
CAST(strftime('%m', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS m, \
COUNT(*) \
FROM photos{filter} GROUP BY y, m ORDER BY y DESC, m DESC"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds), |r| {
Ok((r.get::<_, i32>(0)?, r.get::<_, i32>(1)?, r.get::<_, i64>(2)? as usize))
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
use crate::control::PhotoKind;
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
for row in rows {
let (kind, n) = row?;
match PhotoKind::from_i64(kind) {
PhotoKind::Photo => photos = n as usize,
PhotoKind::Video => videos = n as usize,
PhotoKind::Raw => raw = n as usize,
}
}
Ok((photos, videos, raw))
}
/// The stored photos for `uids`, in no particular order. Used by the thumbnail
@@ -932,7 +1039,7 @@ impl Db {
let conn = self.conn.lock().unwrap();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state FROM photos
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
WHERE uid IN ({placeholders})"
))?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
@@ -942,6 +1049,7 @@ impl Db {
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
})
})?;
let mut photos = Vec::new();
@@ -2001,6 +2109,17 @@ ALTER TABLE pending_op ADD COLUMN name TEXT;
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
";
/// Schema v13: photos record their media type and a derived `kind` so the
/// timeline can be split into Photos / Videos / Raw tabs. `media_type` is
/// nullable — it is filled once the daemon resolves a photo's node — while
/// `kind` (see [`crate::control::PhotoKind`]) defaults to `0` (still photo), the
/// classification anything unresolved falls back to. Existing rows are
/// reclassified for free on the next timeline refresh, so no backfill is needed.
const MIGRATION_V13: &str = "
ALTER TABLE photos ADD COLUMN media_type TEXT;
ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0;
";
#[cfg(test)]
mod tests {
use super::*;
@@ -2060,9 +2179,9 @@ mod tests {
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
("p1".into(), 300, None),
("p2".into(), 200, None),
("p3".into(), 100, None),
("p1".into(), 300, None, Some("video/mp4".into())),
("p2".into(), 200, None, None),
("p3".into(), 100, None, None),
])
.unwrap();
@@ -2071,14 +2190,15 @@ mod tests {
db.photo_set_thumb("p2", THUMB_NONE, None).unwrap();
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
// p1's media type arrives as `None` this time and must not be forgotten.
db.photos_replace(&[
("p0".into(), 400, Some("new.jpg".into())),
("p1".into(), 300, None),
("p2".into(), 200, None),
("p0".into(), 400, Some("new.jpg".into()), None),
("p1".into(), 300, None, None),
("p2".into(), 200, None, None),
])
.unwrap();
let page = db.photos_page(0, 10).unwrap();
let page = db.photos_page(0, 10, None, None).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"],
@@ -2092,27 +2212,59 @@ mod tests {
// A photo we know nothing about yet starts blank.
assert_eq!(page[0].ratio, None);
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
// Media type is learned-and-kept like the ratio: p1 keeps the video type
// it was first seen with even though the later refresh carried `None`, so
// it stays classified as a video.
assert_eq!(page[1].kind, crate::control::PhotoKind::Video);
assert_eq!(db.photos_count().unwrap(), 3);
// The counts break down by tab, and a filtered page returns only its tab.
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
let videos = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None)
.unwrap();
assert_eq!(
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1"]
);
let by_uid = db.photos_by_uid(&["p2".into()]).unwrap();
assert_eq!(by_uid.len(), 1);
assert_eq!(by_uid[0].capture_time, 200);
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
// not p0 at 400. Combined with a kind filter both conditions apply.
let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap();
assert_eq!(
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1", "p2"]
);
let ranged_video = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), Some((150, 350)))
.unwrap();
assert_eq!(
ranged_video.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1"]
);
// All three surviving photos sit in the same (1970-01) local month.
let months = db.photos_months(None).unwrap();
assert_eq!(months.len(), 1);
assert_eq!(months[0].2, 3);
}
#[test]
fn photos_page_slices_the_timeline_in_order() {
let db = Db::open_in_memory().unwrap();
let items: Vec<_> = (0..5)
.map(|i| (format!("p{i}"), 500 - i as i64, None))
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>))
.collect();
db.photos_replace(&items).unwrap();
let page = db.photos_page(2, 2).unwrap();
let page = db.photos_page(2, 2, None, None).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2", "p3"]
);
assert!(db.photos_page(9, 2).unwrap().is_empty());
assert!(db.photos_page(9, 2, None, None).unwrap().is_empty());
}
#[test]
+265 -15
View File
@@ -31,7 +31,7 @@
//! set, so a multi-GiB write never buffers in RAM and only the untouched
//! remainder of the file is pulled from the remote — lazily, at commit.
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::ffi::OsStr;
use std::fs::File;
use std::io::{BufRead, BufReader, Write as _};
@@ -53,7 +53,8 @@ use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem,
LocalHit, PhotoItem, PhotoThumb, PublicLinkInfo, RefreshScope, Request as CtlRequest,
LocalHit, PhotoItem, PhotoKind, PhotoMonth, PhotoThumb, PublicLinkInfo, RefreshScope,
Request as CtlRequest,
Response as CtlResponse, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
SyncPhase, SyncProgress, TransferDirection,
};
@@ -104,6 +105,12 @@ const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
/// in the DB and every page is sliced from there; a stale one is still served
/// immediately and refreshed in the background.
const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60);
/// How many photo nodes are resolved per [`ProtonPhotosClient::enumerate_nodes`]
/// call when enriching a refreshed timeline with names and media types (for the
/// Photos / Videos / Raw split). Batched so a large library is a handful of
/// round-trips rather than one request per photo, and bounded so a single call
/// never asks the server to decrypt the whole library at once.
const TIMELINE_ENRICH_CHUNK: usize = 200;
/// The same, for the persisted trash listing. Shorter, because the trash is the
/// one listing a user changes and then immediately looks at — though our own
/// mutations also invalidate it outright, so this only covers other clients.
@@ -127,6 +134,27 @@ const THUMB_QUALITY: u8 = 82;
/// saturate the link and starve the rest of the daemon.
const THUMB_GEN_CONCURRENCY: usize = 4;
/// A read of an unpinned video at least this large streams *without* persisting
/// its blocks to the on-disk cache. Playing a 2 GB film would otherwise pour it
/// through the block LRU and evict everything else the user actually wants kept —
/// and it re-streams cheaply enough on a rewatch that keeping it was never worth
/// that. Pinned videos (kept offline on purpose) and anything smaller cache as
/// usual.
const STREAM_BYPASS_MIN: u64 = 256 * 1024 * 1024;
/// How many bytes of streamed blocks the in-memory ring keeps. Bypassing the
/// on-disk cache must not mean re-fetching: the kernel asks for a streamed file
/// in reads far smaller than [`BLOCK_SIZE`], so without a ring every 128 KiB the
/// player consumes would download and decrypt a whole 4 MiB block again. Sized
/// for a handful of blocks per concurrently-streamed file.
const STREAM_RING_BYTES: u64 = 128 * 1024 * 1024;
/// Blocks fetched *past* the one a sequential streaming read needed, warmed into
/// the ring in the background so the player's next read is already in memory
/// instead of waiting a round-trip. Kept small so it cannot crowd out the
/// in-flight block governor.
const STREAM_READAHEAD: u64 = 4;
/// How stale the local-file index may get before the background scanner rebuilds
/// it. A rescan is a full walk of `$HOME`, so this trades index freshness against
/// disk churn; the scanner also always rebuilds once per daemon start when the
@@ -645,6 +673,72 @@ static NEXT_OPEN_ID: AtomicU64 = AtomicU64::new(0);
///
/// Cheaply cloneable (every field is a handle/`Arc`), so the control-socket task
/// gets its own copy while the FUSE session keeps another.
/// In-memory LRU of blocks belonging to a file streaming past the on-disk cache
/// (see [`STREAM_BYPASS_MIN`]). Bypassing the disk stops one film evicting the
/// block LRU; it must not also mean the same 4 MiB block is downloaded once per
/// 128 KiB the player reads out of it.
///
/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node
/// whose tag no longer matches has its blocks dropped rather than served, so a
/// new revision can never be stitched together from the old one.
#[derive(Default)]
struct StreamRing {
blocks: HashMap<(NodeUid, u64), Arc<Vec<u8>>>,
/// Keys oldest-first, for eviction.
order: VecDeque<(NodeUid, u64)>,
/// Per-node validity tag, `(mtime, size)`.
tags: HashMap<NodeUid, (i64, u64)>,
bytes: u64,
}
impl StreamRing {
fn get(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Arc<Vec<u8>>> {
if self.tags.get(uid) != Some(&(mtime, size)) {
return None;
}
self.blocks.get(&(uid.clone(), idx)).cloned()
}
fn insert(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64, bytes: Arc<Vec<u8>>) {
if self.tags.get(uid) != Some(&(mtime, size)) {
// Revision changed under us (or first sight): anything held for this
// node describes the old one.
self.drop_node(uid);
self.tags.insert(uid.clone(), (mtime, size));
}
let key = (uid.clone(), idx);
if self.blocks.contains_key(&key) {
return;
}
self.bytes += bytes.len() as u64;
self.blocks.insert(key.clone(), bytes);
self.order.push_back(key);
while self.bytes > STREAM_RING_BYTES {
let Some(victim) = self.order.pop_front() else {
break;
};
if let Some(dropped) = self.blocks.remove(&victim) {
self.bytes -= dropped.len() as u64;
}
}
}
fn drop_node(&mut self, uid: &NodeUid) {
self.order.retain(|(u, _)| u != uid);
let mut freed = 0u64;
self.blocks.retain(|(u, _), bytes| {
if u == uid {
freed += bytes.len() as u64;
false
} else {
true
}
});
self.bytes -= freed;
self.tags.remove(uid);
}
}
#[derive(Clone)]
struct Core {
client: ProtonDriveClient,
@@ -656,6 +750,10 @@ struct Core {
/// Validated by `(mtime, size)` exactly like the content cache, and bounded
/// by [`MAX_OPEN_READERS`].
readers: Arc<Mutex<HashMap<NodeUid, ReaderSlot>>>,
/// Blocks of files streaming past the on-disk cache, held in memory so a
/// player's small sequential reads are served from the last few 4 MiB blocks
/// instead of re-downloading each one. See [`StreamRing`].
stream_ring: Arc<Mutex<StreamRing>>,
/// Threads that serve the FUSE handlers which touch the network, so the
/// session's dispatch loop stays free to answer cheap metadata calls while a
/// cold read is on the wire. See [`Workers`].
@@ -1258,6 +1356,7 @@ impl Core {
fsize: u64,
offset: u64,
len: u64,
cache_blocks: bool,
) -> Result<Vec<u8>, Errno> {
// A queued write has not reached the remote yet, so the remote's current
// revision is stale and the staged blob is the truth. Serve from it until
@@ -1271,7 +1370,7 @@ impl Core {
if is_local_uid(uid) {
return Ok(Vec::new());
}
self.read_range_remote(uid, mtime, fsize, offset, len)
self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks)
}
/// Serve a read from a staged blob, falling back to the remote base for any
@@ -1319,6 +1418,7 @@ impl Core {
m.base_size,
s,
bend - s,
true,
)?);
}
// Past the base: a hole the write extended over.
@@ -1327,6 +1427,59 @@ impl Core {
Ok(out)
}
/// Warm the [`STREAM_READAHEAD`] blocks after `last` into the ring, in the
/// background, so a player reading forward finds its next block already in
/// memory rather than paying a fetch + decrypt round-trip at each boundary.
///
/// Fire-and-forget: a failure here is not an error, it only means the read
/// that needs those bytes will fetch them itself.
fn stream_readahead(&self, uid: &NodeUid, mtime: i64, fsize: u64, last: u64) {
let last_block = (fsize.saturating_sub(1)) / BLOCK_SIZE;
let wanted: Vec<u64> = ((last + 1)..=(last + STREAM_READAHEAD).min(last_block))
.filter(|&bidx| {
self.stream_ring
.lock()
.unwrap()
.get(uid, mtime, fsize, bidx)
.is_none()
})
.collect();
if wanted.is_empty() {
return;
}
let core = self.clone();
let uid = uid.clone();
self.rt.spawn(async move {
let Ok(reader) = core.revision_reader(&uid, mtime, fsize).await else {
return;
};
// Concurrently, for the same reason the demand path fetches its
// misses concurrently: serially awaited blocks cost the sum of their
// round-trips, and the player catches up with the readahead before it
// finishes. The SDK's in-flight block governor bounds the fan-out.
let mut set = tokio::task::JoinSet::new();
for bidx in wanted {
let reader = reader.clone();
let bstart = bidx * BLOCK_SIZE;
let blen = BLOCK_SIZE.min(fsize - bstart);
set.spawn(async move { (bidx, reader.read_at(bstart, blen).await) });
}
while let Some(joined) = set.join_next().await {
let Ok((bidx, read)) = joined else { continue };
match read {
Ok(bytes) => core.stream_ring.lock().unwrap().insert(
&uid,
mtime,
fsize,
bidx,
Arc::new(bytes),
),
Err(e) => debug!(%uid, bidx, error = %e, "stream readahead failed"),
}
}
});
}
/// Read from the content cache, else the remote. The base-content path, with
/// no awareness of queued writes — callers wanting the file's *current*
/// content want [`Core::read_range`]. Gap-filling a staged blob is the one
@@ -1339,6 +1492,7 @@ impl Core {
fsize: u64,
offset: u64,
len: u64,
cache_blocks: bool,
) -> Result<Vec<u8>, Errno> {
if let Some(bytes) = self.cache.read_range(uid, mtime, fsize, offset, len) {
return Ok(bytes);
@@ -1357,10 +1511,22 @@ impl Core {
// would otherwise stall on each block round-trip in turn; downloading the
// misses in parallel saturates the connection and bounds latency at the
// slowest single block instead of their sum.
let mut blocks: Vec<Option<Vec<u8>>> = Vec::with_capacity((last - first + 1) as usize);
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity((last - first + 1) as usize);
let mut misses: Vec<u64> = Vec::new();
for bidx in first..=last {
match self.cache.cached_block(uid, mtime, fsize, bidx) {
// A streaming read keeps its blocks only in memory, so the ring is
// the only cache it has — check it before the disk (which will not
// hold them) and before the network (which would refetch the same
// 4 MiB block for every 128 KiB the player consumes).
let hit = if cache_blocks {
self.cache.cached_block(uid, mtime, fsize, bidx).map(Arc::new)
} else {
self.stream_ring
.lock()
.unwrap()
.get(uid, mtime, fsize, bidx)
};
match hit {
Some(b) => blocks.push(Some(b)),
None => {
blocks.push(None);
@@ -1401,9 +1567,23 @@ impl Core {
Ok::<_, Errno>(out)
})?;
for (bidx, bytes) in fetched {
let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes);
// A streaming read (large unpinned video) skips the on-disk cache
// so it can't evict the rest of it; it keeps the block in the
// in-memory ring instead, which is bounded and dies with the read.
let bytes = Arc::new(bytes);
if cache_blocks {
let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes);
} else {
self.stream_ring
.lock()
.unwrap()
.insert(uid, mtime, fsize, bidx, bytes.clone());
}
blocks[(bidx - first) as usize] = Some(bytes);
}
if !cache_blocks {
self.stream_readahead(uid, mtime, fsize, last);
}
}
for (i, block) in blocks.into_iter().enumerate() {
@@ -1457,6 +1637,7 @@ impl Core {
base_size,
s,
bend - s,
true,
)?);
}
// Anything past the base is a hole: zero-fill.
@@ -1496,7 +1677,7 @@ impl Core {
if s >= bend {
continue; // wholly past the base: already zero-filled on disk
}
let bytes = self.read_range_remote(uid, base_mtime, base_size, s, bend - s)?;
let bytes = self.read_range_remote(uid, base_mtime, base_size, s, bend - s, true)?;
file.write_all_at(&bytes, s).map_err(|err| {
error!(%uid, error = %err, "scratch gap-fill write failed");
Errno::EIO
@@ -2957,6 +3138,8 @@ impl Core {
&self,
offset: usize,
limit: usize,
kind: Option<PhotoKind>,
range: Option<(i64, i64)>,
) -> Result<Option<Vec<PhotoItem>>, String> {
let count = self.db.photos_count().map_err(|e| e.to_string())?;
if count == 0 {
@@ -2976,7 +3159,7 @@ impl Core {
let page = self
.db
.photos_page(offset, limit)
.photos_page(offset, limit, kind, range)
.map_err(|e| e.to_string())?;
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
}
@@ -2998,6 +3181,7 @@ impl Core {
name: photo.name,
ratio: photo.ratio,
no_thumb: photo.thumb_state == db::THUMB_NONE,
kind: photo.kind,
}
}
@@ -3222,9 +3406,37 @@ impl Core {
.enumerate_timeline()
.await
.map_err(|e| format!("timeline: {e}"))?;
let rows: Vec<(String, i64, Option<String>)> = items
// The timeline DTO carries only a uid and capture time, but the Photos
// page has to split into Photos / Videos / Raw — which needs each photo's
// name and media type. Resolve those in batches off the request path.
// Best-effort: a photo whose node we fail to resolve keeps whatever was
// learned before (or classifies from nothing, i.e. a still photo), so a
// partial resolve never blanks the timeline.
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
match photos.enumerate_nodes(chunk).await {
Ok(nodes) => {
for node in nodes {
let media_type = match &node.kind {
NodeKind::File { media_type, .. } => Some(media_type.clone()),
NodeKind::Folder => None,
};
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
}
}
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
}
}
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
.iter()
.map(|it| (it.uid.to_string(), it.capture_time, None))
.map(|it| {
let key = it.uid.to_string();
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
(key, it.capture_time, name, media_type)
})
.collect();
self.db.photos_replace(&rows).map_err(|e| e.to_string())?;
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
@@ -5718,11 +5930,22 @@ impl Filesystem for ProtonFs {
}
return;
}
let (uid, mtime, fsize) = {
let (uid, mtime, fsize, is_video) = {
let st = self.core.state.lock().unwrap();
match st.entries.get(&ino.0) {
Some(e) if e.node.is_file() => {
(e.uid.clone(), e.node.modification_time, node_size(&e.node))
let media_type = match &e.node.kind {
NodeKind::File { media_type, .. } => Some(media_type.as_str()),
NodeKind::Folder => None,
};
let is_video =
PhotoKind::classify(Some(&e.node.name), media_type) == PhotoKind::Video;
(
e.uid.clone(),
e.node.modification_time,
node_size(&e.node),
is_video,
)
}
Some(_) => {
reply.error(Errno::EISDIR);
@@ -5734,6 +5957,10 @@ impl Filesystem for ProtonFs {
}
}
};
// A large unpinned video streams without polluting the block cache (see
// [`STREAM_BYPASS_MIN`]); everything else caches its blocks as usual.
let cache_blocks =
!(is_video && fsize >= STREAM_BYPASS_MIN && !self.core.cache.is_pinned(&uid));
// Off the dispatch loop: this is the one handler that routinely goes to
// the network (block fetch + decrypt, hundreds of ms on a cold file),
// and until it returns the kernel's next request for this mount is not
@@ -5741,7 +5968,7 @@ impl Filesystem for ProtonFs {
// races with nothing. FUSE does not require replies in request order.
let core = self.core.clone();
self.core.workers.run(move || {
match core.read_range(&uid, mtime, fsize, offset, size as u64) {
match core.read_range(&uid, mtime, fsize, offset, size as u64, cache_blocks) {
Ok(bytes) => reply.data(&bytes),
Err(e) => reply.error(e),
}
@@ -6710,19 +6937,41 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::PhotosTimeline { offset, limit }) => {
match core.photos_timeline(offset, limit) {
Ok(CtlRequest::PhotosTimeline {
offset,
limit,
kind,
range,
}) => {
match core.photos_timeline(offset, limit, kind, range) {
Ok(Some(items)) => CtlResponse::Photos {
available: true,
items,
counts: core.db.photos_counts().ok(),
},
Ok(None) => CtlResponse::Photos {
available: false,
items: Vec::new(),
counts: None,
},
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
Ok(months) => CtlResponse::PhotoMonths {
months: months
.into_iter()
.map(|(year, month, count)| PhotoMonth {
year,
month,
count,
})
.collect(),
},
Err(e) => CtlResponse::Error {
message: e.to_string(),
},
},
Ok(CtlRequest::PhotoThumbs { uids }) => {
let parsed: Vec<NodeUid> = uids.iter().filter_map(|u| parse_uid(u)).collect();
CtlResponse::Thumbs {
@@ -7406,6 +7655,7 @@ pub fn mount(
})),
cache: Arc::new(cache),
readers: Arc::new(Mutex::new(HashMap::new())),
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
db,
online: Arc::new(AtomicBool::new(online)),
+405 -40
View File
@@ -34,7 +34,7 @@ use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem,
PhotoItem, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry,
PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry,
ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection,
TransferItem, pending_summary, send,
};
@@ -276,6 +276,23 @@ struct Ui {
gallery_upload: gtk4::Button,
/// "1,204 photos" under the page title.
gallery_subtitle: gtk4::Label,
/// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None`
/// for All. Read by [`load_gallery`] and set by the filter toggles.
gallery_kind: Cell<Option<PhotoKind>>,
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
/// labels can carry live per-kind counts.
gallery_tabs: [gtk4::ToggleButton; 4],
/// The date-jump dropdown ("All dates" then a month per timeline entry), and
/// the `[from, to)` window each of its rows selects (index-aligned; `None` is
/// "All dates"). Selecting a row loads that month via [`load_gallery`].
gallery_dates: gtk4::DropDown,
gallery_date_ranges: RefCell<Vec<Option<(i64, i64)>>>,
/// The capture-time window the timeline is currently filtered to, or `None`
/// for the whole span. Read by [`load_gallery`], set by the date dropdown.
gallery_range: Cell<Option<(i64, i64)>>,
/// Set while the date dropdown is being repopulated, so resetting its model
/// doesn't fire the selection handler and kick off a spurious reload.
gallery_date_suppress: Cell<bool>,
/// True while a timeline page is in flight, so the scroll-to-the-end paging
/// can't fire a second request for the page already coming.
gallery_loading: Cell<bool>,
@@ -543,6 +560,8 @@ fn load_proton_theme() {
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
@@ -693,6 +712,12 @@ fn build_window(app: &adw::Application) {
gallery_more: gallery_widgets.more.clone(),
gallery_upload: gallery_widgets.upload.clone(),
gallery_subtitle: gallery_widgets.subtitle.clone(),
gallery_kind: Cell::new(None),
gallery_tabs: gallery_widgets.tabs.clone(),
gallery_dates: gallery_widgets.dates.clone(),
gallery_date_ranges: RefCell::new(vec![None]),
gallery_range: Cell::new(None),
gallery_date_suppress: Cell::new(false),
gallery_loading: Cell::new(false),
gallery_width: Cell::new(0),
photo_tex: RefCell::new(HashMap::new()),
@@ -2795,13 +2820,33 @@ fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: &gtk4::Box, x: f64,
popover.set_parent(anchor);
popover.connect_closed(|p| p.unparent());
// Videos lead with Play (stream from the mount, no download); the plain Open
// below still downloads a local copy for anyone who wants one.
if is_video_entry(entry) {
let play = menu_item("Play (stream)", "media-playback-start-symbolic");
let ui_play = ui.clone();
let entry_play = entry.clone();
let pop = popover.clone();
play.connect_clicked(move |_| {
pop.popdown();
stream_entry(&ui_play, &entry_play);
});
menu.append(&play);
}
let open = menu_item("Open", "document-open-symbolic");
let ui_open = ui.clone();
let entry_open = entry.clone();
let pop = popover.clone();
open.connect_clicked(move |_| {
pop.popdown();
activate_entry(&ui_open, &entry_open);
// Open always means "download a local copy and hand off", even for a
// video — `activate_entry` would otherwise stream it.
if is_video_entry(&entry_open) {
download_and_open(&ui_open, &entry_open);
} else {
activate_entry(&ui_open, &entry_open);
}
});
menu.append(&open);
@@ -2892,7 +2937,33 @@ fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Option<DirEnt
Some(entry)
}
/// Open an entry the Nautilus way: folders descend, files download-and-open.
/// Whether an entry's name marks it as a video, reusing the same classifier the
/// Photos page splits on — so the file browser and the gallery agree on what a
/// video is.
fn is_video_entry(entry: &DirEntry) -> bool {
!entry.is_dir && PhotoKind::classify(Some(&entry.name), None) == PhotoKind::Video
}
/// Stream a video straight from the mount, no download. Drive folders *are* part
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
/// seeks and buffers — instead of waiting for the whole file to land like
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
/// `.mkv` starts playing in seconds.
fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let abs = mountpoint.join(&rel);
let Some(path) = abs.to_str() else {
toast_error(&ui, "Couldn't play video", "The file path isn't valid UTF-8.");
return;
};
toast(ui, &format!("Streaming “{}”…", entry.name));
play_external(path);
}
/// Open an entry the Nautilus way: folders descend, videos stream from the mount,
/// other files download-and-open.
fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
if entry.is_dir {
@@ -2903,37 +2974,50 @@ fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
}
*ui.browser_path.borrow_mut() = rel;
load_browser(ui);
} else if is_video_entry(entry) {
// A video streams rather than downloads: that is exactly the "play it,
// don't fetch the whole thing" behaviour this is for.
stream_entry(ui, entry);
} else {
// Ignore a repeat activation of a file already downloading, so an
// impatient double-click doesn't kick off a second round-trip.
if !ui.opening.borrow_mut().insert(rel.clone()) {
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenFile { path: rel.clone() },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.opening.borrow_mut().remove(&rel);
match result {
Ok(Ok(Response::FilePath { path })) => open_path(&path),
Ok(Ok(Response::Error { message })) => {
toast_error(&ui, "Couldn't open file", &message)
}
_ => toast_error(
&ui,
"Couldn't open file",
"The mount service didn't respond.",
),
}
});
download_and_open(ui, entry);
}
}
/// Download a file's full content into the cache and hand it to the user's
/// default application. The download-and-open path behind both a plain
/// double-click and the context menu's "Open" (including for a video, when the
/// user explicitly wants a local copy rather than to stream it).
fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
// Ignore a repeat activation of a file already downloading, so an impatient
// double-click doesn't kick off a second round-trip.
if !ui.opening.borrow_mut().insert(rel.clone()) {
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenFile { path: rel.clone() },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.opening.borrow_mut().remove(&rel);
match result {
Ok(Ok(Response::FilePath { path })) => open_path(&path),
Ok(Ok(Response::Error { message })) => {
toast_error(&ui, "Couldn't open file", &message)
}
_ => toast_error(
&ui,
"Couldn't open file",
"The mount service didn't respond.",
),
}
});
}
/// Pin or unpin an entry through the daemon, then reload to reflect the new
/// state.
fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
@@ -6336,6 +6420,11 @@ struct GalleryWidgets {
retry: gtk4::Button,
upload: gtk4::Button,
refresh: gtk4::Button,
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
/// to [`kind_for_tab`]).
tabs: [gtk4::ToggleButton; 4],
/// The date-jump dropdown, populated with the timeline's months.
dates: gtk4::DropDown,
}
/// The Photos page: a [`gtk4::ListView`] of day sections, each a heading over
@@ -6425,6 +6514,43 @@ fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
header_box.append(&refresh);
header_box.append(&upload);
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
// control: exactly one is active, and flipping it reloads the timeline
// filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts
// once a page lands.
let tab_labels = ["All", "Photos", "Videos", "Raw"];
let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| {
gtk4::ToggleButton::builder()
.label(tab_labels[i])
.active(i == 0)
.build()
});
// Group the toggles so they behave as a radio set: chaining each to the first
// is what GTK turns into mutual exclusion.
for btn in &tabs[1..] {
btn.set_group(Some(&tabs[0]));
}
let tab_group = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
tab_group.add_css_class("linked");
for btn in &tabs {
btn.add_css_class("pill");
tab_group.append(btn);
}
// Date jump: "All dates" plus a row per month, filled in once the timeline's
// months are known (see [`refresh_photo_months`]). Pushed to the far end of
// the filter row, opposite the kind toggles.
let dates = gtk4::DropDown::from_strings(&["All dates"]);
dates.add_css_class("pill");
dates.set_tooltip_text(Some("Jump to a month"));
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
filter_bar.append(&tab_group);
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
filter_bar.append(&spacer);
filter_bar.append(&dates);
// The timeline (plus its pager) or the status page, never both.
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
timeline.append(&scroll);
@@ -6442,6 +6568,7 @@ fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header_box);
inner.append(&filter_bar);
inner.append(&content);
(
@@ -6458,10 +6585,90 @@ fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
retry,
upload,
refresh,
tabs,
dates,
},
)
}
/// The [`PhotoKind`] filter a gallery tab index selects: index 0 is All (no
/// filter), then Photos / Videos / Raw. Index-aligned with the toggle array.
fn kind_for_tab(index: usize) -> Option<PhotoKind> {
match index {
1 => Some(PhotoKind::Photo),
2 => Some(PhotoKind::Video),
3 => Some(PhotoKind::Raw),
_ => None,
}
}
/// The `[from, to)` epoch-second window of a local calendar month, or `None` if
/// the date is somehow unrepresentable. Computed with glib so month rollover and
/// the local timezone (matching the daemon's month buckets) are handled for us.
fn month_range(year: i32, month: i32) -> Option<(i64, i64)> {
let start = glib::DateTime::from_local(year, month, 1, 0, 0, 0.0).ok()?;
let end = start.add_months(1).ok()?;
Some((start.to_unix(), end.to_unix()))
}
/// English month names, indexed 1..=12.
const MONTH_NAMES: [&str; 12] = [
"January", "February", "March", "April", "May", "June", "July", "August", "September",
"October", "November", "December",
];
/// Rebuild the date-jump dropdown for the active kind: ask the daemon which
/// months the timeline spans and turn them into "Month YYYY (count)" rows, each
/// remembering the window it jumps to. Resets the selection to "All dates" — the
/// caller pairs this with a fresh timeline load. Off the UI thread; a failure
/// just leaves the dropdown as it was.
fn refresh_photo_months(ui: &Rc<Ui>) {
let rx = spawn_request(
ui.dirs.control_socket(),
Request::PhotoMonths {
kind: ui.gallery_kind.get(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let Ok(Ok(Response::PhotoMonths { months })) = rx.recv().await else {
return;
};
let mut labels = vec!["All dates".to_string()];
let mut ranges: Vec<Option<(i64, i64)>> = vec![None];
for m in months {
let name = MONTH_NAMES.get((m.month - 1) as usize).copied().unwrap_or("?");
labels.push(format!("{name} {} ({})", m.year, m.count));
ranges.push(month_range(m.year, m.month));
}
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
// Repopulating resets the selection to 0, which would otherwise fire the
// handler and reload; suppress that — the caller is already reloading.
ui.gallery_date_suppress.set(true);
ui.gallery_dates
.set_model(Some(&gtk4::StringList::new(&label_refs)));
ui.gallery_dates.set_selected(0);
*ui.gallery_date_ranges.borrow_mut() = ranges;
ui.gallery_date_suppress.set(false);
});
}
/// Label the filter toggles with the whole-timeline `(photos, videos, raw)`
/// counts, so a glance shows how much sits behind each tab. A tab with nothing
/// behind it is disabled — you can't filter to an empty set — but the currently
/// selected one stays clickable so you can always switch back off it.
fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
let (photos, videos, raw) = counts;
let totals = [photos + videos + raw, photos, videos, raw];
for (index, tab) in ui.gallery_tabs.iter().enumerate() {
let name = ["All", "Photos", "Videos", "Raw"][index];
let n = totals[index];
tab.set_label(&format!("{name} {n}"));
tab.set_sensitive(n > 0 || tab.is_active());
}
}
/// Wire the gallery: install the section factory, the zoom gestures, the pager
/// and the upload button. Activating a thumbnail downloads the photo and opens it
/// in the in-app lightbox.
@@ -6582,6 +6789,48 @@ fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::ScrolledWindo
load_gallery(&ui_more, true);
});
// Filter toggles: flipping to a tab reloads the timeline filtered to that
// kind. Only the button being switched *on* acts — the group also fires a
// `toggled` for the one switching off, which this skips — and a redundant
// toggle to the already-current kind is a no-op.
for (index, tab) in ui.gallery_tabs.iter().enumerate() {
let ui_tab = ui.clone();
tab.connect_toggled(move |btn| {
if !btn.is_active() {
return;
}
let kind = kind_for_tab(index);
if ui_tab.gallery_kind.get() == kind {
return;
}
ui_tab.gallery_kind.set(kind);
// A different kind has a different set of months; clearing the active
// window makes the reload rebuild the date jump for the new kind.
ui_tab.gallery_range.set(None);
load_gallery(&ui_tab, false);
});
}
// Date jump: selecting a month loads that window; "All dates" (row 0) clears
// it. Skipped while the model is being repopulated (see `gallery_date_suppress`).
let ui_dates = ui.clone();
ui.gallery_dates.connect_selected_notify(move |dd| {
if ui_dates.gallery_date_suppress.get() {
return;
}
let range = ui_dates
.gallery_date_ranges
.borrow()
.get(dd.selected() as usize)
.copied()
.flatten();
if ui_dates.gallery_range.get() == range {
return;
}
ui_dates.gallery_range.set(range);
load_gallery(&ui_dates, false);
});
let ui_upload = ui.clone();
ui.gallery_upload.connect_clicked(move |_| {
let dialog = gtk4::FileDialog::builder()
@@ -6845,6 +7094,20 @@ fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
overlay.add_overlay(&picture);
overlay.add_overlay(&caption);
// A video reads as a video at a glance: a play glyph centred over the poster
// thumbnail. Kept above the caption scrim so it stays legible on hover.
let is_video = tile.photo.kind == PhotoKind::Video;
if is_video {
let badge = gtk4::Image::builder()
.icon_name("media-playback-start-symbolic")
.pixel_size(28)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.build();
badge.add_css_class("photo-video-badge");
overlay.add_overlay(&badge);
}
let button = gtk4::Button::builder()
.child(&overlay)
.width_request(tile.width)
@@ -6858,9 +7121,17 @@ fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
want_thumb(ui, &tile.photo, &picture);
// A still opens in the in-app lightbox; a video can't render there, so it
// downloads and hands off to an external player instead.
let ui_open = ui.clone();
let uid = tile.photo.uid.clone();
button.connect_clicked(move |_| open_photo_viewer(&ui_open, uid.clone()));
button.connect_clicked(move |_| {
if is_video {
play_video(&ui_open, uid.clone());
} else {
open_photo_viewer(&ui_open, uid.clone());
}
});
button
}
@@ -7142,9 +7413,15 @@ fn repaint_gallery(ui: &Rc<Ui>) {
let count = ui.gallery_model.n_items();
ui.gallery_subtitle.set_visible(count > 0);
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
let (one, many) = match ui.gallery_kind.get() {
Some(PhotoKind::Video) => ("video", "videos"),
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
_ => ("photo", "photos"),
};
ui.gallery_subtitle.set_label(&match count {
1 => "1 photo".to_string(),
n => format!("{n} photos"),
1 => format!("1 {one}"),
n => format!("{n} {many}"),
});
}
@@ -7341,6 +7618,11 @@ fn prefetch_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
else {
return;
};
// Never prefetch a video: the lightbox skips over it, and warming it would
// mean downloading a whole clip the user won't watch from here.
if photo.kind == PhotoKind::Video {
return;
}
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenPhoto { uid: photo.uid },
@@ -7529,15 +7811,41 @@ fn navigate_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
}
let uid_val = viewer.uid.borrow().clone();
let current_idx = find_photo_index(model, &uid_val).unwrap_or(0);
let next_idx = (current_idx as i32 + delta).clamp(0, n as i32 - 1) as u32;
let Some(obj) = model.item(next_idx) else {
// Where the raw step lands (prev/next are ±1; Home/End are huge deltas that
// clamp to the ends), and which way it was travelling.
let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1);
let dir = if delta < 0 { -1 } else { 1 };
// The lightbox renders stills only — a video plays in an external player, so
// it has no frame here. Land on the nearest renderable item, preferring the
// travel direction and falling back to scanning inward from a boundary (so
// Home/End still reach the first/last still even when videos sit at the end).
let renderable = |idx: u32| -> Option<PhotoItem> {
model
.item(idx)
.and_then(|obj| obj.downcast_ref::<BoxedAnyObject>().map(|b| b.borrow::<PhotoItem>().clone()))
.filter(|photo| photo.kind != PhotoKind::Video)
};
let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> {
let mut probe = from;
while (0..n as i32).contains(&probe) {
if let Some(photo) = renderable(probe as u32) {
return Some((probe as u32, photo));
}
probe += step;
}
None
};
// Scan the travel direction first. Only fall back to scanning inward when the
// step landed on a boundary — that is the Home/End case (jump to first/last,
// then step inward past any videos there); a mid-list prev/next with only
// videos ahead should simply stay put rather than reverse.
let at_boundary = start == 0 || start == n as i32 - 1;
let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten());
let Some((next_idx, photo)) = landing else {
return;
};
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else {
return;
};
let photo = boxed.borrow::<PhotoItem>().clone();
*viewer.uid.borrow_mut() = photo.uid.clone();
show_photo_position(viewer, next_idx, n, photo.capture_time);
@@ -7903,6 +8211,12 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
"Reading your Proton Drive timeline.",
false,
);
// Rebuild the date jump for the current kind, but only for a full-span
// load — a jump *to* a month sets a range and reloads, and refreshing the
// dropdown then would fight the selection the user just made.
if ui.gallery_range.get().is_none() {
refresh_photo_months(ui);
}
}
let offset = ui.gallery_model.n_items() as usize;
ui.gallery_loading.set(true);
@@ -7914,6 +8228,8 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
Request::PhotosTimeline {
offset,
limit: PHOTOS_PAGE,
kind: ui.gallery_kind.get(),
range: ui.gallery_range.get(),
},
);
let ui = ui.clone();
@@ -7923,7 +8239,11 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
ui.gallery_loading.set(false);
ui.gallery_more.set_sensitive(true);
match result {
Ok(Ok(Response::Photos { available, items })) => {
Ok(Ok(Response::Photos {
available,
items,
counts,
})) => {
if !available {
gallery_status(
&ui,
@@ -7934,6 +8254,10 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
);
return;
}
// Label the filter tabs with live per-kind counts.
if let Some(counts) = counts {
update_gallery_tabs(&ui, counts);
}
// Take the daemon's word on ratios and thumbnail verdicts before
// anything is laid out: it persists both, so the very first frame
// justifies its rows correctly instead of guessing RATIO_UNKNOWN
@@ -8043,6 +8367,47 @@ fn open_path(path: &str) {
}
}
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
/// user's default handler when mpv isn't installed.
fn play_external(path: &str) {
if Command::new("mpv").arg(path).spawn().is_ok() {
return;
}
open_path(path);
}
/// Download a Photos-library video, then hand it to an external player. Unlike a
/// still photo — which the in-app lightbox can render — a video needs a real
/// player, and the photos volume isn't part of the FUSE mount, so there is no
/// path to stream it from: [`Request::OpenPhoto`] fetches the whole file into the
/// cache (served straight from there on a repeat) and we launch the player on it.
///
/// For large videos kept in an on-demand *drive* folder, streaming through the
/// mount is the better route — that is the file-browser "Play" action, not this.
fn play_video(ui: &Rc<Ui>, uid: String) {
toast(ui, "Preparing video…");
let rx = spawn_request(ui.dirs.control_socket(), Request::OpenPhoto { uid });
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::FilePath { path })) => play_external(&path),
Ok(Ok(Response::Error { message })) => {
toast_error(&ui, "Couldn't open this video", &message)
}
Ok(Ok(_)) => toast_error(
&ui,
"Couldn't open this video",
"Unexpected reply from the mount service.",
),
Ok(Err(_)) | Err(_) => {
toast_error(&ui, "Couldn't open this video", "Couldn't reach Proton Drive.")
}
}
});
}
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
+32 -5
View File
@@ -224,6 +224,13 @@ struct Ui {
/// further input is ignored.
opening: Cell<bool>,
indexing: Cell<bool>,
/// Set when Enter arrives before the in-flight query has rendered (typing
/// then hitting Enter faster than the search debounce). The open is honoured
/// against the selected row once the fresh results settle, rather than being
/// dropped — so a plain "type, Enter" always opens something.
open_pending: Cell<bool>,
/// The window, so a deferred open (above) can reach it from `render`.
window: RefCell<Option<adw::ApplicationWindow>>,
}
/// A titled group of rows: header (hidden when empty) plus its list.
@@ -438,7 +445,10 @@ fn build_window(app: &adw::Application) {
pending: Cell::new(0),
opening: Cell::new(false),
indexing: Cell::new(false),
open_pending: Cell::new(false),
window: RefCell::new(None),
});
*ui.window.borrow_mut() = Some(window.clone());
for (filter, label) in FILTERS {
let chip = gtk4::ToggleButton::builder()
@@ -506,9 +516,9 @@ fn build_window(app: &adw::Application) {
glib::Propagation::Stop
}
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter => {
if let Some(index) = ui_key.cursor.get() {
ui_key.open(index, &window_key);
}
// Fall back to the top row when nothing is explicitly selected
// (e.g. results haven't rendered yet); `open` guards the rest.
ui_key.open(ui_key.cursor.get().unwrap_or(0), &window_key);
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
@@ -521,6 +531,9 @@ fn build_window(app: &adw::Application) {
let debounce: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
let ui_changed = ui.clone();
entry.connect_changed(move |entry| {
// A fresh keystroke supersedes any Enter that was waiting on results:
// the user is still refining the query, not asking to open yet.
ui_changed.open_pending.set(false);
if let Some(source) = debounce.borrow_mut().take() {
source.remove();
}
@@ -821,6 +834,18 @@ impl Ui {
}
self.hint.set_label(&self.status_text(total, searching));
// Honour an Enter that arrived before this query rendered, now that the
// results (and the cursor) are settled. Only once both searches are in,
// so it lands on the final list, not a half-populated one.
if self.open_pending.get() && self.pending.get() == 0 {
self.open_pending.set(false);
if let (Some(index), Some(window)) =
(self.cursor.get(), self.window.borrow().clone())
{
self.open(index, &window);
}
}
}
fn status_text(&self, total: usize, searching: bool) -> String {
@@ -906,9 +931,11 @@ impl Ui {
return;
}
// The rows on screen may belong to an older query whose reply is still
// settling. Refuse to act until the visible list matches what's typed,
// so Enter can never launch a file the user has already typed past.
// settling. Don't act on them — Enter must never launch a file the user
// has typed past. But don't drop the intent either: remember it and open
// the right row once the fresh results land (see `render`).
if self.entry.text().trim() != *self.rendered_query.borrow() {
self.open_pending.set(true);
return;
}
let Some(hit) = self.visible.borrow().get(index).cloned() else {