Compare commits

..
Author SHA1 Message Date
nvrl 38bd282d3a fix(core): reconnect to the Secret Service after it restarts
The keyring store keeps one D-Bus connection and one Secret Service
session for the whole process, and was installed once. The session lives
only in the provider's memory, so after the provider restarts every read
and write on it fails with NoSession until the process restarts. oo7,
the provider on Fedora 45, restarts on failure and can be replaced. A
daemon that met this kept every later token refresh only in memory and
the kernel keyring, and the next cold boot found the spent refresh token
(issue #78, B285).

Every keyring call now goes through with_keyring_entry. A platform
failure drops the store, unless another thread already replaced it, and
the call is made once more on a new connection. A locked keyring, a
dismissed prompt and a missing entry come back unchanged.

Reproduced against gnome-keyring 51.1 and oo7 0.7.1 restarted with
--replace in a throwaway session bus; the ignored test
real_secret_service_survives_a_provider_restart passes against both.

Refs #78
2026-11-14 19:17:11 +01:00
nvrl d773ba15d3 Merge pull request #92 from narrrl/feat/audio-cover-thumbnails
feat(gui,fuse,core): album covers for songs, thumbnails that appear live
2026-11-14 17:44:22 +01:00
nvrl d93982baa9 feat(gui,fuse,core): album covers as song thumbnails
A song in a listing showed a generic icon, and the player a music note.
Both now show the song's cover: the picture in its tags, read with
lofty, or else a cover, folder, front or album image in its folder.

Only the tags are downloaded. A song up to 4 MiB comes whole. A larger
one brings the ID3v2 tag or the FLAC metadata blocks at its start, an
M4A's moov box, the first 4 MiB of an Ogg file, and the last 4 MiB for
formats that keep their tags at the end. The parts are laid out in a
sparse staging file at their own offsets, as video samples are.

A folder cover comes from that image's cached thumbnail, the thumbnail
its uploader attached, or the image itself. It is stored under the song.
Uploads of songs carry their embedded cover as the Drive thumbnail.
2026-11-14 16:48:08 +01:00
nvrl 3f5001b2d2 fix(gui,fuse,core): fill in thumbnails without reopening the folder
Build thumbnails cancelled the requests of the tiles on screen, and those
tiles stayed blank until the folder was opened again, or the whole
recursive build ended.

The daemon now publishes a new Topic::Thumbnails whenever a batch made
at least one thumbnail. The app keeps the tiles still showing their icon
across a cancel, and asks for them again on that event, whatever page is
open. Older front-ends read the topic as Unknown, and older daemons never
send it.
2026-11-14 16:27:57 +01:00
nvrl 2308021aa2 Merge pull request #91 from narrrl/feat/media-player
feat(gui): play videos and music in the app
2026-11-14 15:58:17 +01:00
14 changed files with 1152 additions and 57 deletions

No files matched your search

Generated
+42
View File
@@ -1114,6 +1114,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbl"
version = "0.3.2"
@@ -2902,6 +2908,32 @@ dependencies = [
"scopeguard",
]
[[package]]
name = "lofty"
version = "0.25.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35d45c446382e04663705aa8e786157f64ef1fa85755d410121ffbf08f54b0db"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdbca1b60ba7cea959ffc84ac27f3dbdbacd6dfda168c7eabbc00e48654bb9fd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "log"
version = "0.4.35"
@@ -3323,6 +3355,15 @@ dependencies = [
"subtle",
]
[[package]]
name = "ogg_pager"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "166611b491f939446e64c6be930843a31eb8e6798204ec3e9f57e084c18e7446"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -3594,6 +3635,7 @@ dependencies = [
"futures",
"image",
"libc",
"lofty",
"notify",
"parking_lot 0.13.0",
"pdfs-core",
+4
View File
@@ -50,6 +50,10 @@ sha1 = "0.11"
# formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
# Cover pictures of songs, read from their tags (ID3, FLAC, Ogg, MP4, APE) for
# the song's thumbnail. Its default feature is required: 0.25 does not build
# without it.
lofty = "0.25"
rusqlite = { version = "0.41", features = ["bundled"] }
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
+214 -29
View File
@@ -123,13 +123,84 @@ fn client_config() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
}
fn keyring_entry() -> Result<Entry> {
// Installed lazily rather than once at startup so a session bus that was not
// up yet on the first attempt is retried on the next one.
if keyring_core::get_default_store().is_none() {
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
/// Run `op` on the session's keyring entry.
fn with_keyring<T>(op: impl Fn(&Entry) -> keyring_core::Result<T>) -> Result<T> {
with_keyring_entry(KEYRING_SERVICE, KEYRING_USER, op)
}
/// Run `op` on the entry `service`/`user`, reconnecting once if the Secret
/// Service fails the call.
///
/// The store holds one D-Bus connection and one Secret Service session for the
/// whole process. The session lives in the provider's memory: once the provider
/// restarts (oo7's unit restarts it on failure, and both oo7 and gnome-keyring
/// can be `--replace`d), every read and write on the old session fails with
/// `NoSession`, for as long as this process runs. A daemon then keeps refreshed
/// tokens only in memory, and a cold boot loses them (`docs/BUGS.md` B285). A
/// platform failure therefore drops the store, and the call is made once more
/// on a new one. A locked keyring, a dismissed prompt or a missing entry is
/// not the connection's fault and comes back as it is.
fn with_keyring_entry<T>(
service: &str,
user: &str,
op: impl Fn(&Entry) -> keyring_core::Result<T>,
) -> Result<T> {
let store = keyring_store()?;
match op(&Entry::new(service, user)?) {
Err(keyring_core::Error::PlatformFailure(e)) => {
tracing::warn!(
error = %e,
"the Secret Service failed this process's connection; reconnecting"
);
drop_keyring_store(&store);
keyring_store()?;
Ok(op(&Entry::new(service, user)?)?)
}
result => Ok(result?),
}
}
/// Held while the default store is looked at and replaced, so that threads
/// failing at the same time drop the broken store once and share its
/// replacement.
static KEYRING_STORE: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
/// The process's Secret Service store, connected now if there is none.
///
/// Connected lazily rather than once at startup so a session bus that was not
/// up yet on the first attempt is tried again on the next one.
fn keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
let _swap = KEYRING_STORE.lock();
if let Some(store) = keyring_core::get_default_store() {
return Ok(store);
}
let store = connect_keyring_store()?;
keyring_core::set_default_store(Arc::clone(&store));
Ok(store)
}
/// Forget `failed`, unless another thread has already replaced it.
fn drop_keyring_store(failed: &Arc<keyring_core::CredentialStore>) {
let _swap = KEYRING_STORE.lock();
if keyring_core::get_default_store().is_some_and(|store| Arc::ptr_eq(&store, failed)) {
keyring_core::unset_default_store();
}
}
#[cfg(not(test))]
fn connect_keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
Ok(dbus_secret_service_keyring_store::Store::new()?)
}
/// The tests' in-memory store when they set one, so a reconnect finds the
/// same entries; the real Secret Service otherwise.
#[cfg(test)]
fn connect_keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
tests::KEYRING_CONNECTS.fetch_add(1, Ordering::SeqCst);
match tests::MOCK_STORE.lock().clone() {
Some(store) => Ok(store),
None => Ok(dbus_secret_service_keyring_store::Store::new()?),
}
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
}
/// What the user answered when asked for the mailbox password.
@@ -421,17 +492,15 @@ fn classify_verification_gate(e: ProtonError) -> Error {
fn write_stored(stored: &StoredSession) -> Result<()> {
let json = Zeroizing::new(serde_json::to_string(stored)?);
keyring_entry()?.set_password(&json)?;
Ok(())
with_keyring(|entry| entry.set_password(&json))
}
/// Load the persisted session blob, or `Error::NotLoggedIn` if absent.
pub fn load() -> Result<StoredSession> {
let entry = keyring_entry()?;
match entry.get_password() {
match with_keyring(|entry| entry.get_password()) {
Ok(json) => Ok(serde_json::from_str(&json)?),
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
Err(e) => Err(e.into()),
Err(Error::Keyring(keyring_core::Error::NoEntry)) => Err(Error::NotLoggedIn),
Err(e) => Err(e),
}
}
@@ -589,9 +658,9 @@ pub fn logout() -> Result<bool> {
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
}
let _ = kernelkey::UNSAVED_TOKENS.clear();
match keyring_entry()?.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended),
Err(e) => Err(e.into()),
match with_keyring(|entry| entry.delete_credential()) {
Ok(()) | Err(Error::Keyring(keyring_core::Error::NoEntry)) => Ok(ended),
Err(e) => Err(e),
}
}
@@ -1207,12 +1276,138 @@ mod tests {
/// The keyring is one store per process, so the tests that use it,
/// through `keyring-core`'s in-memory store, take turns.
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
let turn = KEYRING.lock();
keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
let store: Arc<keyring_core::CredentialStore> = keyring_core::mock::Store::new().unwrap();
*MOCK_STORE.lock() = Some(Arc::clone(&store));
keyring_core::set_default_store(store);
turn
}
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
/// The in-memory store a reconnect hands out, so it finds the same
/// entries; `None` connects to the real Secret Service.
pub(super) static MOCK_STORE: parking_lot::Mutex<Option<Arc<keyring_core::CredentialStore>>> =
parking_lot::Mutex::new(None);
/// How many times a store was connected.
pub(super) static KEYRING_CONNECTS: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
/// The session's entry, to make its next call fail.
fn keyring_entry() -> Entry {
Entry::new(KEYRING_SERVICE, KEYRING_USER).unwrap()
}
/// Make the session entry's next call fail with `error`.
fn fail_next(error: keyring_core::Error) {
keyring_entry()
.as_any()
.downcast_ref::<keyring_core::mock::Cred>()
.unwrap()
.set_error(error);
}
fn no_session() -> keyring_core::Error {
keyring_core::Error::PlatformFailure(
std::io::Error::other("The session does not exist (NoSession)").into(),
)
}
/// B285: a restarted Secret Service no longer knows the process's
/// session, and every call on it failed until the process restarted.
#[test]
fn a_dropped_secret_service_session_is_reconnected_and_retried() {
let _turn = mock_keyring();
write_stored(&stored_with("r")).unwrap();
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
fail_next(no_session());
assert_eq!(load().unwrap().refresh_token, "r");
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects + 1);
fail_next(no_session());
write_stored(&stored_with("r2")).unwrap();
assert_eq!(load().unwrap().refresh_token, "r2");
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects + 2);
}
#[test]
fn a_rotation_lands_at_once_after_the_keyring_restarts() {
let _turn = mock_keyring();
write_stored(&stored_with("r")).unwrap();
let sink = Arc::new(TokenSink::new("s", "r".into()));
fail_next(no_session());
sink.rotated(tokens("a2", "r2")).unwrap();
assert_eq!(load().unwrap().refresh_token, "r2");
assert!(sink.state.lock().unsaved.is_none());
}
/// A locked keyring or a dismissed prompt is no fault of the connection.
#[test]
fn a_locked_keyring_is_not_reconnected() {
let _turn = mock_keyring();
write_stored(&stored_with("r")).unwrap();
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
fail_next(keyring_core::Error::NoStorageAccess(
std::io::Error::other("locked").into(),
));
assert!(matches!(
load(),
Err(Error::Keyring(keyring_core::Error::NoStorageAccess(_)))
));
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects);
assert_eq!(load().unwrap().refresh_token, "r");
}
/// The real Secret Service, restarted between two calls on one store.
///
/// Run inside a throwaway session bus, never against the desktop's
/// keyring. With gnome-keyring:
///
/// ```sh
/// echo -n pw | gnome-keyring-daemon --unlock --components=secrets
/// PDFS_SECRET_SERVICE_RESTART_TEST=1 \
/// PDFS_RESTART_PROVIDER='echo -n pw | gnome-keyring-daemon --replace --unlock --components=secrets' \
/// cargo test -p pdfs-core real_secret_service -- --ignored
/// ```
#[test]
#[ignore = "needs a throwaway Secret Service; see the doc comment"]
fn real_secret_service_survives_a_provider_restart() {
if std::env::var_os("PDFS_SECRET_SERVICE_RESTART_TEST").is_none() {
return;
}
let restart = std::env::var("PDFS_RESTART_PROVIDER").expect("PDFS_RESTART_PROVIDER");
let _turn = KEYRING.lock();
*MOCK_STORE.lock() = None;
keyring_core::unset_default_store();
let (service, user) = ("pdfs-test", "restart");
with_keyring_entry(service, user, |e| e.set_password("before")).unwrap();
let status = std::process::Command::new("sh")
.args(["-c", &restart])
.status()
.unwrap();
assert!(status.success(), "restarting the provider failed");
std::thread::sleep(Duration::from_secs(1));
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
let read = with_keyring_entry(service, user, |e| e.get_password()).unwrap();
assert_eq!(read, "before");
assert_eq!(
KEYRING_CONNECTS.load(Ordering::SeqCst),
connects + 1,
"the provider kept the old session, so nothing was tested"
);
with_keyring_entry(service, user, |e| e.set_password("after")).unwrap();
let read = with_keyring_entry(service, user, |e| e.get_password()).unwrap();
assert_eq!(read, "after");
with_keyring_entry(service, user, |e| e.delete_credential()).unwrap();
keyring_core::unset_default_store();
}
fn stored_with(refresh: &str) -> StoredSession {
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
}
@@ -1267,12 +1462,7 @@ mod tests {
write_stored(&stored_with("r")).unwrap();
let sink = Arc::new(TokenSink::new("s", "r".into()));
let entry = keyring_entry().unwrap();
let cred = entry
.as_any()
.downcast_ref::<keyring_core::mock::Cred>()
.unwrap();
cred.set_error(keyring_core::Error::NoStorageAccess(
fail_next(keyring_core::Error::NoStorageAccess(
std::io::Error::other("locked").into(),
));
assert!(sink.rotated(tokens("a2", "r2")).is_err());
@@ -1307,12 +1497,7 @@ mod tests {
let _turn = mock_keyring();
write_stored(&stored_with("r")).unwrap();
let sink = Arc::new(TokenSink::new("s", "r".into()));
let entry = keyring_entry().unwrap();
entry
.as_any()
.downcast_ref::<keyring_core::mock::Cred>()
.unwrap()
.set_error(keyring_core::Error::NoEntry);
fail_next(keyring_core::Error::NoEntry);
assert!(sink.rotated(tokens("a2", "r2")).is_err());
assert!(sink.state.lock().unsaved.is_some());
+49 -9
View File
@@ -768,6 +768,9 @@ pub enum Topic {
Conflicts,
/// The activity log gained entries.
Activity,
/// File thumbnails were made or fetched. A front-end asks again for the
/// tiles it still shows blank; the ones it already painted stay as they are.
Thumbnails,
/// A topic from a newer daemon, which this front-end cannot act on.
#[serde(other)]
Unknown,
@@ -777,7 +780,7 @@ impl Topic {
/// Every topic a front-end can act on. A new subscription is told all of
/// them have changed, so it catches up on whatever happened while it was
/// not connected.
pub const ALL: [Topic; 9] = [
pub const ALL: [Topic; 10] = [
Topic::Files,
Topic::Photos,
Topic::Trash,
@@ -787,6 +790,7 @@ impl Topic {
Topic::Queue,
Topic::Conflicts,
Topic::Activity,
Topic::Thumbnails,
];
}
@@ -1877,11 +1881,26 @@ pub fn is_video_file_name(name: &str, size: Option<u64>) -> bool {
!transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES)
}
/// Whether `name` is a song or other recording in a format that can carry
/// its own cover picture: ID3 (MP3, AAC, WAV, AIFF), FLAC and Ogg pictures,
/// MP4 `covr` (M4A, M4B), or APE tags (WavPack, Monkey's Audio).
pub fn is_audio_file_name(name: &str) -> bool {
let Some((_, extension)) = name.rsplit_once('.') else {
return false;
};
AUDIO_COVER_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
}
const AUDIO_COVER_EXTENSIONS: &[&str] = &[
"aac", "aif", "aiff", "ape", "flac", "m4a", "m4b", "mp3", "oga", "ogg", "opus", "wav", "wv",
];
/// Whether file listings show a thumbnail for a file named `name`, of `size`
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, or a video,
/// whose thumbnail is a frame the daemon takes with ffmpeg.
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, a video,
/// whose thumbnail is a frame the daemon takes with ffmpeg, or a song, whose
/// thumbnail is its cover: the picture in its tags, or a cover image beside it.
pub fn is_thumbnail_media_name(name: &str, size: Option<u64>) -> bool {
is_thumbnail_image_name(name) || is_video_file_name(name, size)
is_thumbnail_image_name(name) || is_video_file_name(name, size) || is_audio_file_name(name)
}
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
@@ -2663,6 +2682,16 @@ mod tests {
assert!(end.is_none(), "{end:?}");
}
#[test]
fn a_thumbnail_change_travels_as_its_own_topic() {
let line = serde_json::to_string(&Event::Changed {
topics: vec![Topic::Thumbnails],
})
.unwrap();
assert_eq!(line, r#"{"Changed":{"topics":["thumbnails"]}}"#);
assert!(Topic::ALL.contains(&Topic::Thumbnails));
}
#[test]
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
@@ -2727,7 +2756,7 @@ mod tests {
}
#[test]
fn listings_thumbnail_images_and_videos() {
fn listings_thumbnail_images_videos_and_songs() {
assert!(is_thumbnail_media_name("photo.JPG", Some(10)));
assert!(is_thumbnail_media_name("camera.nef", None));
assert!(is_thumbnail_media_name("clip.mp4", Some(10)));
@@ -2736,13 +2765,24 @@ mod tests {
Some(64 * 1024 * 1024)
));
assert!(!is_thumbnail_media_name("app.ts", Some(2048)));
assert!(!is_thumbnail_media_name(
"song.flac",
Some(64 * 1024 * 1024)
));
assert!(is_thumbnail_media_name("song.flac", Some(64 * 1024 * 1024)));
assert!(is_thumbnail_media_name("Track 01.MP3", None));
assert!(!is_thumbnail_media_name("notes.txt", None));
}
#[test]
fn songs_are_told_by_their_extension() {
for name in [
"a.mp3", "b.FLAC", "c.ogg", "d.oga", "e.opus", "f.m4a", "g.m4b", "h.aac", "i.wav",
"j.aiff", "k.aif", "l.wv", "m.ape",
] {
assert!(is_audio_file_name(name), "{name}");
}
for name in ["mp3", "song.mp3.zip", "clip.mp4", "notes.txt", "cover.jpg"] {
assert!(!is_audio_file_name(name), "{name}");
}
}
/// Keep every extension accepted by the shared classifier covered. Drive
/// activation uses this classification before it knows whether opening a
/// file should stream through FUSE or materialise a complete cache entry;
+1
View File
@@ -23,6 +23,7 @@ libc.workspace = true
serde_json.workspace = true
base64.workspace = true
image.workspace = true
lofty.workspace = true
notify = "8"
parking_lot.workspace = true
+529 -4
View File
@@ -19,7 +19,7 @@ use std::time::{Duration, Instant};
use base64::Engine as _;
use pdfs_core::control::{
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
ThumbnailBuildStatus, Topic, is_audio_file_name, is_raw_image_name, is_thumbnail_image_name,
is_thumbnail_media_name,
};
use pdfs_core::db::{self, StoredPhoto};
@@ -76,6 +76,36 @@ const VIDEO_BOX_WALK_MAX: usize = 32;
/// 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);
/// A song no larger than this is downloaded whole for its cover.
const AUDIO_WHOLE_MAX_BYTES: u64 = 4 * 1024 * 1024;
/// How much of a song's start is read first, to learn where its tags end.
const AUDIO_PROBE_BYTES: u64 = 64 * 1024;
/// How much of a song's start is read when its first bytes don't say where its
/// tags end: an Ogg comment packet, which holds the cover, has no size up front.
const AUDIO_HEAD_BYTES: u64 = 4 * 1024 * 1024;
/// Read past the tags at a song's start, so the first audio frames are there
/// for a reader that looks for them.
const AUDIO_HEAD_SLACK: u64 = 64 * 1024;
/// Largest start of a song read for its tags. A cover bigger than this is not
/// worth the download for a 512-pixel thumbnail.
const AUDIO_TAG_MAX_BYTES: u64 = 16 * 1024 * 1024;
/// How much of a song's end is read in formats that keep their tags there
/// (APE tags in WavPack and Monkey's Audio, `id3 ` chunks in WAV and AIFF).
const AUDIO_TAIL_BYTES: u64 = 4 * 1024 * 1024;
/// Names of a folder's cover image, best first, matched without case and
/// extension. A song without a picture in its tags gets this image's.
const FOLDER_COVER_STEMS: [&str; 6] = [
"cover",
"folder",
"front",
"album",
"albumart",
"albumartsmall",
];
/// Image types a folder cover is taken in.
const FOLDER_COVER_EXTENSIONS: [&str; 4] = ["jpg", "jpeg", "png", "webp"];
/// Largest folder cover image downloaded whole for a song's thumbnail.
const FOLDER_COVER_MAX_BYTES: u64 = 32 * 1024 * 1024;
/// 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;
@@ -630,6 +660,7 @@ enum Fetched {
/// A whole image.
Whole(Vec<u8>),
Video(VideoSample),
Audio(AudioSample),
}
/// The parts of a video its thumbnail needs, downloaded: the start of the
@@ -766,13 +797,181 @@ fn video_sample_thumbnail(
video_file_thumbnail(&file.0, ffmpeg, timeout)
}
/// The parts of a song its cover needs, downloaded: the start, where most
/// formats keep their tags, an M4A's index, and for formats that keep their
/// tags at the end, the end.
struct AudioSample {
/// The whole file's length.
size: u64,
/// Each part and the offset it came from.
parts: Vec<(u64, Vec<u8>)>,
}
/// Where the tags at the start of a song end, read from its first `probe`
/// bytes: past an ID3v2 tag, and past FLAC's metadata blocks, which hold its
/// picture. `None` when the start says nothing, as for Ogg. A FLAC block
/// header past `probe` stops the walk with a guess of [`AUDIO_HEAD_BYTES`]
/// more.
fn audio_tags_end(probe: &[u8]) -> Option<u64> {
let mut offset = 0u64;
let mut known = false;
if probe.get(0..3) == Some(b"ID3") {
let size = probe.get(6..10)?;
if size.iter().any(|byte| byte & 0x80 != 0) {
return None;
}
let size = size
.iter()
.fold(0u64, |acc, byte| (acc << 7) | u64::from(*byte));
let footer = probe.get(5).is_some_and(|flags| flags & 0x10 != 0);
offset = 10 + size + if footer { 10 } else { 0 };
known = true;
}
let start = usize::try_from(offset).ok()?;
if probe.get(start..start + 4) == Some(b"fLaC") {
offset += 4;
loop {
let at = usize::try_from(offset).ok()?;
let Some(header) = probe.get(at..at + 4) else {
return Some(offset + AUDIO_HEAD_BYTES);
};
let len = u64::from_be_bytes([0, 0, 0, 0, 0, header[1], header[2], header[3]]);
offset += 4 + len;
if header[0] & 0x80 != 0 {
return Some(offset);
}
}
}
known.then_some(offset)
}
/// Whether a song in format `name` may keep its tags at its end.
fn audio_tags_at_end(name: &str) -> bool {
let extension = name
.rsplit_once('.')
.map(|(_, extension)| extension.to_ascii_lowercase())
.unwrap_or_default();
matches!(extension.as_str(), "wv" | "ape" | "wav" | "aif" | "aiff")
}
/// Download what the cover of the song `reader` reads, named `name`, needs. A
/// song of up to [`AUDIO_WHOLE_MAX_BYTES`] comes whole; a larger one brings its
/// tags, never its audio.
async fn sample_audio(reader: &dyn RevisionRead, name: &str) -> DriveResult<AudioSample> {
let size = reader.size();
if size <= AUDIO_WHOLE_MAX_BYTES {
let whole = reader.read_at(0, size).await?;
return Ok(AudioSample {
size,
parts: vec![(0, whole)],
});
}
let probe = reader.read_at(0, AUDIO_PROBE_BYTES.min(size)).await?;
// An M4A keeps its cover in its index, fetched below wherever it sits.
let iso_media = probe.get(4..8) == Some(b"ftyp");
let want = match audio_tags_end(&probe) {
_ if iso_media => 0,
Some(end) => end.saturating_add(AUDIO_HEAD_SLACK),
None => AUDIO_HEAD_BYTES,
}
.min(AUDIO_TAG_MAX_BYTES)
.min(size);
let head = if want > probe.len() as u64 {
reader.read_at(0, want).await?
} else {
probe
};
let mut parts = Vec::new();
match iso_media_index(reader, &head, size).await? {
Some((offset, len)) if offset.saturating_add(len) > head.len() as u64 => {
parts.push((offset, reader.read_at(offset, len).await?));
}
_ => {}
}
let head_len = head.len() as u64;
parts.push((0, head));
if audio_tags_at_end(name) {
let start = size.saturating_sub(AUDIO_TAIL_BYTES).max(head_len);
if start < size {
parts.push((start, reader.read_at(start, size - start).await?));
}
}
Ok(AudioSample { size, parts })
}
/// The picture a song's tags carry: the front cover, or else the first one.
/// `None` for a song without one, or one lofty can't read.
fn embedded_cover(path: &Path) -> Option<Vec<u8>> {
use lofty::config::{ParseOptions, ParsingMode};
use lofty::file::TaggedFileExt as _;
use lofty::picture::PictureType;
let tagged = lofty::probe::Probe::open(path)
.ok()?
.options(
ParseOptions::new()
.read_properties(false)
.parsing_mode(ParsingMode::Relaxed),
)
.guess_file_type()
.ok()?
.read()
.ok()?;
let pictures = || tagged.tags().iter().flat_map(|tag| tag.pictures());
pictures()
.find(|picture| picture.pic_type() == PictureType::CoverFront)
.or_else(|| pictures().next())
.map(|picture| picture.data().to_vec())
.filter(|data| !data.is_empty())
}
/// A thumbnail from the cover in a downloaded [`AudioSample`]'s tags. The
/// sample is laid out in a staging file at the offsets it came from, so the
/// tags are where the file's own headers say they are; the audio between
/// holds no data and costs no disk. A song without a cover, or with one the
/// image decoder can't read, is [`ScaleAttempt::Undecodable`].
fn audio_cover_thumbnail(sample: &AudioSample, name: &str, staging_dir: &Path) -> ScaleAttempt {
let Some(file) = RawTempFile::create_in(staging_dir, &[], name) else {
return ScaleAttempt::Unavailable;
};
let laid_out = OpenOptions::new()
.write(true)
.open(&file.0)
.and_then(|staged| {
for (offset, bytes) in &sample.parts {
staged.write_all_at(bytes, *offset)?;
}
staged.set_len(sample.size)
});
if laid_out.is_err() {
return ScaleAttempt::Unavailable;
}
match embedded_cover(&file.0) {
Some(picture) => scale_thumbnail(&picture, "cover", staging_dir),
None => ScaleAttempt::Undecodable,
}
}
/// How good a folder cover `name` is: its place in [`FOLDER_COVER_STEMS`],
/// lowest best, or `None` for a file that is no cover image.
fn cover_rank(name: &str) -> Option<usize> {
let (stem, extension) = name.rsplit_once('.')?;
let extension = extension.to_ascii_lowercase();
if !FOLDER_COVER_EXTENSIONS.contains(&extension.as_str()) {
return None;
}
let stem = stem.to_ascii_lowercase();
FOLDER_COVER_STEMS.iter().position(|known| *known == stem)
}
/// 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.
/// Best effort: a file that isn't an image, a video or a song with a cover in
/// its tags, 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();
@@ -806,6 +1005,14 @@ fn upload_thumbnails_with(
return Vec::new();
};
scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
} else if is_audio_file_name(name) {
// Only the song's own picture: a cover image beside it is not known
// here, where `path` may be a staging copy rather than the song's
// folder. The daemon finds that one when a listing asks.
match embedded_cover(path) {
Some(picture) => scale_thumbnail(&picture, "cover", staging_dir),
None => return Vec::new(),
}
} else {
return Vec::new();
};
@@ -1703,6 +1910,7 @@ impl Core {
return (uid, ThumbAttempt::Cancelled);
}
let video = is_video_name(&name);
let audio = is_audio_file_name(&name);
// Without ffmpeg a video can still have its uploader's
// thumbnail, but Photos has asked the server already.
let can_sample = !video || ffmpeg_available();
@@ -1757,6 +1965,7 @@ impl Core {
let download_uid = uid.clone();
let drive = core.drive.clone();
let audio_name = name.clone();
let download = async move {
// Photos asked the server in one batch already. A
// file view's or a build's file may carry a
@@ -1776,6 +1985,11 @@ impl Core {
if video {
let reader = drive.open_revision(&download_uid).await?;
sample_video(reader.as_ref()).await.map(Fetched::Video)
} else if audio {
let reader = drive.open_revision(&download_uid).await?;
sample_audio(reader.as_ref(), &audio_name)
.await
.map(Fetched::Audio)
} else {
client
.download_file(&download_uid)
@@ -1833,7 +2047,11 @@ impl Core {
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
// 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 = staging_dir.clone();
let made = tokio::task::spawn_blocking(move || match fetched {
Fetched::Audio(sample) => {
audio_cover_thumbnail(&sample, &name, &staging_dir)
}
Fetched::Video(sample) => video_sample_thumbnail(
&sample,
&name,
@@ -1850,6 +2068,14 @@ impl Core {
})
.await
.unwrap_or(ScaleAttempt::Unavailable);
// A song without a picture of its own shows its
// folder's cover.
let made = match made {
ScaleAttempt::Undecodable if audio && core.thumb_job_current(job) => {
core.folder_cover_thumbnail(&uid, staging).await
}
made => made,
};
if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled);
}
@@ -1910,9 +2136,85 @@ impl Core {
ThumbAttempt::Cancelled => summary.cancelled += 1,
}
}
// A folder on screen whose tiles were given up on, as a recursive
// build's start does to them, fills in without being opened again.
if summary.made > 0 {
self.events.publish(&[Topic::Thumbnails]);
}
summary
}
/// The cover image in the folder of the song `uid`: the best-named one of
/// [`FOLDER_COVER_STEMS`] among its siblings the database knows.
fn folder_cover(&self, uid: &NodeUid) -> Option<proton_drive_rs::Node> {
let node = self.db.node_by_uid(&uid.to_string()).ok()??;
let parent = node.parent_uid?;
let siblings = match self.db.children_if_listed(&parent) {
Ok(Some(children)) => children,
_ => self.db.known_children(&parent).ok()?,
};
siblings
.into_iter()
.filter(|sibling| !sibling.is_folder() && !sibling.trashed)
.filter_map(|sibling| cover_rank(&sibling.name).map(|rank| (rank, sibling)))
.min_by(|(a, x), (b, y)| a.cmp(b).then_with(|| x.name.cmp(&y.name)))
.map(|(_, cover)| cover)
}
/// A thumbnail of the song `uid` made from its folder's cover image: the
/// cover's own cached thumbnail when it has one, else the thumbnail its
/// uploader attached, else the image itself, up to
/// [`FOLDER_COVER_MAX_BYTES`].
///
/// The result is stored under the song, so a cover added to the folder or
/// replaced later shows only once the song changes or the daemon restarts
/// and forgets the songs it found no cover for.
async fn folder_cover_thumbnail(&self, uid: &NodeUid, staging_dir: PathBuf) -> ScaleAttempt {
let Some(cover) = self.folder_cover(uid) else {
return ScaleAttempt::Undecodable;
};
let ttype = ThumbnailType::Thumbnail;
let cached = self
.cache
.cached_thumbnail_path(&cover.uid, ttype.as_i32(), cover.modification_time)
.and_then(|path| std::fs::read(path).ok());
let ready = match cached {
Some(bytes) => Some(bytes),
None => match self.drive.download_thumbnail(&cover.uid, ttype).await {
Ok(bytes) => bytes.filter(|bytes| !bytes.is_empty()),
Err(e) => {
warn!(cover = %cover.uid, error = %e, "folder cover thumbnail download failed");
return ScaleAttempt::Unavailable;
}
},
};
if let Some(bytes) = ready {
return match ratio_of(&bytes) {
Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }),
None => ScaleAttempt::Undecodable,
};
}
let read = async {
let reader = self.drive.open_revision(&cover.uid).await?;
if reader.size() > FOLDER_COVER_MAX_BYTES {
return Ok(None);
}
reader.read_at(0, reader.size()).await.map(Some)
};
let bytes = match read.await {
Ok(Some(bytes)) => bytes,
Ok(None) => return ScaleAttempt::Undecodable,
Err(e) => {
warn!(cover = %cover.uid, error = %e, "folder cover download failed");
return ScaleAttempt::Unavailable;
}
};
let name = cover.name;
tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name, &staging_dir))
.await
.unwrap_or(ScaleAttempt::Unavailable)
}
pub(crate) fn thumbnail_build_status(&self) -> ThumbnailBuildStatus {
self.thumbnail_build.lock().clone()
}
@@ -2454,6 +2756,10 @@ mod decode_budget_tests {
#[cfg(test)]
mod thumb_tests {
use super::{
AUDIO_HEAD_BYTES, AUDIO_TAIL_BYTES, AudioSample, audio_cover_thumbnail, audio_tags_end,
cover_rank, sample_audio,
};
use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
@@ -2965,6 +3271,28 @@ mod thumb_tests {
assert!(thumb.ratio > 0.0);
}
/// Optional check against real songs, through the same partial download a
/// Drive song gets. `PDFS_AUDIO_FIXTURE` lists files separated by `:`.
#[test]
#[ignore = "set PDFS_AUDIO_FIXTURE to songs with a cover in their tags"]
fn real_audio_fixture_yields_its_cover() {
let paths = std::env::var_os("PDFS_AUDIO_FIXTURE").expect("PDFS_AUDIO_FIXTURE");
for path in std::env::split_paths(&paths) {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let bytes = std::fs::read(&path).unwrap();
let revision = FakeRevision::new(bytes.len() as u64).with(0, bytes);
let sample = block_on(sample_audio(&revision, &name)).unwrap();
let read = revision.read.load(Ordering::Relaxed);
let attempt = audio_cover_thumbnail(&sample, &name, &std::env::temp_dir());
assert!(
matches!(attempt, ScaleAttempt::Made(_)),
"{name}: no cover, read {read} of {} bytes",
revision.size
);
eprintln!("{name}: read {read} of {} bytes", revision.size);
}
}
#[test]
fn videos_are_told_apart_by_name() {
for name in [
@@ -3121,4 +3449,201 @@ mod thumb_tests {
"a small one is untouched"
);
}
/// A `width`×`height` PNG, the cover a tag carries.
fn png(width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(image::RgbImage::new(width, height))
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.unwrap();
bytes
}
/// An ID3v2.3 tag holding `picture` as its front cover.
fn id3_with_cover(picture: &[u8]) -> Vec<u8> {
let mut body = vec![0u8];
body.extend_from_slice(b"image/png\0");
body.push(3);
body.push(0);
body.extend_from_slice(picture);
let mut frame = b"APIC".to_vec();
frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
frame.extend_from_slice(&[0, 0]);
frame.extend_from_slice(&body);
let len = frame.len() as u32;
let mut tag = b"ID3\x03\x00\x00".to_vec();
tag.extend((0..4).rev().map(|i| ((len >> (7 * i)) & 0x7f) as u8));
tag.extend_from_slice(&frame);
tag
}
/// Ten silent MPEG-1 Layer III frames at 128 kbit/s and 44.1 kHz.
fn mpeg_frames() -> Vec<u8> {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x64]);
frame.repeat(10)
}
/// A FLAC stream's metadata: STREAMINFO, then `picture` as its front cover.
fn flac_with_cover(picture: &[u8]) -> Vec<u8> {
let mut info = vec![0x10, 0x00, 0x10, 0x00, 0, 0, 0, 0, 0, 0];
let packed: u64 = (44_100 << 44) | (1 << 41) | (15 << 36);
info.extend_from_slice(&packed.to_be_bytes());
info.extend_from_slice(&[0; 16]);
let mut block = Vec::new();
block.extend_from_slice(&3u32.to_be_bytes());
block.extend_from_slice(&9u32.to_be_bytes());
block.extend_from_slice(b"image/png");
block.extend_from_slice(&0u32.to_be_bytes());
for value in [16u32, 8, 24, 0, picture.len() as u32] {
block.extend_from_slice(&value.to_be_bytes());
}
block.extend_from_slice(picture);
let mut out = b"fLaC".to_vec();
out.push(0);
out.extend_from_slice(&(info.len() as u32).to_be_bytes()[1..]);
out.extend_from_slice(&info);
out.push(0x80 | 6);
out.extend_from_slice(&(block.len() as u32).to_be_bytes()[1..]);
out.extend_from_slice(&block);
out
}
fn whole_song(bytes: Vec<u8>) -> AudioSample {
AudioSample {
size: bytes.len() as u64,
parts: vec![(0, bytes)],
}
}
#[test]
fn a_song_shows_the_cover_in_its_tags() {
let tmp = scratch("song-cover");
std::fs::create_dir_all(&tmp).unwrap();
let mut mp3 = id3_with_cover(&png(16, 8));
mp3.extend(mpeg_frames());
let ScaleAttempt::Made(thumb) = audio_cover_thumbnail(&whole_song(mp3), "song.mp3", &tmp)
else {
panic!("an ID3 cover scales");
};
assert!((thumb.ratio - 2.0).abs() < 1e-6);
let ScaleAttempt::Made(thumb) =
audio_cover_thumbnail(&whole_song(flac_with_cover(&png(8, 16))), "song.flac", &tmp)
else {
panic!("a FLAC picture scales");
};
assert!((thumb.ratio - 0.5).abs() < 1e-6);
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn a_song_without_a_cover_is_no_thumbnail_of_its_own() {
let tmp = scratch("song-bare");
std::fs::create_dir_all(&tmp).unwrap();
let attempt = audio_cover_thumbnail(&whole_song(mpeg_frames()), "song.mp3", &tmp);
assert!(matches!(attempt, ScaleAttempt::Undecodable));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn the_start_of_a_song_says_where_its_tags_end() {
let tag = id3_with_cover(&png(4, 4));
assert_eq!(audio_tags_end(&tag), Some(tag.len() as u64));
let flac = flac_with_cover(&png(4, 4));
assert_eq!(audio_tags_end(&flac), Some(flac.len() as u64));
// FLAC after an ID3 tag, as some taggers write it.
let both = [tag.clone(), flac.clone()].concat();
assert_eq!(audio_tags_end(&both), Some(both.len() as u64));
// The picture's block header cut off by the end of the probe, just
// past STREAMINFO: a guess past it.
let cut = &flac[..4 + 4 + 34 + 2];
assert!(audio_tags_end(cut).is_some_and(|end| end > cut.len() as u64));
assert_eq!(audio_tags_end(b"OggS\0\0\0\0"), None);
assert_eq!(
audio_tags_end(b"ID3\x03\x00\x00\x80\0\0\0"),
None,
"not synchsafe"
);
}
#[test]
fn a_large_song_downloads_its_tags_not_its_audio() {
const SIZE: u64 = 300 << 20;
let mut start = id3_with_cover(&png(16, 8));
start.extend(mpeg_frames());
let revision = FakeRevision::new(SIZE).with(0, start);
let sample = block_on(sample_audio(&revision, "long mix.mp3")).unwrap();
let read = revision.read.load(Ordering::Relaxed);
assert!(read < 1 << 20, "read {read} bytes");
let tmp = scratch("song-sparse");
std::fs::create_dir_all(&tmp).unwrap();
let ScaleAttempt::Made(thumb) = audio_cover_thumbnail(&sample, "long mix.mp3", &tmp) else {
panic!("the cover comes from the start alone");
};
assert!((thumb.ratio - 2.0).abs() < 1e-6);
std::fs::remove_dir_all(&tmp).unwrap();
// Ogg says nothing up front: a bounded head. WavPack keeps its tags at
// the end: the tail too.
let revision = FakeRevision::new(SIZE);
block_on(sample_audio(&revision, "live.ogg")).unwrap();
assert!(revision.read.load(Ordering::Relaxed) <= AUDIO_HEAD_BYTES + (64 << 10));
let revision = FakeRevision::new(SIZE);
let sample = block_on(sample_audio(&revision, "live.wv")).unwrap();
assert!(
sample
.parts
.iter()
.any(|(offset, bytes)| *offset == SIZE - AUDIO_TAIL_BYTES
&& bytes.len() as u64 == AUDIO_TAIL_BYTES)
);
}
#[test]
fn an_uploaded_song_carries_its_cover() {
let tmp = scratch("upload-song");
std::fs::create_dir_all(&tmp).unwrap();
// A staging blob has no extension: the format comes from the bytes.
let blob = tmp.join("blob");
let mut mp3 = id3_with_cover(&png(64, 64));
mp3.extend(mpeg_frames());
std::fs::write(&blob, mp3).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
let thumbs = upload_thumbnails_with(&blob, "song.mp3", &tmp, ffmpeg.as_os_str());
let [thumbnail] = thumbs.as_slice() else {
panic!("one thumbnail, got {}", thumbs.len());
};
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
assert_eq!(ratio_of(&thumbnail.content), Some(1.0));
std::fs::write(&blob, mpeg_frames()).unwrap();
assert!(
upload_thumbnails_with(&blob, "song.mp3", &tmp, ffmpeg.as_os_str()).is_empty(),
"a song without a cover uploads bare"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn a_folder_cover_is_found_by_its_usual_names() {
assert_eq!(cover_rank("cover.jpg"), Some(0));
assert_eq!(cover_rank("Cover.JPG"), Some(0));
assert_eq!(cover_rank("folder.png"), Some(1));
assert_eq!(cover_rank("AlbumArtSmall.jpg"), Some(5));
assert!(cover_rank("cover.jpg") < cover_rank("front.webp"));
for name in [
"cover.txt",
"my cover.jpg",
"cover",
"back.jpg",
"cover.jpg.part",
] {
assert_eq!(cover_rank(name), None, "{name}");
}
}
}
+82
View File
@@ -3732,6 +3732,88 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A song with no picture in its tags shows the cover image in its folder,
/// and the daemon tells a following front-end that a thumbnail landed, so
/// a folder on screen fills in without being opened again.
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_song_without_a_cover_shows_its_folders_and_says_so() {
let dir = scratch("song-cover");
std::fs::create_dir_all(&dir).unwrap();
// Silent MPEG-1 Layer III frames, without any tag.
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x64]);
let song = frame.repeat(64);
let mut cover = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut cover)
.encode_image(&image::RgbImage::new(300, 300))
.unwrap();
let drive = FakeDrive::new();
drive.device().write("track one.mp3", &song);
drive.device().write("Cover.jpg", &cover);
let client = drive.client(1, Faults::lan());
let daemon = Daemon::start(&dir.join("daemon"), client).unwrap();
let landed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut subscription = control::subscribe(&daemon.socket).unwrap();
let flag = landed.clone();
std::thread::spawn(move || {
// The first change names every topic: a new subscription catches up.
let mut caught_up = false;
while let Ok(Some(event)) = subscription.next_event() {
if let control::Event::Changed { topics } = event {
if caught_up && topics.contains(&control::Topic::Thumbnails) {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
return;
}
caught_up = true;
}
}
});
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
path: String::new(),
}) else {
panic!("listing");
};
let song = entries
.iter()
.find(|entry| entry.name == "track one.mp3")
.expect("the song is listed");
let items = vec![control::FileThumbRequest {
uid: song.uid.clone(),
modified: song.modified,
name: song.name.clone(),
}];
let Ok(Response::FileThumbGeneration { generation }) =
daemon.request(&Request::ReserveFileThumbGeneration)
else {
panic!("generation");
};
let mut path = None;
let settled = wait_until(Duration::from_secs(60), || {
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
items: items.clone(),
generation,
}) else {
return false;
};
path = replies.first().and_then(|reply| reply.path.clone());
path.is_some()
});
assert!(settled, "the song got no thumbnail");
let thumb = image::load_from_memory(&std::fs::read(path.unwrap()).unwrap()).unwrap();
assert_eq!(thumb.width(), thumb.height(), "the square cover");
assert!(
wait_until(Duration::from_secs(15), || landed
.load(std::sync::atomic::Ordering::SeqCst)),
"no thumbnails change was published"
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// A video in My files gets a thumbnail from a frame of its own, but a
/// recording can be gigabytes: only its start, and an MP4's index, are
/// downloaded for it. A video with a thumbnail from its uploader is not
+1
View File
@@ -63,6 +63,7 @@ scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
.photo-viewer-window { background-color: #111014; }
.media-player-window video { background-color: black; }
.media-player-note { color: rgba(255, 255, 255, 0.5); }
.media-player-note .file-thumbnail { border-radius: 12px; }
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
+4
View File
@@ -128,6 +128,10 @@ fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
if changed(Topic::Shares) {
refresh_invitations_badge(ui, Duration::ZERO);
}
// On any page: a grid, a list or a player may show a tile still blank.
if changed(Topic::Thumbnails) {
refresh_file_thumbnails(ui);
}
let page = ui.stack.visible_child_name();
// A timeline out of sight is read again when the user comes back to it;
// one on screen is left alone (below).
+17 -9
View File
@@ -19,6 +19,9 @@ const SEEK_SHORT: i64 = 5;
/// How much ↑ and ↓ change the volume in the My files player.
const VOLUME_STEP: f64 = 0.1;
/// The edge of a song's cover in the My files player, in logical pixels.
const COVER_SIZE: i32 = 320;
thread_local! {
/// The volume and mute the user last set, applied to the next stream so
/// muting one video doesn't leave the next one blaring. Kept for the run
@@ -202,16 +205,21 @@ fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
wire_player(&video, &gtk_window);
// A song has no picture, which would leave a black window above the
// controls: a note stands in for it.
let note = gtk4::Image::builder()
.icon_name("audio-x-generic-symbolic")
.pixel_size(128)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.can_target(false)
.visible(false)
.build();
// controls: its cover stands in for it, the one its listing tile shows,
// and a note while there is none.
let note = file_thumbnail_widget(COVER_SIZE, 128);
note.set_can_target(false);
note.set_visible(false);
note.add_css_class("media-player-note");
if let Some(cover) = note
.child()
.and_then(|fallback| fallback.next_sibling())
.and_downcast::<gtk4::Picture>()
{
// The whole cover, not a square cut out of it.
cover.set_content_fit(gtk4::ContentFit::Contain);
}
bind_file_thumbnail(ui, &note, entry, true);
let overlay = gtk4::Overlay::new();
overlay.set_child(Some(&video));
overlay.add_overlay(&note);
+114 -6
View File
@@ -11,6 +11,7 @@ use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_fil
const THUMB_BATCH: usize = 32;
const THUMB_CACHE_MAX: usize = 256;
const THUMB_MISSING_MAX: usize = 2048;
const THUMB_BLANK_MAX: usize = 1024;
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
const THUMB_RETRY: Duration = Duration::from_secs(2);
@@ -20,6 +21,9 @@ pub(crate) struct FileThumbnailState {
missing: RefCell<HashSet<String>>,
missing_order: RefCell<VecDeque<String>>,
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
/// Tiles on screen still showing their icon, by key, kept across a
/// cancelled request so [`refresh_file_thumbnails`] can ask for them again.
blank: RefCell<HashMap<String, BlankTile>>,
queue: RefCell<VecDeque<FileThumbRequest>>,
inflight: Cell<bool>,
source: RefCell<Option<glib::SourceId>>,
@@ -27,6 +31,18 @@ pub(crate) struct FileThumbnailState {
generation_loading: Cell<bool>,
}
/// What a tile left blank needs to be asked for again.
struct BlankTile {
request: FileThumbRequest,
pictures: Vec<glib::WeakRef<gtk4::Picture>>,
}
/// Whether a picture registered as blank under `key` still is: bound to that
/// key, not painted, and in a window rather than in a list's spare pool.
fn tile_still_blank(key: &str, bound: &str, painted: bool, attached: bool) -> bool {
bound == key && !painted && attached
}
#[derive(Debug, PartialEq, Eq)]
enum ReplyDisposition {
Ignore,
@@ -55,6 +71,7 @@ impl FileThumbnailState {
missing: RefCell::new(HashSet::new()),
missing_order: RefCell::new(VecDeque::new()),
wanted: RefCell::new(HashMap::new()),
blank: RefCell::new(HashMap::new()),
queue: RefCell::new(VecDeque::new()),
inflight: Cell::new(false),
source: RefCell::new(None),
@@ -88,6 +105,85 @@ impl FileThumbnailState {
}
}
}
fn remember_blank(&self, key: &str, request: &FileThumbRequest, picture: &gtk4::Picture) {
let mut blank = self.blank.borrow_mut();
if blank.len() >= THUMB_BLANK_MAX {
prune_blank(&mut blank);
}
let tile = blank.entry(key.to_string()).or_insert_with(|| BlankTile {
request: request.clone(),
pictures: Vec::new(),
});
if !tile
.pictures
.iter()
.any(|weak| weak.upgrade().as_ref() == Some(picture))
{
tile.pictures.push(picture.downgrade());
}
}
}
/// Drop the tiles that were painted, rebound to another file, or freed.
fn prune_blank(blank: &mut HashMap<String, BlankTile>) {
blank.retain(|key, tile| {
tile.pictures.retain(|weak| {
weak.upgrade().is_some_and(|picture| {
tile_still_blank(
key,
&picture.widget_name(),
picture.is_visible(),
picture.root().is_some(),
)
})
});
!tile.pictures.is_empty()
});
}
/// Ask again for every tile on screen still showing its icon. Sent when the
/// daemon says thumbnails landed, so a folder fills in while a build runs in
/// it, or after its own requests were cancelled, without being opened again.
pub(crate) fn refresh_file_thumbnails(ui: &Rc<Ui>) {
let tiles: Vec<(String, FileThumbRequest, Vec<gtk4::Picture>)> = {
let mut blank = ui.file_thumbs.blank.borrow_mut();
prune_blank(&mut blank);
blank
.iter()
.map(|(key, tile)| {
let pictures = tile.pictures.iter().filter_map(|w| w.upgrade()).collect();
(key.clone(), tile.request.clone(), pictures)
})
.collect()
};
if tiles.is_empty() {
return;
}
for (key, request, pictures) in tiles {
let cached = ui.file_thumbs.textures.borrow().get(&key).cloned();
if let Some(texture) = cached {
for picture in &pictures {
paint(picture, &key, &texture);
}
ui.file_thumbs.blank.borrow_mut().remove(&key);
continue;
}
// A miss from before the thumbnail landed is no verdict any more.
ui.file_thumbs.missing.borrow_mut().remove(&key);
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
let waiters = wanted.entry(key).or_default();
for picture in pictures {
if !waiters.contains(&picture) {
waiters.push(picture);
}
}
drop(wanted);
let mut queue = ui.file_thumbs.queue.borrow_mut();
queue.retain(|item| item.uid != request.uid);
queue.push_back(request);
}
schedule_file_thumbs(ui);
}
/// Abandon every opportunistic ordinary-file thumbnail associated with the
@@ -230,6 +326,12 @@ pub(crate) fn bind_file_thumbnail(
paint(&picture, &key, texture);
return;
}
let request = FileThumbRequest {
uid: entry.uid.clone(),
modified: entry.modified,
name: entry.name.clone(),
};
ui.file_thumbs.remember_blank(&key, &request, &picture);
if ui.file_thumbs.missing.borrow().contains(&key) {
return;
}
@@ -242,11 +344,7 @@ pub(crate) fn bind_file_thumbnail(
.push(picture);
let mut queue = ui.file_thumbs.queue.borrow_mut();
queue.retain(|item| item.uid != entry.uid);
queue.push_back(FileThumbRequest {
uid: entry.uid.clone(),
modified: entry.modified,
name: entry.name.clone(),
});
queue.push_back(request);
drop(queue);
schedule_file_thumbs(ui);
}
@@ -387,6 +485,7 @@ fn flush_file_thumbs(ui: &Rc<Ui>) {
match gtk4::gdk::Texture::from_filename(&path) {
Ok(texture) => {
ui.file_thumbs.store_texture(&key, texture.clone());
ui.file_thumbs.blank.borrow_mut().remove(&key);
if let Some(pictures) =
ui.file_thumbs.wanted.borrow_mut().remove(&key)
{
@@ -447,7 +546,16 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
#[cfg(test)]
mod tests {
use super::{ReplyDisposition, reply_disposition};
use super::{ReplyDisposition, reply_disposition, tile_still_blank};
#[test]
fn only_a_tile_still_showing_its_icon_is_asked_for_again() {
assert!(tile_still_blank("a:1", "a:1", false, true));
assert!(!tile_still_blank("a:1", "a:1", true, true), "painted");
assert!(!tile_still_blank("a:1", "b:1", false, true), "rebound");
assert!(!tile_still_blank("a:1", "a:2", false, true), "file changed");
assert!(!tile_still_blank("a:1", "a:1", false, false), "spare row");
}
fn is_image_name(name: &str) -> bool {
pdfs_core::control::is_thumbnail_image_name(name)
+36
View File
@@ -109,6 +109,7 @@ a live account since.
| Entry | Title |
|---|---|
| [B285](#b285--a-restarted-secret-service-leaves-the-process-on-a-dead-session) | A restarted Secret Service leaves the process on a dead session |
| [B284](#b284--a-keyring-entry-reported-missing-discards-refreshed-session-tokens) | A keyring entry reported missing discards refreshed session tokens |
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
@@ -387,6 +388,41 @@ a live account since.
Newest first.
### B285 — A restarted Secret Service leaves the process on a dead session
**Status:** Fixed (unverified)
**Found:** 2026-11-14, following up [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
(signed out after every cold boot on Fedora 45, which replaced gnome-keyring with oo7). See
[the investigation](ISSUE-78.md#a-restarted-secret-service).
**Where:** `crates/pdfs-core/src/auth.rs`, `with_keyring_entry`
**Repro:** Write the session blob, restart the Secret Service provider
(`gnome-keyring-daemon --replace`, or `oo7-daemon --replace`), then read or write it again from
the same process. `dbus-secret-service-keyring-store` keeps one D-Bus connection and one Secret
Service session for the life of the process, and pdfs installed it once. The session exists only
in the provider's memory, so the new provider answers every `GetSecret` and `SetSecret` with
`org.freedesktop.Secret.Error.NoSession`, for as long as the process runs. A fresh store in the
same process reads the same entry fine. oo7's systemd unit restarts the daemon on failure, and
both providers can be replaced, so the daemon could meet this mid-session. A token refresh after
that point reached only memory and the kernel keyring: B61's retry kept asking on the dead
session, and the next cold boot read the spent refresh token (`InvalidRefreshToken`).
**Fix:** Every keyring call goes through `with_keyring_entry`. A platform failure (which is what
`NoSession` and other D-Bus errors become) logs "the Secret Service failed this process's
connection; reconnecting", drops the store unless another thread already replaced it, and makes
the call once more on a new connection and session. A locked keyring, a dismissed prompt and a
missing entry come back unchanged. Affects the daemon, the app and the tray alike.
**Verified:** `auth::tests::a_dropped_secret_service_session_is_reconnected_and_retried`,
`a_rotation_lands_at_once_after_the_keyring_restarts` and `a_locked_keyring_is_not_reconnected`
on the in-memory store. The ignored `auth::tests::real_secret_service_survives_a_provider_restart`
passes against gnome-keyring 51.1 and oo7 0.7.1 (built from its tag), each in a throwaway session
bus and restarted with `--replace`; it checks that a reconnect actually happened. A standalone
program holding one store, as before the fix, gets `NoSession` on read and write after the same
restart under both providers. Whether the reporter's oo7 restarts during a session is not known.
---
### B284 — A keyring entry reported missing discards refreshed session tokens
**Status:** Fixed (unverified)
+21
View File
@@ -104,6 +104,14 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
thumbnail strip in Photos stays off a video, where it covered the seek bar.
- **Album covers for songs.** A song in My files, in search and in Shared shows its cover as its
thumbnail, and the player shows it above the controls instead of a music note. The cover is the
picture in the song's tags (MP3, FLAC, Ogg, Opus, M4A, WAV, AIFF, WavPack, Monkey's Audio), or
else a `cover`, `folder`, `front` or `album` image (`.jpg`, `.png`, `.webp`) in the same folder.
Only the song's tags are downloaded for it, not the whole song. Songs uploaded from this computer
carry their cover as their thumbnail, so they show it at once on other devices too. A cover
image added to a folder later is picked up once the song changes or the service restarts.
### Changed
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
@@ -116,6 +124,19 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
don't fill the window, also after a zoom or a resize.
- **Signed out after a cold boot on Fedora 45** (issue #78, B284, B285). Fedora 45 replaced the
GNOME keyring with oo7, and two things there could keep a refreshed sign-in from being saved:
oo7 reports the entries of a locked keyring as missing, which the client took for a sign-out,
and a restarted oo7 no longer knew the client's connection, which then failed every save until
the service restarted. The refreshed sign-in lived only in memory, and the next boot found the
spent one. The client now keeps such a sign-in and saves it once the keyring can be read, and
reconnects to a keyring service that restarted.
- **Thumbnails appear in the folder you are looking at as soon as they are made.** During
**Build thumbnails**, the folder on screen kept its file icons until you left it and opened it
again. Its tiles now fill in while the build runs, and the same goes for any tile whose
thumbnail is made while it is on screen.
- **My files comes back after the service was killed for hanging** (B279). A killed service left
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
forever without opening anything, until the mounts were cleared by hand. The service now clears
+38
View File
@@ -58,6 +58,44 @@ can resolve the default alias to a locked collection. Both have fixes in
after the reporter's November 7 retest. Neither can be assigned as this report's cause without
matching runtime evidence.
## A restarted Secret Service
A second path to the same symptom, independent of locking (B285). The keyring store pdfs used
holds one D-Bus connection and one Secret Service session for the whole process, and was
installed once. The session lives only in the provider's memory. When the provider restarts,
the new one answers every `GetSecret` and `SetSecret` on the old session with
`org.freedesktop.Secret.Error.NoSession`, until the pdfs process itself restarts. Reproduced
with both gnome-keyring 51.1 and oo7 0.7.1 using `--replace`.
gnome-keyring practically never restarts within a desktop session. oo7 can:
`oo7-daemon.service` has `Restart=on-failure`, the daemon allows itself to be replaced, and it
exits on a wrong credential password and is D-Bus-activated again. A daemon that met a restarted
provider kept every later token refresh only in memory and the kernel keyring, and a cold boot
then left the spent refresh token in the saved blob.
Now a platform failure drops the store and the call is retried once on a new connection. The
daemon log shows `the Secret Service failed this process's connection; reconnecting` when that
happens. To check whether a machine meets this, look for oo7 restarts in the same boot:
```sh
journalctl --user -b -1 -u oo7-daemon.service | grep -E 'Started|Stopped|Starting oo7|Main process exited'
journalctl --user -b -1 -u proton-drive.service | grep -iE 'keyring|reconnecting|refresh'
```
Reproduce in a throwaway session bus (never against the desktop's keyring; the runtime directory
must be short, as it holds a Unix socket):
```sh
T=$(mktemp -d /tmp/ss.XXXX); mkdir -p $T/data $T/run; chmod 700 $T/run
XDG_DATA_HOME=$T/data XDG_RUNTIME_DIR=$T/run PDFS_SECRET_SERVICE_RESTART_TEST=1 \
PDFS_RESTART_PROVIDER='echo -n pw | gnome-keyring-daemon --replace --unlock --components=secrets >/dev/null' \
dbus-run-session -- sh -c 'echo -n pw | gnome-keyring-daemon --unlock --components=secrets >/dev/null; sleep 1;
cargo test -p pdfs-core real_secret_service -- --ignored'
```
For oo7, start `oo7-daemon --login` with the password on stdin instead, and restart it with
`--login --replace` in the background.
## Reproduce without a Proton account or desktop keyring
The oo7 fixture uses fake tokens, a temporary disk keyring and a private peer-to-peer D-Bus