495 lines
16 KiB
Rust
495 lines
16 KiB
Rust
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<PathBuf>,
|
|
out_filename: Option<String>,
|
|
out_codec: ffmpeg::Codec,
|
|
merge_tracks: bool,
|
|
folder: Option<ZiplineFolder>,
|
|
}
|
|
|
|
struct Tyrolienne {
|
|
config: Config,
|
|
step: Step,
|
|
progress: usize,
|
|
total: usize,
|
|
can_merge: bool,
|
|
video_path: Option<PathBuf>,
|
|
out_filename: Option<String>,
|
|
out_codec: ffmpeg::Codec,
|
|
merge_tracks: bool,
|
|
folder: Option<ZiplineFolder>,
|
|
dialog: Controller<Dialog>,
|
|
toast: Controller<Toast>,
|
|
}
|
|
|
|
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::<gtk::StringObject>().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::<GtkZiplineFolder>().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<Self>,
|
|
) -> AsyncComponentParts<Self> {
|
|
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::<Vec<_>>();
|
|
gtk_folders.insert(0, GtkZiplineFolder::none());
|
|
|
|
let folder_store = gio::ListStore::new::<GtkZiplineFolder>();
|
|
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<LoadingWidgets> {
|
|
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<Self>,
|
|
_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<Self>,
|
|
_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::<Tyrolienne>(config),
|
|
Err(e) => RelmApp::new("net.uku3lig.Tyrolienne").run::<StandaloneDialog>(e.to_string()),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn get_config() -> Result<Config> {
|
|
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<ProgressMessage>) -> Result<String> {
|
|
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)
|
|
}
|