diff --git a/Cargo.lock b/Cargo.lock index 378b334..98614cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2282,10 +2282,12 @@ version = "0.1.0" dependencies = [ "color-eyre", "dirs", + "futures", "relm4", "reqwest", "rfd", "serde", + "tokio-util", "toml", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 4e84d11..3141852 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,12 @@ edition = "2024" [dependencies] color-eyre = "0.6.4" dirs = "6.0.0" +futures = "0.3.31" relm4 = { version = "0.9.1", features = ["gnome_47", "libadwaita"] } reqwest = { version = "0.12.15", default-features = false, features = ["http2", "charset", "rustls-tls", "json", "multipart", "stream"] } rfd = { version = "0.15.3", default-features = false, features = ["tokio", "xdg-portal"] } serde = { version = "1.0.219", features = ["derive"] } +tokio-util = { version = "0.7.15", features = ["io"] } toml = "0.8.22" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/src/main.rs b/src/main.rs index f6ae7ee..cd0c291 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use color_eyre::eyre::{OptionExt, Result, bail}; use gobject::GtkZiplineFolder; use relm::{Dialog, DialogInput}; use relm4::{ - Component, ComponentController, Controller, RelmApp, + Component, ComponentController, Controller, RelmApp, Sender, adw::{self, prelude::*}, gtk::{self, gio}, prelude::{AsyncComponent, AsyncComponentParts}, @@ -43,14 +43,29 @@ impl Config { enum Message { OpenFilePicker, SetFolder(ZiplineFolder), - LockAndStart, StartTheProcess, Nothing, } +#[derive(Debug)] +enum ProgressMessage { + SetTotal(usize), + Progress(usize), + Finish, + Error(String), +} + +struct UploadInfo { + config: Config, + video_path: Option, + folder: Option, +} + struct Tyrolienne { config: Config, locked: bool, + progress: usize, + total: usize, video_path: Option, folder: Option, dialog: Controller, @@ -63,13 +78,21 @@ impl Tyrolienne { .map(|p| p.to_string_lossy()) .unwrap_or(Cow::Borrowed("None")) } + + fn clone_as_info(&self) -> UploadInfo { + UploadInfo { + config: self.config.clone(), + video_path: self.video_path.clone(), + folder: self.folder.clone(), + } + } } #[relm4::component(async)] impl AsyncComponent for Tyrolienne { type Input = Message; type Output = (); - type CommandOutput = (); + type CommandOutput = ProgressMessage; type Init = Config; view! { @@ -119,12 +142,22 @@ impl AsyncComponent for Tyrolienne { } }, - gtk::Button { - #[watch] - set_label: if model.locked { "Uploading..." } else { "Send" }, - #[watch] - set_sensitive: !model.locked && model.video_path.is_some(), - connect_clicked => Message::LockAndStart, + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 10, + + gtk::ProgressBar { + #[watch] + set_fraction: model.progress as f64 / model.total as f64, + }, + + gtk::Button { + #[watch] + set_label: if model.locked { "Uploading..." } else { "Send" }, + #[watch] + set_sensitive: !model.locked && model.video_path.is_some(), + connect_clicked => Message::StartTheProcess, + } } } } @@ -139,6 +172,8 @@ impl AsyncComponent for Tyrolienne { let model = Tyrolienne { config, locked: false, + progress: 0, + total: 1, video_path: None, folder: None, dialog: Dialog::builder() @@ -199,21 +234,48 @@ impl AsyncComponent for Tyrolienne { self.video_path = Some(file.path().to_owned()); } } - Message::LockAndStart => { - // a little bit convoluted, but i don't really know how to force a view update - // before starting the "long" process - self.locked = true; - sender.input(Message::StartTheProcess); - } Message::StartTheProcess => { - match the_process(self).await { - Ok(url) => tracing::info!("{url}"), - Err(e) => self.dialog.emit(DialogInput::Show { + self.locked = true; + let info = self.clone_as_info(); + sender.command(|out, shutdown| { + Box::pin( + shutdown + .register(async move { + match the_process(info, &out).await { + Ok(url) => { + tracing::info!("{url}"); + out.emit(ProgressMessage::Finish); + } + Err(e) => out.emit(ProgressMessage::Error(e.to_string())), + } + }) + .drop_on_shutdown(), + ) + }); + } + } + } + + async fn update_cmd( + &mut self, + message: Self::CommandOutput, + _sender: relm4::AsyncComponentSender, + _root: &Self::Root, + ) { + match message { + ProgressMessage::SetTotal(total) => self.total = total, + ProgressMessage::Progress(prog) => self.progress += prog, + ProgressMessage::Finish | ProgressMessage::Error(_) => { + self.locked = false; + self.progress = 0; + self.total = 1; + + if let ProgressMessage::Error(e) = message { + self.dialog.emit(DialogInput::Show { heading: "An error occurred".into(), body: e.to_string(), - }), + }); } - self.locked = false; } } } @@ -264,7 +326,7 @@ fn get_config() -> Result { Ok(config) } -async fn the_process(app: &Tyrolienne) -> Result { +async fn the_process(app: UploadInfo, sender: &Sender) -> Result { if let Some(folder) = app.folder.as_ref() { tracing::info!("uploading to folder '{}'...", folder.name); } else { @@ -275,7 +337,7 @@ async fn the_process(app: &Tyrolienne) -> Result { bail!("No video given!"); }; - let res = zipline::upload_file(&app.config, app.folder.as_ref(), video_path).await?; + let res = zipline::upload_file(&app.config, sender, app.folder.as_ref(), video_path).await?; let zp_file = &res.files[0]; tracing::info!("recalculating thumbnails..."); diff --git a/src/zipline.rs b/src/zipline.rs index a39985a..952fc4a 100644 --- a/src/zipline.rs +++ b/src/zipline.rs @@ -1,9 +1,16 @@ use std::{path::Path, sync::LazyLock}; use color_eyre::eyre::{Result, bail}; -use reqwest::{Client, StatusCode, header::AUTHORIZATION, multipart::Form}; +use futures::StreamExt; +use relm4::{Sender, tokio::fs::File}; +use reqwest::{ + Body, Client, StatusCode, + header::AUTHORIZATION, + multipart::{Form, Part}, +}; +use tokio_util::io::ReaderStream; -use crate::Config; +use crate::{Config, ProgressMessage}; static CLIENT: LazyLock = LazyLock::new(Client::new); @@ -42,6 +49,31 @@ impl ZiplineFileInfo { } } +async fn wrap_file(path: &Path, sender: Sender) -> Result { + let file_name = path + .file_name() + .map(|filename| filename.to_string_lossy().into_owned()); + + let file = File::open(path).await?; + let len = file.metadata().await?.len(); + sender.emit(ProgressMessage::SetTotal(len as usize)); + + let stream = ReaderStream::new(file).map(move |b| { + if let Ok(ref bytes) = b { + sender.emit(ProgressMessage::Progress(bytes.len())); + } + b + }); + + let field = Part::stream_with_length(Body::wrap_stream(stream), len).mime_str("video/webm")?; + + Ok(if let Some(file_name) = file_name { + field.file_name(file_name) + } else { + field + }) +} + pub async fn get_folders(config: &Config) -> Result> { let url = format!("{}api/user/folders?noincl=true", config.fixed_url()); @@ -64,14 +96,14 @@ pub async fn get_folders(config: &Config) -> Result> { pub async fn upload_file( config: &Config, + sender: &Sender, folder: Option<&ZiplineFolder>, file_path: &Path, ) -> Result { let url = format!("{}api/upload", config.fixed_url()); - // TODO use Part::stream to provide a wrapped file with a custom stream impl to send progress - // (i hope it works) - let form = Form::new().file("file", file_path).await?; + let wrapped_file = wrap_file(file_path, sender.clone()).await?; + let form = Form::new().part("file", wrapped_file); let mut req = CLIENT .post(url)