mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5109427c32 | ||
|
|
e2c4dac8df | ||
|
|
ebeb9cdbef | ||
|
|
82e96543ac | ||
|
|
96c77e96cb | ||
|
|
90e110e893 | ||
|
|
0b7236bd4a | ||
|
|
2fa5eaa75b | ||
|
|
dc9ea9f541 | ||
|
|
b13d9d9f38 |
No files matched your search
Generated
+4
-4
@@ -3449,7 +3449,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "2.1.1"
|
version = "2.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3464,7 +3464,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "2.1.1"
|
version = "2.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
@@ -3488,7 +3488,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "2.1.1"
|
version = "2.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"fuser",
|
"fuser",
|
||||||
@@ -3508,7 +3508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "2.1.1"
|
version = "2.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.1.1"
|
version = "2.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 31;
|
pub(super) const SCHEMA_VERSION: i64 = 32;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -270,6 +270,20 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V31)?;
|
tx.execute_batch(MIGRATION_V31)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 32 {
|
||||||
|
// Data only, so safe to repeat; each table is guarded for a very
|
||||||
|
// old fixture that never had it.
|
||||||
|
for table in ["photos", "album_photos"] {
|
||||||
|
let has_table: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_table {
|
||||||
|
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -952,3 +966,10 @@ ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
|||||||
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||||
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// V32: give videos written off as un-thumbnailable another chance.
|
||||||
|
///
|
||||||
|
/// Before local video thumbnails, the fallback fed a video's bytes to the image
|
||||||
|
/// decoder, which could only fail, and that failure was recorded as the
|
||||||
|
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
||||||
|
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
||||||
@@ -4235,6 +4235,48 @@ fn the_servers_photo_relation_groups_a_live_photo() {
|
|||||||
/// A database written before grouping existed opens, keeps its timeline, and
|
/// A database written before grouping existed opens, keeps its timeline, and
|
||||||
/// shows every photo — a row with no `group_key` is its own group, which is how
|
/// shows every photo — a row with no `group_key` is its own group, which is how
|
||||||
/// the gallery behaved before the column.
|
/// the gallery behaved before the column.
|
||||||
|
#[test]
|
||||||
|
fn migration_v32_retries_videos_written_off_as_unthumbnailable() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-v31-fixture-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
{
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("VID-20260804-WA0023.mp4".into()),
|
||||||
|
..TimelineRow::new("video", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("broken.jpg".into()),
|
||||||
|
..TimelineRow::new("photo", 200)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_thumb("video", THUMB_NONE, None).unwrap();
|
||||||
|
db.photo_set_thumb("photo", THUMB_NONE, None).unwrap();
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch("UPDATE sync_state SET value = '31' WHERE key = 'schema_version';")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let state = |uid: &str| db.photos_by_uid(&[uid.into()]).unwrap()[0].thumb_state;
|
||||||
|
assert_eq!(state("video"), THUMB_UNKNOWN, "the video is tried again");
|
||||||
|
assert_eq!(
|
||||||
|
state("photo"),
|
||||||
|
THUMB_NONE,
|
||||||
|
"an undecodable photo stays written off"
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_v30_leaves_a_v29_timeline_intact() {
|
fn migration_v30_leaves_a_v29_timeline_intact() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
@@ -945,6 +945,7 @@ impl Core {
|
|||||||
if !meta.complete {
|
if !meta.complete {
|
||||||
return Err("queued create holds an incomplete blob".into());
|
return Err("queued create holds an incomplete blob".into());
|
||||||
}
|
}
|
||||||
|
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
|
||||||
let guard = self
|
let guard = self
|
||||||
.transfers
|
.transfers
|
||||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -955,7 +956,7 @@ impl Core {
|
|||||||
media_type_for(name),
|
media_type_for(name),
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
))?;
|
))?;
|
||||||
@@ -1145,6 +1146,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let alt = conflict_name(&name, now_secs());
|
let alt = conflict_name(&name, now_secs());
|
||||||
|
let thumbnails = self.upload_thumbnails(blob, &alt);
|
||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -1155,7 +1157,7 @@ impl Core {
|
|||||||
media_type_for(&alt),
|
media_type_for(&alt),
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
))?;
|
))?;
|
||||||
@@ -1438,6 +1440,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = self.node_name(&uid);
|
let name = self.node_name(&uid);
|
||||||
|
let thumbnails = self.upload_thumbnails(&blob, &name);
|
||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -1451,7 +1454,7 @@ impl Core {
|
|||||||
&uid,
|
&uid,
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
let took = started.elapsed();
|
let took = started.elapsed();
|
||||||
|
|||||||
+484
-58
@@ -12,8 +12,8 @@ use std::io::{Read as _, Write as _};
|
|||||||
use std::os::unix::fs::OpenOptionsExt as _;
|
use std::os::unix::fs::OpenOptionsExt as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::Once;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Once, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
@@ -25,8 +25,8 @@ use pdfs_core::db::{self, StoredPhoto};
|
|||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType};
|
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
|
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
|
||||||
@@ -55,6 +55,20 @@ const THUMB_CANCEL_POLL: Duration = Duration::from_millis(100);
|
|||||||
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
|
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
|
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
|
||||||
static EXIFTOOL_MISSING_WARNING: Once = Once::new();
|
static EXIFTOOL_MISSING_WARNING: Once = Once::new();
|
||||||
|
static FFMPEG_MISSING_WARNING: Once = Once::new();
|
||||||
|
/// Largest video downloaded whole to grab one frame for its thumbnail. The
|
||||||
|
/// download lands in memory, so a long 4K recording is left without one.
|
||||||
|
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||||
|
/// A single frame is seconds of work even for 4K; this only stops a wedged
|
||||||
|
/// ffmpeg from holding a thumbnail permit.
|
||||||
|
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
|
||||||
|
/// this, stepping the JPEG quality down until it fits.
|
||||||
|
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
|
||||||
|
/// JPEG qualities tried, in order, for an upload thumbnail over that size.
|
||||||
|
const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
|
||||||
|
/// Largest still image read whole to render the thumbnail its upload carries.
|
||||||
|
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
|
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
|
||||||
/// pixels are decoded. `None` when the format is unknown or the header is torn.
|
/// pixels are decoded. `None` when the format is unknown or the header is torn.
|
||||||
@@ -90,7 +104,8 @@ struct RawPreviewUnavailable;
|
|||||||
/// the photo will never have a thumbnail, and only that verdict is persisted.
|
/// the photo will never have a thumbnail, and only that verdict is persisted.
|
||||||
enum ThumbAttempt {
|
enum ThumbAttempt {
|
||||||
Made(GeneratedThumb),
|
Made(GeneratedThumb),
|
||||||
/// Decoded nothing — a format this build has no decoder for. Permanent.
|
/// Decoded nothing — a format this build has no decoder for, or a video
|
||||||
|
/// too large to fetch for one frame. Permanent.
|
||||||
Undecodable,
|
Undecodable,
|
||||||
/// The photo couldn't be downloaded. Transient: try again next time.
|
/// The photo couldn't be downloaded. Transient: try again next time.
|
||||||
Unavailable,
|
Unavailable,
|
||||||
@@ -196,6 +211,11 @@ fn scale_thumbnail_with_exiftool(
|
|||||||
}
|
}
|
||||||
None => return ScaleAttempt::Undecodable,
|
None => return ScaleAttempt::Undecodable,
|
||||||
};
|
};
|
||||||
|
encode_thumb(&image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
|
||||||
|
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
|
||||||
let (width, height) = (image.width(), image.height());
|
let (width, height) = (image.width(), image.height());
|
||||||
if width == 0 || height == 0 {
|
if width == 0 || height == 0 {
|
||||||
return ScaleAttempt::Undecodable;
|
return ScaleAttempt::Undecodable;
|
||||||
@@ -268,9 +288,69 @@ impl Drop for RawTempFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run one bounded exiftool JSON query while draining stdout concurrently. The
|
/// How a bounded helper run failed.
|
||||||
/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
/// pipe buffer long before the child exits.
|
enum ToolError {
|
||||||
|
/// The helper isn't installed.
|
||||||
|
Missing,
|
||||||
|
/// It couldn't be started or read, or overran its budget and was killed.
|
||||||
|
Unavailable,
|
||||||
|
/// It ran and exited non-zero.
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a helper to completion within `timeout` and return its stdout, drained
|
||||||
|
/// concurrently. The latter matters for exiftool's full-size `JpgFromRaw`
|
||||||
|
/// fallbacks and for ffmpeg's frames, either of which can exceed a pipe buffer
|
||||||
|
/// long before the child exits.
|
||||||
|
fn run_bounded(mut command: Command, timeout: Duration) -> Result<Vec<u8>, ToolError> {
|
||||||
|
command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
let mut child = command.spawn().map_err(|error| {
|
||||||
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
ToolError::Missing
|
||||||
|
} else {
|
||||||
|
ToolError::Unavailable
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let Some(mut stdout) = child.stdout.take() else {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err(ToolError::Unavailable);
|
||||||
|
};
|
||||||
|
let reader = std::thread::spawn(move || {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
stdout.read_to_end(&mut output).map(|_| output)
|
||||||
|
});
|
||||||
|
let started = Instant::now();
|
||||||
|
let status = loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => break Some(status.success()),
|
||||||
|
Ok(None) if started.elapsed() < timeout => {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
Ok(None) | Err(_) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let output = reader
|
||||||
|
.join()
|
||||||
|
.ok()
|
||||||
|
.and_then(Result::ok)
|
||||||
|
.ok_or(ToolError::Unavailable)?;
|
||||||
|
match status {
|
||||||
|
Some(true) => Ok(output),
|
||||||
|
Some(false) => Err(ToolError::Failed),
|
||||||
|
None => Err(ToolError::Unavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one bounded exiftool JSON query.
|
||||||
fn exiftool_query(
|
fn exiftool_query(
|
||||||
exiftool: &OsStr,
|
exiftool: &OsStr,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
@@ -280,58 +360,172 @@ fn exiftool_query(
|
|||||||
let mut command = Command::new(exiftool);
|
let mut command = Command::new(exiftool);
|
||||||
command.args(["-j", "-b", "-n"]);
|
command.args(["-j", "-b", "-n"]);
|
||||||
command.args(tags);
|
command.args(tags);
|
||||||
command
|
command.arg(path);
|
||||||
.arg(path)
|
let output = run_bounded(command, timeout).map_err(|error| {
|
||||||
.stdout(Stdio::piped())
|
if error == ToolError::Missing {
|
||||||
.stderr(Stdio::null());
|
EXIFTOOL_MISSING_WARNING.call_once(|| {
|
||||||
let mut child = match command.spawn() {
|
warn!(
|
||||||
Ok(child) => child,
|
"exiftool is not installed; camera RAW thumbnails are unavailable \
|
||||||
Err(error) => {
|
(Debian/Ubuntu: libimage-exiftool-perl)"
|
||||||
if error.kind() == std::io::ErrorKind::NotFound {
|
);
|
||||||
EXIFTOOL_MISSING_WARNING.call_once(|| {
|
});
|
||||||
warn!(
|
|
||||||
"exiftool is not installed; camera RAW thumbnails are unavailable \
|
|
||||||
(Debian/Ubuntu: libimage-exiftool-perl)"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Err(RawPreviewUnavailable);
|
|
||||||
}
|
}
|
||||||
};
|
RawPreviewUnavailable
|
||||||
let Some(mut stdout) = child.stdout.take() else {
|
})?;
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
return Err(RawPreviewUnavailable);
|
|
||||||
};
|
|
||||||
let reader = std::thread::spawn(move || {
|
|
||||||
let mut output = Vec::new();
|
|
||||||
stdout.read_to_end(&mut output).map(|_| output)
|
|
||||||
});
|
|
||||||
let started = Instant::now();
|
|
||||||
let success = loop {
|
|
||||||
match child.try_wait() {
|
|
||||||
Ok(Some(status)) => break status.success(),
|
|
||||||
Ok(None) if started.elapsed() < timeout => {
|
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
Ok(None) | Err(_) => {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
break false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let output = reader
|
|
||||||
.join()
|
|
||||||
.ok()
|
|
||||||
.and_then(Result::ok)
|
|
||||||
.ok_or(RawPreviewUnavailable)?;
|
|
||||||
if !success {
|
|
||||||
return Err(RawPreviewUnavailable);
|
|
||||||
}
|
|
||||||
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
|
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
|
||||||
|
/// than a picture the image decoder can scale.
|
||||||
|
fn is_video_name(name: &str) -> bool {
|
||||||
|
PhotoKind::classify(Some(name), None) == PhotoKind::Video
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether ffmpeg can be run at all. Asked once, before a video is downloaded:
|
||||||
|
/// without ffmpeg every scroll past a video would fetch it whole for nothing.
|
||||||
|
fn ffmpeg_available() -> bool {
|
||||||
|
static AVAILABLE: OnceLock<bool> = OnceLock::new();
|
||||||
|
*AVAILABLE.get_or_init(|| {
|
||||||
|
let mut command = Command::new("ffmpeg");
|
||||||
|
command.arg("-version");
|
||||||
|
let available = run_bounded(command, Duration::from_secs(5)).is_ok();
|
||||||
|
if !available {
|
||||||
|
warn_ffmpeg_missing();
|
||||||
|
}
|
||||||
|
available
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn_ffmpeg_missing() {
|
||||||
|
FFMPEG_MISSING_WARNING.call_once(|| {
|
||||||
|
warn!("ffmpeg is not installed; video thumbnails are unavailable");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
|
||||||
|
/// staging copy. See [`video_file_thumbnail`].
|
||||||
|
fn video_thumbnail(
|
||||||
|
bytes: &[u8],
|
||||||
|
name: &str,
|
||||||
|
staging_dir: &Path,
|
||||||
|
ffmpeg: &OsStr,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> ScaleAttempt {
|
||||||
|
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
|
||||||
|
return ScaleAttempt::Unavailable;
|
||||||
|
};
|
||||||
|
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thumbnail from one frame of the video at `path`. A second in, past the
|
||||||
|
/// black lead-in most clips open on, and the first frame for a clip shorter than
|
||||||
|
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
|
||||||
|
/// upright.
|
||||||
|
///
|
||||||
|
/// Missing or wedged ffmpeg is retryable; a file ffmpeg can take no frame from
|
||||||
|
/// is the permanent verdict.
|
||||||
|
fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> ScaleAttempt {
|
||||||
|
for seek in ["1", "0"] {
|
||||||
|
let mut command = Command::new(ffmpeg);
|
||||||
|
command.args(["-nostdin", "-loglevel", "error", "-ss", seek, "-i"]);
|
||||||
|
command.arg(path);
|
||||||
|
command.args(["-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-"]);
|
||||||
|
match run_bounded(command, timeout) {
|
||||||
|
Ok(frame) if !frame.is_empty() => {
|
||||||
|
return match image::load_from_memory(&frame) {
|
||||||
|
Ok(image) => encode_thumb(&image),
|
||||||
|
Err(_) => ScaleAttempt::Undecodable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// No frame that far in: try from the start.
|
||||||
|
Ok(_) | Err(ToolError::Failed) => continue,
|
||||||
|
Err(ToolError::Missing) => {
|
||||||
|
warn_ffmpeg_missing();
|
||||||
|
return ScaleAttempt::Unavailable;
|
||||||
|
}
|
||||||
|
Err(ToolError::Unavailable) => return ScaleAttempt::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScaleAttempt::Undecodable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thumbnail to attach to an upload of the local file at `path`, which is
|
||||||
|
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
|
||||||
|
/// the gallery has to download the whole file to make its own.
|
||||||
|
///
|
||||||
|
/// Best effort: a file that isn't an image or a video, is too large to read, or
|
||||||
|
/// can't be decoded uploads without a thumbnail, as it always did. Blocking and
|
||||||
|
/// CPU-bound, so async callers run it on the blocking pool.
|
||||||
|
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
|
||||||
|
if is_video_name(name) && !ffmpeg_available() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
upload_thumbnails_with(path, name, staging_dir, OsStr::new("ffmpeg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload_thumbnails_with(
|
||||||
|
path: &Path,
|
||||||
|
name: &str,
|
||||||
|
staging_dir: &Path,
|
||||||
|
ffmpeg: &OsStr,
|
||||||
|
) -> Vec<Thumbnail> {
|
||||||
|
let attempt = if is_video_name(name) {
|
||||||
|
if !is_video_file(path, name) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
|
||||||
|
} else if is_thumbnail_image_name(name) {
|
||||||
|
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
|
||||||
|
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
scale_thumbnail(&bytes, name, staging_dir)
|
||||||
|
} else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let ScaleAttempt::Made(thumb) = attempt else {
|
||||||
|
debug!(name, "uploading without a thumbnail; none could be made");
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
fit_upload_thumb(thumb.bytes)
|
||||||
|
.and_then(|bytes| Thumbnail::new(ThumbnailType::Thumbnail, bytes).ok())
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.ts` is TypeScript as often as it is an MPEG transport stream, so a source
|
||||||
|
/// tree must not cost an ffmpeg run per file. A transport stream opens on its
|
||||||
|
/// 0x47 sync byte; every other video extension is taken at its word.
|
||||||
|
fn is_video_file(path: &Path, name: &str) -> bool {
|
||||||
|
if !name.to_ascii_lowercase().ends_with(".ts") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut first = [0u8; 1];
|
||||||
|
std::fs::File::open(path)
|
||||||
|
.and_then(|mut file| file.read_exact(&mut first))
|
||||||
|
.is_ok_and(|()| first[0] == 0x47)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-encode a thumbnail at falling quality until it fits
|
||||||
|
/// [`UPLOAD_THUMB_MAX_BYTES`], and at half the size if even the lowest quality
|
||||||
|
/// doesn't. `None` if nothing fits.
|
||||||
|
fn fit_upload_thumb(bytes: Vec<u8>) -> Option<Vec<u8>> {
|
||||||
|
if bytes.len() <= UPLOAD_THUMB_MAX_BYTES {
|
||||||
|
return Some(bytes);
|
||||||
|
}
|
||||||
|
let image = image::load_from_memory(&bytes).ok()?;
|
||||||
|
let half = image.thumbnail(image.width() / 2, image.height() / 2);
|
||||||
|
[image.to_rgb8(), half.to_rgb8()].iter().find_map(|image| {
|
||||||
|
UPLOAD_THUMB_QUALITIES.iter().find_map(|&quality| {
|
||||||
|
let mut smaller = Vec::new();
|
||||||
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut smaller, quality)
|
||||||
|
.encode_image(image)
|
||||||
|
.ok()?;
|
||||||
|
(smaller.len() <= UPLOAD_THUMB_MAX_BYTES).then_some(smaller)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn exiftool_object(
|
fn exiftool_object(
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||||
@@ -418,6 +612,24 @@ struct PhotoMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
|
/// [`upload_thumbnails`] for a file about to upload, staging any RAW copy
|
||||||
|
/// under this cache. Blocking.
|
||||||
|
pub(crate) fn upload_thumbnails(&self, path: &Path, name: &str) -> Vec<Thumbnail> {
|
||||||
|
upload_thumbnails(path, name, self.cache.raw_thumbnail_staging_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::upload_thumbnails`] on the blocking pool, for async callers.
|
||||||
|
pub(crate) async fn upload_thumbnails_async(
|
||||||
|
&self,
|
||||||
|
path: PathBuf,
|
||||||
|
name: String,
|
||||||
|
) -> Vec<Thumbnail> {
|
||||||
|
let staging_dir = self.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || upload_thumbnails(&path, &name, &staging_dir))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn photos_timeline(
|
pub(crate) fn photos_timeline(
|
||||||
&self,
|
&self,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
@@ -973,6 +1185,10 @@ impl Core {
|
|||||||
if !core.thumb_job_current(job) {
|
if !core.thumb_job_current(job) {
|
||||||
return (uid, ThumbAttempt::Cancelled);
|
return (uid, ThumbAttempt::Cancelled);
|
||||||
}
|
}
|
||||||
|
let video = is_video_name(&name);
|
||||||
|
if video && !ffmpeg_available() {
|
||||||
|
return (uid, ThumbAttempt::Unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
// One shared permit pool bounds all thumbnail batches,
|
// One shared permit pool bounds all thumbnail batches,
|
||||||
// rather than multiplying the limit by every control
|
// rather than multiplying the limit by every control
|
||||||
@@ -1019,6 +1235,19 @@ impl Core {
|
|||||||
return (uid, ThumbAttempt::Cancelled);
|
return (uid, ThumbAttempt::Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A video is downloaded whole for one frame, so a long
|
||||||
|
// recording is not worth it — and would not fit in memory.
|
||||||
|
if video {
|
||||||
|
match core.photos().get_node(&uid).await {
|
||||||
|
Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => {
|
||||||
|
info!(%uid, size = node_size(&node), "video too large for a local thumbnail");
|
||||||
|
return (uid, ThumbAttempt::Undecodable);
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let download_uid = uid.clone();
|
let download_uid = uid.clone();
|
||||||
let download = async move { client.download_file(&download_uid).await };
|
let download = async move { client.download_file(&download_uid).await };
|
||||||
tokio::pin!(download);
|
tokio::pin!(download);
|
||||||
@@ -1072,7 +1301,17 @@ impl Core {
|
|||||||
// stall the runtime's worker; hand it to the blocking pool.
|
// stall the runtime's worker; hand it to the blocking pool.
|
||||||
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||||||
let made = tokio::task::spawn_blocking(move || {
|
let made = tokio::task::spawn_blocking(move || {
|
||||||
scale_thumbnail(&bytes, &name, &staging_dir)
|
if video {
|
||||||
|
video_thumbnail(
|
||||||
|
&bytes,
|
||||||
|
&name,
|
||||||
|
&staging_dir,
|
||||||
|
OsStr::new("ffmpeg"),
|
||||||
|
FFMPEG_TIMEOUT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
scale_thumbnail(&bytes, &name, &staging_dir)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or(ScaleAttempt::Unavailable);
|
.unwrap_or(ScaleAttempt::Unavailable);
|
||||||
@@ -1592,10 +1831,12 @@ impl Core {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod thumb_tests {
|
mod thumb_tests {
|
||||||
use super::{
|
use super::{
|
||||||
RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail,
|
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
||||||
scale_thumbnail_with_exiftool, thumbnail_build_may_start,
|
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
||||||
|
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
||||||
};
|
};
|
||||||
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||||||
|
use proton_drive_rs::ThumbnailType;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -1758,6 +1999,103 @@ mod thumb_tests {
|
|||||||
assert!(matches!(attempt, ScaleAttempt::Unavailable));
|
assert!(matches!(attempt, ScaleAttempt::Unavailable));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An executable shell script standing in for ffmpeg, in its own directory
|
||||||
|
/// under `tmp` so parallel tests never share one.
|
||||||
|
fn fake_ffmpeg(tmp: &std::path::Path, body: &str) -> std::path::PathBuf {
|
||||||
|
std::fs::create_dir_all(tmp).unwrap();
|
||||||
|
let path = tmp.join("ffmpeg");
|
||||||
|
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scratch(test: &str) -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_video_frame_becomes_a_thumbnail() {
|
||||||
|
let tmp = scratch("video-frame");
|
||||||
|
let frame = tmp.join("frame.jpg");
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
|
||||||
|
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
|
||||||
|
|
||||||
|
let ScaleAttempt::Made(thumb) = video_thumbnail(
|
||||||
|
b"video bytes",
|
||||||
|
"clip.mp4",
|
||||||
|
&tmp,
|
||||||
|
ffmpeg.as_os_str(),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
) else {
|
||||||
|
panic!("a frame scales");
|
||||||
|
};
|
||||||
|
assert!((thumb.ratio - 1080.0 / 1920.0).abs() < 1e-6);
|
||||||
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
|
||||||
|
let tmp = scratch("video-broken");
|
||||||
|
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
|
||||||
|
let attempt = video_thumbnail(
|
||||||
|
b"not a video",
|
||||||
|
"clip.mp4",
|
||||||
|
&tmp,
|
||||||
|
ffmpeg.as_os_str(),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
assert!(matches!(attempt, ScaleAttempt::Undecodable));
|
||||||
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_ffmpeg_is_retryable() {
|
||||||
|
let attempt = video_thumbnail(
|
||||||
|
b"video bytes",
|
||||||
|
"clip.mp4",
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
|
||||||
|
Duration::from_millis(10),
|
||||||
|
);
|
||||||
|
assert!(matches!(attempt, ScaleAttempt::Unavailable));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional real-ffmpeg check, like the RAW fixture harness above.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "set PDFS_VIDEO_FIXTURE to a video file; needs ffmpeg"]
|
||||||
|
fn real_video_fixture_yields_a_frame() {
|
||||||
|
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
|
||||||
|
let path = std::path::PathBuf::from(path);
|
||||||
|
let name = path.file_name().unwrap().to_string_lossy();
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let ScaleAttempt::Made(thumb) = video_thumbnail(
|
||||||
|
&bytes,
|
||||||
|
&name,
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
OsStr::new("ffmpeg"),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
) else {
|
||||||
|
panic!("a frame from the video");
|
||||||
|
};
|
||||||
|
assert!(thumb.ratio > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn videos_are_told_apart_by_name() {
|
||||||
|
for name in [
|
||||||
|
"VID-20260804-WA0023.mp4",
|
||||||
|
"PXL_1.TS.mp4",
|
||||||
|
"clip.MOV",
|
||||||
|
"a.webm",
|
||||||
|
] {
|
||||||
|
assert!(is_video_name(name), "{name}");
|
||||||
|
}
|
||||||
|
for name in ["photo.jpg", "shot.NEF", "notes.txt", "mp4"] {
|
||||||
|
assert!(!is_video_name(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_running_build_only_accepts_the_same_root() {
|
fn a_running_build_only_accepts_the_same_root() {
|
||||||
let status = ThumbnailBuildStatus {
|
let status = ThumbnailBuildStatus {
|
||||||
@@ -1771,4 +2109,92 @@ mod thumb_tests {
|
|||||||
let idle = ThumbnailBuildStatus::default();
|
let idle = ThumbnailBuildStatus::default();
|
||||||
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
|
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uploaded_photo_carries_its_thumbnail() {
|
||||||
|
let tmp = scratch("upload-photo");
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
let photo = tmp.join("staged-blob");
|
||||||
|
std::fs::write(&photo, jpeg(4000, 3000)).unwrap();
|
||||||
|
|
||||||
|
let thumbnails = upload_thumbnails_with(&photo, "IMG_0001.jpg", &tmp, OsStr::new("ffmpeg"));
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
let [thumbnail] = thumbnails.as_slice() else {
|
||||||
|
panic!("one thumbnail, got {}", thumbnails.len());
|
||||||
|
};
|
||||||
|
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
|
||||||
|
assert!(thumbnail.content.len() <= UPLOAD_THUMB_MAX_BYTES);
|
||||||
|
assert_eq!(ratio_of(&thumbnail.content), Some(4.0 / 3.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uploaded_video_carries_a_frame() {
|
||||||
|
let tmp = scratch("upload-video");
|
||||||
|
let frame = tmp.join("frame.jpg");
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
|
||||||
|
std::fs::write(tmp.join("clip"), b"video bytes").unwrap();
|
||||||
|
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
|
||||||
|
|
||||||
|
let thumbnails = upload_thumbnails_with(
|
||||||
|
&tmp.join("clip"),
|
||||||
|
"VID-WA0001.mp4",
|
||||||
|
&tmp,
|
||||||
|
ffmpeg.as_os_str(),
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
assert_eq!(thumbnails.len(), 1);
|
||||||
|
assert!(ratio_of(&thumbnails[0].content).is_some_and(|ratio| ratio < 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_files_upload_without_a_thumbnail() {
|
||||||
|
let tmp = scratch("upload-other");
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
std::fs::write(tmp.join("file"), b"export const answer = 42;\n").unwrap();
|
||||||
|
// An ffmpeg that would hand back a frame, to prove it is never asked.
|
||||||
|
std::fs::write(tmp.join("frame.jpg"), jpeg(64, 64)).unwrap();
|
||||||
|
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", tmp.join("frame.jpg").display()));
|
||||||
|
|
||||||
|
for name in ["notes.txt", "answer.ts", "broken.jpg"] {
|
||||||
|
assert!(
|
||||||
|
upload_thumbnails_with(&tmp.join("file"), name, &tmp, ffmpeg.as_os_str())
|
||||||
|
.is_empty(),
|
||||||
|
"{name} uploads bare"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_oversized_thumbnail_is_squeezed_under_the_upload_limit() {
|
||||||
|
// Noise is the worst case for JPEG: at the gallery's quality a 512 px
|
||||||
|
// square of it lands far over the limit.
|
||||||
|
let mut seed = 0x2545_f491_u32;
|
||||||
|
let noise = image::RgbImage::from_fn(THUMB_EDGE, THUMB_EDGE, |_, _| {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 17;
|
||||||
|
seed ^= seed << 5;
|
||||||
|
let [r, g, b, _] = seed.to_le_bytes();
|
||||||
|
image::Rgb([r, g, b])
|
||||||
|
});
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, super::THUMB_QUALITY)
|
||||||
|
.encode_image(&noise)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
bytes.len() > UPLOAD_THUMB_MAX_BYTES,
|
||||||
|
"the fixture is oversized"
|
||||||
|
);
|
||||||
|
|
||||||
|
let fitted = fit_upload_thumb(bytes).expect("noise fits at half size");
|
||||||
|
assert!(fitted.len() <= UPLOAD_THUMB_MAX_BYTES);
|
||||||
|
assert_eq!(ratio_of(&fitted), Some(1.0), "shrinking keeps the shape");
|
||||||
|
let small = jpeg(64, 64);
|
||||||
|
assert_eq!(
|
||||||
|
fit_upload_thumb(small.clone()),
|
||||||
|
Some(small),
|
||||||
|
"a small one is untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1226,6 +1226,9 @@ impl Core {
|
|||||||
// Taken from the descriptor being streamed, so it names the content that
|
// Taken from the descriptor being streamed, so it names the content that
|
||||||
// actually goes up rather than whatever is at the path when we finish.
|
// actually goes up rather than whatever is at the path when we finish.
|
||||||
let streamed = LocalSig::from(&meta);
|
let streamed = LocalSig::from(&meta);
|
||||||
|
let thumbnails = self
|
||||||
|
.upload_thumbnails_async(path.clone(), name.to_string())
|
||||||
|
.await;
|
||||||
// Count the bytes as they are read, so this upload shows up in
|
// Count the bytes as they are read, so this upload shows up in
|
||||||
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
||||||
// draft is sealed, so the transfer registers without one.
|
// draft is sealed, so the transfer registers without one.
|
||||||
@@ -1246,7 +1249,7 @@ impl Core {
|
|||||||
crate::media_type_for(name),
|
crate::media_type_for(name),
|
||||||
reader,
|
reader,
|
||||||
meta.len() as i64,
|
meta.len() as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
Some(mtime),
|
Some(mtime),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -1274,6 +1277,9 @@ impl Core {
|
|||||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||||
let mtime = system_mtime(&meta);
|
let mtime = system_mtime(&meta);
|
||||||
let streamed = LocalSig::from(&meta);
|
let streamed = LocalSig::from(&meta);
|
||||||
|
let thumbnails = self
|
||||||
|
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
|
||||||
|
.await;
|
||||||
let reader = OwnedCountingReader::new(
|
let reader = OwnedCountingReader::new(
|
||||||
file,
|
file,
|
||||||
self.transfers.begin(
|
self.transfers.begin(
|
||||||
@@ -1284,7 +1290,7 @@ impl Core {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
self.client
|
self.client
|
||||||
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
|
.upload_new_revision_from(uid, reader, meta.len() as i64, thumbnails, Some(mtime))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||||
warn_if_torn(rel, &path, streamed);
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ async fn run_uploads(
|
|||||||
format!("destination access denied: errno {}", error.code()),
|
format!("destination access denied: errno {}", error.code()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let thumbnails = core
|
||||||
|
.upload_thumbnails_async(t.path.clone(), t.name.clone())
|
||||||
|
.await;
|
||||||
match core
|
match core
|
||||||
.client
|
.client
|
||||||
.upload_file_from(
|
.upload_file_from(
|
||||||
@@ -77,7 +80,7 @@ async fn run_uploads(
|
|||||||
media_type_for(&t.name),
|
media_type_for(&t.name),
|
||||||
reader,
|
reader,
|
||||||
t.size as i64,
|
t.size as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
mtime,
|
mtime,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1294,11 +1294,39 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
.developer_name("Nils Pukropp")
|
.developer_name("Nils Pukropp")
|
||||||
.website("https://github.com/narrrl/proton-drive-linux")
|
.website("https://github.com/narrrl/proton-drive-linux")
|
||||||
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
||||||
|
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
|
||||||
.license_type(gtk4::License::MitX11)
|
.license_type(gtk4::License::MitX11)
|
||||||
.comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial client — not affiliated with, endorsed by, or supported by Proton AG."))
|
.comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial client — not affiliated with, endorsed by, or supported by Proton AG."))
|
||||||
.debug_info(debug_info(&ui_about))
|
.debug_info(debug_info(&ui_about))
|
||||||
.debug_info_filename("proton-drive-linux-debug.txt")
|
.debug_info_filename("proton-drive-linux-debug.txt")
|
||||||
.build();
|
.build();
|
||||||
|
dialog.add_link(
|
||||||
|
&gettext("Source Code"),
|
||||||
|
"https://github.com/narrrl/proton-drive-linux",
|
||||||
|
);
|
||||||
|
dialog.add_link(
|
||||||
|
&gettext("Proton Terms of Service"),
|
||||||
|
"https://proton.me/legal/terms",
|
||||||
|
);
|
||||||
|
dialog.add_link(
|
||||||
|
&gettext("Proton Privacy Policy"),
|
||||||
|
"https://proton.me/legal/privacy",
|
||||||
|
);
|
||||||
|
// The text is Pango markup: an ampersand must be written as "&".
|
||||||
|
dialog.add_legal_section(
|
||||||
|
&gettext("Disclaimer"),
|
||||||
|
None,
|
||||||
|
gtk4::License::Custom,
|
||||||
|
Some(&gettext(
|
||||||
|
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.add_legal_section(
|
||||||
|
"proton-sdk-rs",
|
||||||
|
Some("© 2026 Nils Pukropp (contact@narl.io)"),
|
||||||
|
gtk4::License::MitX11,
|
||||||
|
None,
|
||||||
|
);
|
||||||
// Translators: replace with your name(s) and email address(es), one
|
// Translators: replace with your name(s) and email address(es), one
|
||||||
// per line, to be credited in the About dialog.
|
// per line, to be credited in the About dialog.
|
||||||
let credits = gettext("translator-credits");
|
let credits = gettext("translator-credits");
|
||||||
|
|||||||
@@ -419,6 +419,21 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
|
|||||||
.get_field(tag, exif::In::PRIMARY)
|
.get_field(tag, exif::In::PRIMARY)
|
||||||
.map(|f| f.display_value().with_unit(&reader).to_string())
|
.map(|f| f.display_value().with_unit(&reader).to_string())
|
||||||
};
|
};
|
||||||
|
// Text tags, without the quotes `display_value` wraps each string in and
|
||||||
|
// the NUL padding some cameras leave behind.
|
||||||
|
let ascii = |tag: exif::Tag| {
|
||||||
|
let field = reader.get_field(tag, exif::In::PRIMARY)?;
|
||||||
|
let exif::Value::Ascii(parts) = &field.value else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let text = parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| String::from_utf8_lossy(part).into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
|
||||||
|
(!text.is_empty()).then(|| text.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(size) = field(exif::Tag::PixelXDimension)
|
if let Some(size) = field(exif::Tag::PixelXDimension)
|
||||||
.zip(field(exif::Tag::PixelYDimension))
|
.zip(field(exif::Tag::PixelYDimension))
|
||||||
@@ -428,33 +443,41 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
|
|||||||
// Translators: label of a photo's pixel dimensions in the details panel.
|
// Translators: label of a photo's pixel dimensions in the details panel.
|
||||||
fields.push((gettext("Dimensions"), size));
|
fields.push((gettext("Dimensions"), size));
|
||||||
}
|
}
|
||||||
if let Some(taken) = field(exif::Tag::DateTimeOriginal) {
|
if let Some(taken) = ascii(exif::Tag::DateTimeOriginal) {
|
||||||
// Translators: label of when a photo was taken, in the details panel.
|
// Translators: label of when a photo was taken, in the details panel.
|
||||||
fields.push((pgettext("photo property", "Taken"), taken));
|
fields.push((pgettext("photo property", "Taken"), taken));
|
||||||
}
|
}
|
||||||
|
|
||||||
let camera = [exif::Tag::Make, exif::Tag::Model]
|
if let Some(camera) = camera_name(ascii(exif::Tag::Make), ascii(exif::Tag::Model)) {
|
||||||
.iter()
|
|
||||||
.filter_map(|tag| field(*tag))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ");
|
|
||||||
if !camera.is_empty() {
|
|
||||||
// Translators: label of the camera model in a photo's details panel.
|
// Translators: label of the camera model in a photo's details panel.
|
||||||
fields.push((gettext("Camera"), camera));
|
fields.push((gettext("Camera"), camera));
|
||||||
}
|
}
|
||||||
if let Some(lens) = field(exif::Tag::LensModel) {
|
if let Some(lens) = ascii(exif::Tag::LensModel) {
|
||||||
// Translators: label of the lens model in a photo's details panel.
|
// Translators: label of the lens model in a photo's details panel.
|
||||||
fields.push((gettext("Lens"), lens));
|
fields.push((gettext("Lens"), lens));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shutter = reader
|
||||||
|
.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY)
|
||||||
|
.and_then(|f| match &f.value {
|
||||||
|
exif::Value::Rational(r) => r.first().map(|r| r.to_f64()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|seconds| seconds.is_finite() && *seconds > 0.0)
|
||||||
|
.map(format_shutter);
|
||||||
|
let iso = reader
|
||||||
|
.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY)
|
||||||
|
.and_then(|f| f.value.get_uint(0))
|
||||||
|
// Translators: a photo's ISO sensitivity, such as "ISO 400".
|
||||||
|
.map(|n| gettext_f("ISO {value}", &[("value", &n.to_string())]));
|
||||||
let exposure: Vec<String> = [
|
let exposure: Vec<String> = [
|
||||||
exif::Tag::FNumber,
|
field(exif::Tag::FNumber),
|
||||||
exif::Tag::ExposureTime,
|
shutter,
|
||||||
exif::Tag::PhotographicSensitivity,
|
iso,
|
||||||
exif::Tag::FocalLength,
|
field(exif::Tag::FocalLength),
|
||||||
]
|
]
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter_map(|tag| field(*tag))
|
.flatten()
|
||||||
.collect();
|
.collect();
|
||||||
if !exposure.is_empty() {
|
if !exposure.is_empty() {
|
||||||
// Translators: label of aperture, shutter speed, ISO and focal length in a photo's details panel.
|
// Translators: label of aperture, shutter speed, ISO and focal length in a photo's details panel.
|
||||||
@@ -479,6 +502,29 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
|
|||||||
ExifInfo { fields, coords }
|
ExifInfo { fields, coords }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Google Pixel 6" from Make "Google" and Model "Pixel 6", but just "Canon EOS
|
||||||
|
/// R5" where the model already names the maker.
|
||||||
|
fn camera_name(make: Option<String>, model: Option<String>) -> Option<String> {
|
||||||
|
match (make, model) {
|
||||||
|
(Some(make), Some(model)) if model.to_lowercase().starts_with(&make.to_lowercase()) => {
|
||||||
|
Some(model)
|
||||||
|
}
|
||||||
|
(Some(make), Some(model)) => Some(format!("{make} {model}")),
|
||||||
|
(make, model) => make.or(model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shutter speed the way cameras print it: "1/250 s" below a second, "2.5 s"
|
||||||
|
/// from one up.
|
||||||
|
fn format_shutter(seconds: f64) -> String {
|
||||||
|
if seconds >= 0.95 {
|
||||||
|
let text = format!("{seconds:.1}");
|
||||||
|
format!("{} s", text.strip_suffix(".0").unwrap_or(&text))
|
||||||
|
} else {
|
||||||
|
format!("1/{} s", (1.0 / seconds).round())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
|
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
|
||||||
/// decimal degrees, negating for the S/W hemispheres.
|
/// decimal degrees, negating for the S/W hemispheres.
|
||||||
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
|
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
|
||||||
@@ -1019,8 +1065,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
info_panel.set_width_request(320);
|
info_panel.set_width_request(320);
|
||||||
info_panel.append(&info_scroll);
|
info_panel.append(&info_scroll);
|
||||||
|
|
||||||
|
// Set explicitly: the title's hexpand would otherwise propagate up and have
|
||||||
|
// the revealer take half the window even while it is closed.
|
||||||
let info_revealer = gtk4::Revealer::builder()
|
let info_revealer = gtk4::Revealer::builder()
|
||||||
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
|
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
|
||||||
|
.hexpand(false)
|
||||||
.child(&info_panel)
|
.child(&info_panel)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -1425,7 +1474,27 @@ pub(crate) fn over_photo(picture: >k4::Picture, x: f64, y: f64) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{named_link, position_label};
|
use super::{camera_name, format_shutter, named_link, position_label};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutter_speeds_read_like_a_camera() {
|
||||||
|
assert_eq!(format_shutter(1.0 / 18.869_704_689_121_615), "1/19 s");
|
||||||
|
assert_eq!(format_shutter(0.001), "1/1000 s");
|
||||||
|
assert_eq!(format_shutter(2.5), "2.5 s");
|
||||||
|
assert_eq!(format_shutter(1.0), "1 s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_name_does_not_repeat_the_maker() {
|
||||||
|
let s = |v: &str| Some(v.to_string());
|
||||||
|
assert_eq!(camera_name(s("Google"), s("Pixel 6")), s("Google Pixel 6"));
|
||||||
|
assert_eq!(
|
||||||
|
camera_name(s("Canon"), s("Canon EOS R5")),
|
||||||
|
s("Canon EOS R5")
|
||||||
|
);
|
||||||
|
assert_eq!(camera_name(None, s("iPhone 15")), s("iPhone 15"));
|
||||||
|
assert_eq!(camera_name(None, None), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_counter_counts_the_library_while_pages_are_left() {
|
fn the_counter_counts_the_library_while_pages_are_left() {
|
||||||
|
|||||||
@@ -157,10 +157,12 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
||||||
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
||||||
/// The rows currently realised by the ListView, as row index -> the uid of
|
/// The rows currently realised by the ListView, as row index -> the uid of
|
||||||
/// its first photo (absent for a heading row). A resize or a zoom step
|
/// its first photo (absent for a heading row) and the row's widget. A
|
||||||
/// changes how many tiles fit per row, so the row model has to be rebuilt —
|
/// resize or a zoom step changes how many tiles fit per row, so the row
|
||||||
/// this is what lets the rebuild put the user back where they were.
|
/// model has to be rebuilt — this is what lets the rebuild put the user
|
||||||
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>,
|
/// back where they were. The ListView realises rows beyond both edges of
|
||||||
|
/// the viewport, so the widget is what tells which of them is on screen.
|
||||||
|
pub(crate) bound: RefCell<BTreeMap<u32, (Option<String>, gtk4::Box)>>,
|
||||||
/// The ListView itself, so a rebuild can scroll back to the row the user was
|
/// The ListView itself, so a rebuild can scroll back to the row the user was
|
||||||
/// looking at.
|
/// looking at.
|
||||||
pub(crate) list: gtk4::ListView,
|
pub(crate) list: gtk4::ListView,
|
||||||
@@ -434,7 +436,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
.halign(gtk4::Align::End)
|
.halign(gtk4::Align::End)
|
||||||
.margin_top(12)
|
.margin_top(12)
|
||||||
.margin_bottom(12)
|
.margin_bottom(12)
|
||||||
.margin_end(4)
|
// Clear of the overlay scrollbar, which widens under the pointer.
|
||||||
|
.margin_end(18)
|
||||||
.tooltip_text(gettext("Jump to a month"))
|
.tooltip_text(gettext("Jump to a month"))
|
||||||
.visible(false)
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
@@ -900,7 +903,7 @@ pub(crate) fn wire_gallery(
|
|||||||
.gallery
|
.gallery
|
||||||
.bound
|
.bound
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.insert(item.position(), row.anchor());
|
.insert(item.position(), (row.anchor(), row_box.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ListView recycles row widgets, so a scrolled-away row must give up its
|
// ListView recycles row widgets, so a scrolled-away row must give up its
|
||||||
@@ -2004,7 +2007,7 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
|||||||
/// rebuilt. What that would cost the user is their scroll position, so the
|
/// rebuilt. What that would cost the user is their scroll position, so the
|
||||||
/// topmost realised row's first photo is remembered and scrolled back to.
|
/// topmost realised row's first photo is remembered and scrolled back to.
|
||||||
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||||
let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned();
|
let anchor = top_anchor(ui);
|
||||||
repaint_gallery(ui);
|
repaint_gallery(ui);
|
||||||
let Some(anchor) = anchor else { return };
|
let Some(anchor) = anchor else { return };
|
||||||
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
|
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
|
||||||
@@ -2015,6 +2018,30 @@ pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
|||||||
.scroll_to(row, gtk4::ListScrollFlags::empty(), None);
|
.scroll_to(row, gtk4::ListScrollFlags::empty(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first photo of the topmost photo row that reaches into the viewport.
|
||||||
|
///
|
||||||
|
/// Not simply the first bound row: the ListView keeps rows bound well outside
|
||||||
|
/// the viewport (unmapped, with their last allocation), and scrolling one of
|
||||||
|
/// those "back" to the top is a jump. Falls back to the first bound photo row
|
||||||
|
/// while the list isn't on screen.
|
||||||
|
fn top_anchor(ui: &Rc<Ui>) -> Option<String> {
|
||||||
|
let list = &ui.gallery.list;
|
||||||
|
let bound = ui.gallery.bound.borrow();
|
||||||
|
let mut photo_rows = bound
|
||||||
|
.values()
|
||||||
|
.filter_map(|(uid, row)| uid.as_ref().map(|uid| (uid, row)));
|
||||||
|
let first = photo_rows.clone().next().map(|(uid, _)| uid.clone());
|
||||||
|
photo_rows
|
||||||
|
.find(|(_, row)| {
|
||||||
|
row.is_mapped()
|
||||||
|
&& row
|
||||||
|
.compute_bounds(list)
|
||||||
|
.is_some_and(|rect| rect.y() + rect.height() > 0.0)
|
||||||
|
})
|
||||||
|
.map(|(uid, _)| uid.clone())
|
||||||
|
.or(first)
|
||||||
|
}
|
||||||
|
|
||||||
/// Which row of the rendered model holds `uid`, if any.
|
/// Which row of the rendered model holds `uid`, if any.
|
||||||
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
||||||
(0..store.n_items()).find(|i| {
|
(0..store.n_items()).find(|i| {
|
||||||
@@ -2329,7 +2356,7 @@ fn sync_scrubber_position(ui: &Rc<Ui>) {
|
|||||||
if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() {
|
if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(uid) = ui.gallery.bound.borrow().values().flatten().next().cloned() else {
|
let Some(uid) = top_anchor(ui) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid)
|
let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid)
|
||||||
|
|||||||
@@ -11,6 +11,33 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.2.1] — 2026-09-24
|
||||||
|
|
||||||
|
Schema: **32** (unchanged).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Legal information in About.** The About dialog now links to the source code and to Proton's
|
||||||
|
terms of service and privacy policy, and its Legal page carries the full disclaimer: this client
|
||||||
|
is unofficial, not affiliated with or endorsed by Proton AG, and comes without warranty.
|
||||||
|
|
||||||
|
## [2.2.0] — 2026-09-23
|
||||||
|
|
||||||
|
Schema: **32**. Videos that earlier got no thumbnail are tried again.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Video thumbnails.** Videos without a thumbnail from Drive now show a frame from the clip in
|
||||||
|
Photos. This needs `ffmpeg`, a new optional dependency.
|
||||||
|
- **Uploads carry a thumbnail.** Photos and videos uploaded from this client now include one, so
|
||||||
|
they show a preview in Proton's own apps as well, without downloading the whole file.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The Photos gallery no longer jumps** while thumbnails load, when zooming, or when switching
|
||||||
|
tabs and back.
|
||||||
|
- **The month scrubber no longer collides with the scrollbar.**
|
||||||
|
- **The photo details panel is no longer empty.**
|
||||||
|
- **Photo details read like a camera's:** "Google Pixel 6" instead of quoted EXIF values, shutter
|
||||||
|
speeds such as 1/19 s, and a labelled ISO.
|
||||||
|
|
||||||
## [2.1.1] — 2026-09-23
|
## [2.1.1] — 2026-09-23
|
||||||
|
|
||||||
Schema: **31** (unchanged).
|
Schema: **31** (unchanged).
|
||||||
@@ -1011,6 +1038,7 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
|||||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
|
||||||
[2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
|
[2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
|
||||||
[2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0
|
[2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0
|
||||||
[1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1
|
[1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=2.1.1
|
pkgver=2.2.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -20,6 +20,7 @@ makedepends=('cargo' 'git' 'gettext')
|
|||||||
provides=('pdfs')
|
provides=('pdfs')
|
||||||
# Files-on-demand mount + keyring need these at runtime.
|
# Files-on-demand mount + keyring need these at runtime.
|
||||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||||
|
'ffmpeg: video thumbnails'
|
||||||
'xdg-utils: "Open folder" action from the tray'
|
'xdg-utils: "Open folder" action from the tray'
|
||||||
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||||
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 2.1.0
|
pkgver = 2.2.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -14,26 +14,27 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
depends = glibc
|
depends = glibc
|
||||||
depends = hicolor-icon-theme
|
depends = hicolor-icon-theme
|
||||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||||
|
optdepends = ffmpeg: video thumbnails
|
||||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||||
optdepends = kwallet: credential storage over the Secret Service API
|
optdepends = kwallet: credential storage over the Secret Service API
|
||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.1.0
|
provides = proton-drive-for-linux=2.2.0
|
||||||
provides = proton-drive-linux=2.1.0
|
provides = proton-drive-linux=2.2.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
source = proton-drive-linux-2.1.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.1.0/proton-drive-linux-2.1.0-x86_64.tar.gz
|
source = proton-drive-linux-2.2.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.0/proton-drive-linux-2.2.0-x86_64.tar.gz
|
||||||
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.0/packaging/io.narl.proton-drive-linux.desktop
|
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux.desktop
|
||||||
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||||
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.0/packaging/io.narl.proton-drive-linux.svg
|
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux.svg
|
||||||
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.0/packaging/proton-drive.service
|
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/proton-drive.service
|
||||||
source = LICENSE-2.1.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.0/LICENSE
|
source = LICENSE-2.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/LICENSE
|
||||||
sha256sums = dd8aba8e38f42386e44994476e9a949516580bd077be1d274c396eef411664ac
|
sha256sums = 4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=2.1.0
|
pkgver=2.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -10,6 +10,7 @@ license=('MIT')
|
|||||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||||
'hicolor-icon-theme')
|
'hicolor-icon-theme')
|
||||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||||
|
'ffmpeg: video thumbnails'
|
||||||
'gnome-keyring: credential storage over the Secret Service API'
|
'gnome-keyring: credential storage over the Secret Service API'
|
||||||
'kwallet: credential storage over the Secret Service API'
|
'kwallet: credential storage over the Secret Service API'
|
||||||
'xdg-utils: "Open folder" action in the tray menu'
|
'xdg-utils: "Open folder" action in the tray menu'
|
||||||
@@ -29,7 +30,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$url/releases/download/$_tag/
|
|||||||
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('dd8aba8e38f42386e44994476e9a949516580bd077be1d274c396eef411664ac'
|
sha256sums=('4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 2.0.0.r1.g56161f6
|
pkgver = 2.2.0.r1.g82e9654
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -17,13 +17,14 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
depends = glibc
|
depends = glibc
|
||||||
depends = hicolor-icon-theme
|
depends = hicolor-icon-theme
|
||||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||||
|
optdepends = ffmpeg: video thumbnails
|
||||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||||
optdepends = kwallet: credential storage over the Secret Service API
|
optdepends = kwallet: credential storage over the Secret Service API
|
||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.0.0.r1.g56161f6
|
provides = proton-drive-for-linux=2.2.0.r1.g82e9654
|
||||||
provides = proton-drive-linux=2.0.0.r1.g56161f6
|
provides = proton-drive-linux=2.2.0.r1.g82e9654
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=2.0.0.r1.g56161f6
|
pkgver=2.2.0.r1.g82e9654
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
|||||||
'hicolor-icon-theme')
|
'hicolor-icon-theme')
|
||||||
makedepends=('cargo' 'git' 'gettext')
|
makedepends=('cargo' 'git' 'gettext')
|
||||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||||
|
'ffmpeg: video thumbnails'
|
||||||
'gnome-keyring: credential storage over the Secret Service API'
|
'gnome-keyring: credential storage over the Secret Service API'
|
||||||
'kwallet: credential storage over the Secret Service API'
|
'kwallet: credential storage over the Secret Service API'
|
||||||
'xdg-utils: "Open folder" action in the tray menu'
|
'xdg-utils: "Open folder" action in the tray menu'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 2.1.0
|
pkgver = 2.2.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -16,18 +16,19 @@ pkgbase = proton-drive-for-linux
|
|||||||
depends = glibc
|
depends = glibc
|
||||||
depends = hicolor-icon-theme
|
depends = hicolor-icon-theme
|
||||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||||
|
optdepends = ffmpeg: video thumbnails
|
||||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||||
optdepends = kwallet: credential storage over the Secret Service API
|
optdepends = kwallet: credential storage over the Secret Service API
|
||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=2.1.0
|
provides = proton-drive-linux=2.2.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
source = proton-drive-for-linux-2.1.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.1.0.tar.gz
|
source = proton-drive-for-linux-2.2.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.0.tar.gz
|
||||||
sha256sums = 9ca0409636552dc6d158595396402fd0fe6d77fab2ac5f5239fc51d702b791cb
|
sha256sums = 73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=2.1.0
|
pkgver=2.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
|||||||
'hicolor-icon-theme')
|
'hicolor-icon-theme')
|
||||||
makedepends=('cargo' 'gettext')
|
makedepends=('cargo' 'gettext')
|
||||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||||
|
'ffmpeg: video thumbnails'
|
||||||
'gnome-keyring: credential storage over the Secret Service API'
|
'gnome-keyring: credential storage over the Secret Service API'
|
||||||
'kwallet: credential storage over the Secret Service API'
|
'kwallet: credential storage over the Secret Service API'
|
||||||
'xdg-utils: "Open folder" action in the tray menu'
|
'xdg-utils: "Open folder" action in the tray menu'
|
||||||
@@ -21,7 +22,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
|
|||||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('9ca0409636552dc6d158595396402fd0fe6d77fab2ac5f5239fc51d702b791cb')
|
sha256sums=('73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ for pkg in proton-drive-for-linux proton-drive-for-linux-bin; do
|
|||||||
(cd "$pkg" && updpkgsums)
|
(cd "$pkg" && updpkgsums)
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# --printsrcinfo does not run pkgver(), so let makepkg clone the source and
|
||||||
|
# write the current git describe version back into the -git PKGBUILD first.
|
||||||
|
# --noprepare skips the cargo fetch in prepare().
|
||||||
|
(cd proton-drive-for-linux-git && makepkg -od --noprepare --noconfirm && rm -rf src)
|
||||||
|
|
||||||
for pkg in proton-drive-for-linux proton-drive-for-linux-git proton-drive-for-linux-bin; do
|
for pkg in proton-drive-for-linux proton-drive-for-linux-git proton-drive-for-linux-bin; do
|
||||||
(cd "$pkg" && makepkg --printsrcinfo > .SRCINFO)
|
(cd "$pkg" && makepkg --printsrcinfo > .SRCINFO)
|
||||||
echo "updated $pkg"
|
echo "updated $pkg"
|
||||||
|
|||||||
+221
-177
File diff suppressed because it is too large.
Load diff
+213
-178
File diff suppressed because it is too large.
Load diff
+222
-177
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user