mod ffmpeg; mod gobject; mod relm; mod zipline; use std::{borrow::Cow, path::PathBuf, time::Duration}; use color_eyre::eyre::{OptionExt, Result, bail}; use gobject::GtkZiplineFolder; use relm::{Dialog, DialogInput, StandaloneDialog, Toast, ToastInput}; use relm4::{ Component, ComponentController, Controller, RelmApp, Sender, adw::{self, prelude::*}, gtk::{self, gio}, loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentParts}, tokio, }; use tracing::Level; use tracing_subscriber::EnvFilter; use urlencoding::Encoded; use zipline::ZiplineFolder; #[derive(Debug, Default, Clone, serde::Deserialize, serde::Serialize)] struct Config { zipline_url: String, zipline_token: String, } impl Config { fn fixed_url(&self) -> String { let mut url = self.zipline_url.clone(); if !url.ends_with("/") { url += "/"; } if url.ends_with("api/") { url.truncate(url.len() - 3); } url } } #[derive(Debug)] enum Message { OpenFilePicker, SetOutFilename(String), SetOutCodec(String), SetMergeTracks(bool), SetFolder(ZiplineFolder), StartTheProcess, Nothing, } #[derive(Debug)] enum ProgressMessage { SetStep(Step), SetTotal(usize), AbsProgress(usize), IncProgress(usize), Finish(String), Error(String), } #[derive(Debug)] enum Step { Waiting, Converting, Uploading, Thumbnail, Shortening, } impl Step { fn button_text(&self) -> &'static str { match self { Step::Waiting => "Send", Step::Converting => "Converting video...", Step::Uploading => "Uploading...", Step::Thumbnail => "Generating thumbnail...", Step::Shortening => "Shortening url...", } } } struct UploadInfo { config: Config, video_path: Option, out_filename: Option, out_codec: ffmpeg::Codec, merge_tracks: bool, folder: Option, } struct Tyrolienne { config: Config, step: Step, progress: usize, total: usize, can_merge: bool, video_path: Option, out_filename: Option, out_codec: ffmpeg::Codec, merge_tracks: bool, folder: Option, dialog: Controller, toast: Controller, } impl Tyrolienne { fn display_video_path(&self) -> Cow<'_, str> { self.video_path .as_ref() .map(|p| p.to_string_lossy()) .unwrap_or(Cow::Borrowed("None")) } fn clone_as_info(&self) -> UploadInfo { UploadInfo { config: self.config.clone(), out_filename: self.out_filename.clone(), out_codec: self.out_codec, merge_tracks: self.can_merge && self.merge_tracks, video_path: self.video_path.clone(), folder: self.folder.clone(), } } } #[relm4::component(async)] impl AsyncComponent for Tyrolienne { type Input = Message; type Output = (); type CommandOutput = ProgressMessage; type Init = Config; view! { adw::ApplicationWindow { set_title: Some("Tyrolienne"), set_default_width: 500, gtk::Box { set_orientation: gtk::Orientation::Vertical, set_spacing: 0, adw::HeaderBar {}, #[local_ref] toast_overlay -> adw::ToastOverlay { gtk::Box { set_orientation: gtk::Orientation::Vertical, set_spacing: 32, set_margin_top: 32, set_margin_bottom: 32, set_margin_start: 32, set_margin_end: 32, gtk::ListBox { set_selection_mode: gtk::SelectionMode::None, set_css_classes: &["boxed-list"], adw::ActionRow { set_activatable: true, set_css_classes: &["property"], set_title: "Video file", #[watch] set_subtitle: &model.display_video_path(), add_suffix = >k::Image { set_margin_end: 8, set_icon_name: Some("document-open-symbolic"), }, connect_activated => Message::OpenFilePicker, }, adw::EntryRow { set_title: "Output file name", connect_changed[sender] => move |e| sender.input(Message::SetOutFilename(e.text().into())), }, adw::ComboRow { set_title: "Output video codec", set_model: Some(>k::StringList::new(&["VP9", "AV1"])), connect_selected_item_notify[sender] => move |r| { if let Some(item) = r .selected_item() .and_then(|i| i.downcast::().ok()) { sender.input(Message::SetOutCodec(item.into())); } } }, adw::SwitchRow { set_title: "Merge audio tracks", #[watch] set_sensitive: model.can_merge, #[watch] set_active: model.can_merge && model.merge_tracks, connect_active_notify[sender] => move |s| sender.input(Message::SetMergeTracks(s.is_active())), }, adw::ComboRow { set_title: "Zipline folder", set_model: Some(&folder_store), set_expression: Some(&folder_expression), connect_selected_item_notify[sender] => move |r| { if let Some(item) = r .selected_item() .and_then(|i| i.downcast::().ok()) { sender.input(Message::SetFolder(item.as_folder())); } }, } }, 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: model.step.button_text(), #[watch] set_sensitive: matches!(model.step, Step::Waiting) && model.video_path.is_some(), set_css_classes: &["suggested-action"], connect_clicked => Message::StartTheProcess, } } } } } } } async fn init( config: Self::Init, root: Self::Root, sender: relm4::AsyncComponentSender, ) -> AsyncComponentParts { let toast_overlay = adw::ToastOverlay::new(); let model = Tyrolienne { config, step: Step::Waiting, progress: 0, total: 1, can_merge: false, video_path: None, out_filename: None, out_codec: ffmpeg::Codec::VP9, merge_tracks: true, folder: None, dialog: Dialog::builder() .launch(toast_overlay.clone()) .forward(sender.input_sender(), |_| Message::Nothing), toast: Toast::builder() .launch(toast_overlay.clone()) .forward(sender.input_sender(), |_| Message::Nothing), }; let folders = match zipline::get_folders(&model.config).await { Ok(v) => v, Err(e) => { model.dialog.emit(DialogInput::Show { heading: "Could not fetch folders".into(), body: e.to_string(), }); Vec::new() } }; let mut gtk_folders = folders .iter() .map(GtkZiplineFolder::from_folder) .collect::>(); gtk_folders.insert(0, GtkZiplineFolder::none()); let folder_store = gio::ListStore::new::(); folder_store.extend_from_slice(>k_folders); let folder_expression = gtk::PropertyExpression::new( GtkZiplineFolder::r#type(), None::<>k::Expression>, "name", ); let widgets = view_output!(); AsyncComponentParts { model, widgets } } fn init_loading_widgets(root: Self::Root) -> Option { relm4::view! { #[local] root { #[name(spinner)] gtk::Spinner { start: (), set_halign: gtk::Align::Center, } } } Some(LoadingWidgets::new(root, spinner)) } async fn update( &mut self, message: Self::Input, sender: relm4::AsyncComponentSender, _root: &Self::Root, ) { match message { Message::Nothing => {} Message::OpenFilePicker => { let file = rfd::AsyncFileDialog::new() .add_filter("Video file", &["mp4", "mkv", "webm"]) .pick_file() .await; if let Some(file) = file { let meta = ffmpeg::get_video_meta(file.path()).await; self.can_merge = meta.map(|m| m.audio_streams == 2).unwrap_or(false); self.video_path = Some(file.path().to_owned()); } } Message::SetOutFilename(name) => { self.out_filename = if name.is_empty() { None } else { Some(name) } } Message::SetOutCodec(codec) => self.out_codec = codec.into(), Message::SetMergeTracks(m) => self.merge_tracks = m, Message::SetFolder(folder) => { self.folder = (folder.id != GtkZiplineFolder::NONE_ID).then_some(folder) } Message::StartTheProcess => { let info = self.clone_as_info(); sender.command(|out, shutdown| { Box::pin( shutdown .register(async move { match the_process(info, &out).await { Ok(url) => out.emit(ProgressMessage::Finish(url)), 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::SetStep(step) => self.step = step, ProgressMessage::SetTotal(total) => { self.progress = 0; self.total = total; } ProgressMessage::AbsProgress(prog) => self.progress = prog, ProgressMessage::IncProgress(prog) => self.progress += prog, ProgressMessage::Finish(url) => { self.step = Step::Waiting; self.progress = 0; self.total = 1; self.toast.emit(ToastInput::ShowAndCopy(url)); } ProgressMessage::Error(e) => { self.step = Step::Waiting; self.progress = 0; self.total = 1; self.dialog.emit(DialogInput::Show { heading: "An error occurred".into(), body: e.to_string(), }); } } } } // TODO app icon fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env().add_directive(Level::INFO.into())) .init(); color_eyre::install()?; match ffmpeg::check().and_then(|_| get_config()) { Ok(config) => RelmApp::new("net.uku3lig.Tyrolienne").run_async::(config), Err(e) => RelmApp::new("net.uku3lig.Tyrolienne").run::(e.to_string()), } Ok(()) } fn get_config() -> Result { let Some(config_dir) = dirs::config_dir() else { bail!("could not get config dir!"); }; std::fs::create_dir_all(&config_dir)?; let config_file_path = config_dir.join("tyrolienne.toml"); let config_contents = match std::fs::read_to_string(&config_file_path) { Ok(str) => str, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let default_cfg = toml::to_string(&Config::default())?; std::fs::write(&config_file_path, &default_cfg)?; bail!("config file was not found, wrote default one at {config_file_path:?}"); } Err(e) => bail!(e), }; let config: Config = toml::from_str(&config_contents)?; if config.zipline_token.is_empty() { bail!("zipline token is empty! please set it at {config_file_path:?}"); } if config.zipline_url.is_empty() { bail!("zipline url is empty! please set it at {config_file_path:?}"); } Ok(config) } async fn the_process(app: UploadInfo, sender: &Sender) -> Result { let Some(ref video_path) = app.video_path else { bail!("No video given!"); }; sender.emit(ProgressMessage::SetStep(Step::Converting)); let video_meta = ffmpeg::get_video_meta(video_path).await?; sender.emit(ProgressMessage::SetTotal(video_meta.duration_us)); let out_path = ffmpeg::convert_video( video_path, app.out_filename, app.out_codec, app.merge_tracks, sender.clone(), ) .await?; sender.emit(ProgressMessage::SetStep(Step::Uploading)); if let Some(folder) = app.folder.as_ref() { tracing::info!("uploading to folder '{}'...", folder.name); } else { tracing::info!("uploading video..."); } let res = zipline::upload_file(&app.config, sender, app.folder.as_ref(), &out_path).await?; let zp_file = &res.files[0]; sender.emit(ProgressMessage::SetStep(Step::Thumbnail)); tracing::info!("recalculating thumbnails..."); zipline::recalc_thumbnails(&app.config).await?; tokio::time::sleep(Duration::from_secs(2)).await; tracing::info!("fetching thumbnail url..."); let res = zipline::get_file_details(&app.config, &zp_file.id).await?; let thumbnail_url = res .thumbnail_url(&app.config) .ok_or_eyre("could not get thumbnail url")?; sender.emit(ProgressMessage::SetStep(Step::Shortening)); let full_url = format!( "https://autocompressor.net/av1?v={}&i={}&w={}&h={}", Encoded(&zp_file.url), Encoded(&thumbnail_url), video_meta.width, video_meta.height, ); let shortened_url = zipline::shorten_url(&app.config, &full_url).await?; Ok(shortened_url) }