Rewrite Curator in Rust with an egui GUI

This commit is contained in:
2026-10-17 11:28:17 +02:00
parent 364b515e3c
commit 65b5b74c9f
156 changed files with 24359 additions and 25795 deletions
View File
+2001
View File
File diff suppressed because it is too large Load Diff
+474
View File
@@ -0,0 +1,474 @@
//! Building blocks shared by the main window and the dialogs: message boxes,
//! small prompts, background jobs and formatting helpers.
use std::path::Path;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::thread;
use egui::{Color32, Id, RichText, Ui};
use crate::core::movie::normalize_3d_label;
use crate::core::naming::THREE_D_FORMATS;
pub const RED: Color32 = Color32::from_rgb(0xc0, 0x39, 0x2b);
pub const BLUE: Color32 = Color32::from_rgb(0x29, 0x80, 0xb9);
pub const GREEN: Color32 = Color32::from_rgb(0x27, 0xae, 0x60);
pub const GREY: Color32 = Color32::from_rgb(0x88, 0x88, 0x88);
/// Human-readable size (`1.5 GB`).
pub fn format_size(bytes: u64) -> String {
let mut size = bytes as f64;
for unit in ["B", "KB", "MB", "GB"] {
if size < 1024.0 {
return format!("{size:.1} {unit}");
}
size /= 1024.0;
}
format!("{size:.1} TB")
}
/// "verze" / "verzí" by count.
pub fn versions_word(count: usize) -> &'static str {
if (2..=4).contains(&count) {
"verze"
} else {
"verzí"
}
}
/// Bullet list of at most `limit` items with an "… a další (n)" tail.
pub fn bullet_list(items: &[String], limit: usize) -> String {
let mut s: String = items
.iter()
.take(limit)
.map(|i| format!(" • {i}\n"))
.collect();
if items.len() > limit {
s.push_str(&format!(" … a další ({})\n", items.len() - limit));
}
s.trim_end().to_string()
}
/// Open a file with the desktop's default application.
pub fn open_path(path: &Path) -> Result<(), String> {
let result = if cfg!(target_os = "windows") {
std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.spawn()
} else if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(path).spawn()
} else {
std::process::Command::new("xdg-open").arg(path).spawn()
};
result
.map(|_| ())
.map_err(|e| format!("Nelze otevřít {}: {e}", path.display()))
}
/// Keep only ASCII digits, at most `max` of them (a `\d{0,max}` validator).
pub fn digits_only(text: &mut String, max: usize) {
let cleaned: String = text
.chars()
.filter(char::is_ascii_digit)
.take(max)
.collect();
if *text != cleaned {
*text = cleaned;
}
}
/// Add `widget` with a fixed width (inside a grid egui would otherwise shrink a
/// text field to the column's width from the previous frame).
pub fn sized(ui: &mut Ui, width: f32, widget: impl egui::Widget) -> egui::Response {
let height = ui.spacing().interact_size.y;
ui.add_sized([width, height], widget)
}
/// A single-line text field restricted to digits.
pub fn digit_edit(
ui: &mut Ui,
text: &mut String,
max: usize,
width: f32,
hint: &str,
) -> egui::Response {
let resp = sized(ui, width, egui::TextEdit::singleline(text).hint_text(hint));
digits_only(text, max);
resp
}
// ---------------------------------------------------------------------------
// Message boxes
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Info,
Warning,
}
/// An informational / warning popup, optionally with collapsible details.
#[derive(Debug, Clone)]
pub struct Message {
pub level: Level,
pub title: String,
pub text: String,
pub details: Option<String>,
}
impl Message {
pub fn info(title: impl Into<String>, text: impl Into<String>) -> Self {
Self {
level: Level::Info,
title: title.into(),
text: text.into(),
details: None,
}
}
pub fn warning(title: impl Into<String>, text: impl Into<String>) -> Self {
Self {
level: Level::Warning,
title: title.into(),
text: text.into(),
details: None,
}
}
pub fn with_details(mut self, details: impl Into<String>) -> Self {
let d = details.into();
if !d.trim().is_empty() {
self.details = Some(d);
}
self
}
/// Show it; `true` once the user closed it.
pub fn show(&self, ctx: &egui::Context, id: Id) -> bool {
let mut closed = false;
let resp = egui::Modal::new(id).show(ctx, |ui| {
ui.set_max_width(560.0);
let icon = match self.level {
Level::Info => "ℹ",
Level::Warning => "⚠",
};
ui.heading(format!("{icon} {}", self.title));
ui.add_space(6.0);
ui.label(&self.text);
if let Some(details) = &self.details {
ui.add_space(6.0);
egui::CollapsingHeader::new("Podrobnosti")
.id_salt(id.with("details"))
.show(ui, |ui| {
egui::ScrollArea::vertical()
.max_height(320.0)
.show(ui, |ui| {
ui.add(
egui::Label::new(RichText::new(details).monospace())
.selectable(true),
);
});
});
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("OK").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
closed = true;
}
});
});
closed || resp.should_close()
}
}
// ---------------------------------------------------------------------------
// Prompts (small modal questions owned by a dialog or the main window)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub enum PromptKind {
/// Yes / No.
Confirm,
/// A free text answer.
Text { value: String },
/// Pick one option (optionally typing a new one).
Choice {
options: Vec<String>,
selected: usize,
editable: bool,
typed: String,
},
/// A row of custom buttons; the answer is the button index.
Buttons(Vec<String>),
/// A version popisek: a 3D preset or free text.
VersionLabel {
preset: Option<String>,
label: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum Answer {
Cancel,
Yes,
Text(String),
Choice(usize, String),
Button(usize),
}
/// A modal question carrying a `purpose` the owner acts on when answered.
#[derive(Debug, Clone)]
pub struct Prompt<T> {
pub title: String,
pub text: String,
pub kind: PromptKind,
pub purpose: T,
}
impl<T: Clone> Prompt<T> {
pub fn confirm(title: impl Into<String>, text: impl Into<String>, purpose: T) -> Self {
Self {
title: title.into(),
text: text.into(),
kind: PromptKind::Confirm,
purpose,
}
}
pub fn text(
title: impl Into<String>,
text: impl Into<String>,
value: impl Into<String>,
purpose: T,
) -> Self {
Self {
title: title.into(),
text: text.into(),
kind: PromptKind::Text {
value: value.into(),
},
purpose,
}
}
pub fn choice(
title: impl Into<String>,
text: impl Into<String>,
options: Vec<String>,
editable: bool,
purpose: T,
) -> Self {
let typed = options.first().cloned().unwrap_or_default();
Self {
title: title.into(),
text: text.into(),
kind: PromptKind::Choice {
options,
selected: 0,
editable,
typed,
},
purpose,
}
}
pub fn buttons(
title: impl Into<String>,
text: impl Into<String>,
buttons: Vec<String>,
purpose: T,
) -> Self {
Self {
title: title.into(),
text: text.into(),
kind: PromptKind::Buttons(buttons),
purpose,
}
}
/// The version-label prompt, pre-filled from `initial` (a 3D preset or text).
pub fn version_label(intro: impl Into<String>, initial: &str, purpose: T) -> Self {
let preset = normalize_3d_label(initial);
let label = if preset.is_some() {
format!("3D {}", preset.clone().unwrap_or_default())
} else {
initial.to_string()
};
Self {
title: "Popisek verze".into(),
text: intro.into(),
kind: PromptKind::VersionLabel { preset, label },
purpose,
}
}
/// Show it; `Some((purpose, answer))` once answered.
pub fn show(&mut self, ctx: &egui::Context, id: Id) -> Option<(T, Answer)> {
let mut answer = None;
let resp = egui::Modal::new(id).show(ctx, |ui| {
ui.set_max_width(520.0);
ui.heading(&self.title);
ui.add_space(4.0);
if !self.text.is_empty() {
ui.label(&self.text);
ui.add_space(4.0);
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
match &mut self.kind {
PromptKind::Confirm => {
ui.horizontal(|ui| {
if ui.button("Ano").clicked() || enter {
answer = Some(Answer::Yes);
}
if ui.button("Ne").clicked() {
answer = Some(Answer::Cancel);
}
});
}
PromptKind::Text { value } => {
let r = crate::ui::common::sized(ui, 420.0, egui::TextEdit::singleline(value));
r.request_focus();
ok_cancel(ui, enter, &mut answer, || Answer::Text(value.trim().to_string()));
}
PromptKind::Choice { options, selected, editable, typed } => {
if *editable {
crate::ui::common::sized(ui, 300.0, egui::TextEdit::singleline(typed));
}
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (i, opt) in options.iter().enumerate() {
if ui.selectable_label(*selected == i, opt).clicked() {
*selected = i;
*typed = opt.clone();
}
}
});
let (sel, text) = (*selected, if *editable { typed.trim().to_string() } else { options.get(*selected).cloned().unwrap_or_default() });
ok_cancel(ui, enter, &mut answer, || Answer::Choice(sel, text));
}
PromptKind::Buttons(buttons) => {
ui.horizontal_wrapped(|ui| {
for (i, b) in buttons.iter().enumerate() {
if ui.button(b).clicked() {
answer = Some(Answer::Button(i));
}
}
});
}
PromptKind::VersionLabel { preset, label } => {
ui.label("Jak tuhle verzi Jellyfin rozliší od ostatních (uvidí ji v přehrávači jako volbu).");
egui::Grid::new(id.with("vl")).num_columns(2).show(ui, |ui| {
ui.label("3D formát:");
let before = preset.clone();
egui::ComboBox::from_id_salt(id.with("fmt"))
.selected_text(preset.as_ref().map_or("— vlastní popisek —".to_string(), |f| format!("3D {f}")))
.show_ui(ui, |ui| {
ui.selectable_value(preset, None, "— vlastní popisek —");
for f in THREE_D_FORMATS {
ui.selectable_value(preset, Some(f.to_string()), format!("3D {f}"));
}
});
if *preset != before {
*label = preset.as_ref().map(|f| format!("3D {f}")).unwrap_or_default();
}
ui.end_row();
ui.label("Popisek:");
ui.add_enabled_ui(preset.is_none(), |ui| {
sized(
ui,
320.0,
egui::TextEdit::singleline(label).hint_text(
"např. 4K, Director's Cut (prázdné = obyčejná 2D verze, Výchozí)",
),
)
});
ui.end_row();
});
let value = label.trim().to_string();
ok_cancel(ui, enter, &mut answer, || Answer::Text(value));
}
}
});
if answer.is_none() && resp.should_close() {
answer = Some(Answer::Cancel);
}
answer.map(|a| (self.purpose.clone(), a))
}
}
fn ok_cancel(ui: &mut Ui, enter: bool, answer: &mut Option<Answer>, ok: impl FnOnce() -> Answer) {
ui.add_space(6.0);
ui.horizontal(|ui| {
let ok_clicked = ui.button("OK").clicked() || enter;
if ui.button("Zrušit").clicked() {
*answer = Some(Answer::Cancel);
} else if ok_clicked {
*answer = Some(ok());
}
});
}
// ---------------------------------------------------------------------------
// Background jobs
// ---------------------------------------------------------------------------
/// A closure running on a worker thread; poll it every frame.
pub struct Job<T> {
rx: Receiver<T>,
}
impl<T: Send + 'static> Job<T> {
pub fn spawn(ctx: &egui::Context, f: impl FnOnce() -> T + Send + 'static) -> Self {
let (tx, rx) = channel();
let ctx = ctx.clone();
thread::spawn(move || {
let _ = tx.send(f());
ctx.request_repaint();
});
Self { rx }
}
/// The result once ready. A crashed worker yields `None` forever, so callers
/// treat a disconnected job as done via [`Job::finished`].
pub fn poll(&self) -> Option<T> {
self.rx.try_recv().ok()
}
pub fn finished(&self) -> bool {
matches!(self.rx.try_recv(), Err(TryRecvError::Disconnected))
}
}
/// A button that is disabled while `busy` and shows a spinner next to it.
pub fn busy_button(ui: &mut Ui, busy: bool, text: &str) -> bool {
let clicked = ui.add_enabled(!busy, egui::Button::new(text)).clicked();
if busy {
ui.spinner();
}
clicked
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_size() {
assert_eq!(format_size(0), "0.0 B");
assert_eq!(format_size(1536), "1.5 KB");
assert_eq!(format_size(5 * 1024 * 1024 * 1024), "5.0 GB");
}
#[test]
fn test_digits_only() {
let mut s = "19a99x9".to_string();
digits_only(&mut s, 4);
assert_eq!(s, "1999");
}
#[test]
fn test_bullet_list() {
let items: Vec<String> = (1..=3).map(|i| i.to_string()).collect();
assert_eq!(bullet_list(&items, 2), " • 1\n • 2\n … a další (1)");
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Tri-state bulk tag assignment over the selected movies.
use std::collections::HashSet;
use std::path::PathBuf;
use super::{DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::file_manager::FileManager;
use crate::ui::common::{Answer, Prompt};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Checked,
Unchecked,
/// Some of the movies have it — left unchanged.
Mixed,
}
#[derive(Debug, Clone)]
enum Purpose {
Category,
Name(String),
}
pub struct AssignTagsDialog {
files: Vec<PathBuf>,
file_tags: Vec<HashSet<String>>,
/// `(category, [(name, state)])` in display order.
tree: Vec<(String, Vec<(String, State)>)>,
prompt: Option<Prompt<Purpose>>,
}
impl AssignTagsDialog {
pub fn new(fm: &FileManager, files: Vec<PathBuf>) -> Self {
let file_tags: Vec<HashSet<String>> = files
.iter()
.map(|p| {
fm.file(p)
.map(|f| f.tag_paths().into_iter().collect())
.unwrap_or_default()
})
.collect();
let mut d = Self {
files,
file_tags,
tree: Vec::new(),
prompt: None,
};
// schema categories (incl. empty ones) ∪ categories that have tags
let mut categories: Vec<String> = fm.tag_schema().into_iter().map(|e| e.category).collect();
let mut known = fm.tagmanager.categories();
known.sort();
for c in known {
if !categories.contains(&c) {
categories.push(c);
}
}
for c in categories {
d.category(&c);
for t in fm.tagmanager.tags_in_category(&c) {
d.add_tag(&c, &t.name, false);
}
}
d
}
fn category(&mut self, category: &str) -> usize {
if let Some(i) = self.tree.iter().position(|(c, _)| c == category) {
return i;
}
self.tree.push((category.to_string(), Vec::new()));
self.tree.len() - 1
}
fn add_tag(&mut self, category: &str, name: &str, force_checked: bool) {
let full = format!("{category}/{name}");
let ci = self.category(category);
if let Some(entry) = self.tree[ci].1.iter_mut().find(|(n, _)| n == name) {
if force_checked {
entry.1 = State::Checked;
}
return;
}
let have = self.file_tags.iter().filter(|s| s.contains(&full)).count();
let state = if force_checked || (have == self.file_tags.len() && have > 0) {
State::Checked
} else if have == 0 {
State::Unchecked
} else {
State::Mixed
};
self.tree[ci].1.push((name.to_string(), state));
}
fn apply(&self, env: &mut Env) {
for (cat, tags) in &self.tree {
for (name, state) in tags {
let full = format!("{cat}/{name}");
let r = match state {
State::Checked => env.fm.assign_tag(&self.files, &full),
State::Unchecked => env.fm.remove_tag(&self.files, &full),
State::Mixed => Ok(()),
};
if let Err(e) = r {
env.warn("Štítky", e.to_string());
return;
}
}
}
env.events.push(Event::Refresh);
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut ok = None;
let mut add = false;
let (_, close) = modal(ctx, "assign_tags", "Přiřadit štítky", 380.0, |ui| {
ui.label(format!("Vybráno filmů: {}", self.files.len()));
egui::ScrollArea::vertical()
.max_height(420.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (cat, tags) in &mut self.tree {
egui::CollapsingHeader::new(cat.as_str())
.default_open(true)
.show(ui, |ui| {
for (name, state) in tags.iter_mut() {
let mut on = *state == State::Checked;
let resp = ui.add(
egui::Checkbox::new(&mut on, name.as_str())
.indeterminate(*state == State::Mixed),
);
if resp.clicked() {
// checked → unchecked → (mixed is only an initial state)
*state = if *state == State::Checked {
State::Unchecked
} else {
State::Checked
};
}
}
});
}
});
add = ui.button("➕ Nový štítek…").clicked();
ok = ok_cancel_row(ui);
});
if add {
let known: Vec<String> = self.tree.iter().map(|(c, _)| c.clone()).collect();
self.prompt = Some(Prompt::choice(
"Nový štítek",
"Kategorie:",
known,
true,
Purpose::Category,
));
}
if let Some(mut p) = self.prompt.take() {
match p.show(ctx, egui::Id::unique("tags_prompt")) {
Some((Purpose::Category, Answer::Choice(_, cat))) if !cat.trim().is_empty() => {
let cat = cat.trim().to_string();
self.prompt = Some(Prompt::text(
"Nový štítek",
format!("Název štítku v kategorii „{cat}“:"),
"",
Purpose::Name(cat),
));
}
Some((Purpose::Name(cat), Answer::Text(name))) if !name.trim().is_empty() => {
self.add_tag(&cat, name.trim(), true)
}
Some(_) => {}
None => self.prompt = Some(p),
}
}
match ok {
Some(true) => {
self.apply(env);
DialogState::Closed
}
Some(false) => DialogState::Closed,
None if close && self.prompt.is_none() => DialogState::Closed,
None => DialogState::Open,
}
}
}
+231
View File
@@ -0,0 +1,231 @@
//! Nástroje → Kontrola duplicit: movies present more than once, with merge
//! (as a version of another one) and delete.
use std::path::{Path, PathBuf};
use egui::RichText;
use super::{DialogState, Env, Event, modal};
use crate::core::file_manager::{DuplicateGroup, FileManager};
use crate::core::naming::parse_3d_format;
use crate::core::paths;
use crate::ui::common::{Answer, Prompt, format_size};
#[derive(Debug, Clone)]
enum Purpose {
PickKeep(PathBuf, Vec<PathBuf>),
Label { keep: PathBuf, other: PathBuf },
Remove(PathBuf),
}
pub struct DuplicatesDialog {
groups: Vec<DuplicateGroup>,
selected: Option<(usize, usize)>,
prompt: Option<Prompt<Purpose>>,
}
fn display_name(fm: &FileManager, p: &Path) -> String {
fm.file(p)
.and_then(|f| f.title.clone())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| paths::name(p))
}
impl DuplicatesDialog {
pub fn new(fm: &FileManager) -> Self {
Self {
groups: fm.find_duplicates(),
selected: None,
prompt: None,
}
}
fn reload(&mut self, fm: &FileManager) {
self.groups = fm.find_duplicates();
self.selected = None;
}
fn start_merge(&mut self, env: &mut Env) {
let Some((gi, mi)) = self.selected else {
return;
};
let group = &self.groups[gi];
let other = group.movies[mi].clone();
let candidates: Vec<PathBuf> = group
.movies
.iter()
.filter(|m| **m != other)
.cloned()
.collect();
if candidates.len() == 1 {
self.ask_label(env.fm, candidates[0].clone(), other);
} else {
self.prompt = Some(Prompt::choice(
"Sloučit",
"Do kterého filmu přidat jako verzi?",
candidates.iter().map(|c| paths::name(c)).collect(),
false,
Purpose::PickKeep(other, candidates),
));
}
}
fn ask_label(&mut self, fm: &FileManager, keep: PathBuf, other: PathBuf) {
let fmt = parse_3d_format(&paths::stem(&other));
self.prompt = Some(Prompt::version_label(
format!(
"„{}“ → verze filmu „{}“.",
paths::name(&other),
display_name(fm, &keep)
),
&fmt.map(|f| format!("3D {f}")).unwrap_or_default(),
Purpose::Label { keep, other },
));
}
fn handle(&mut self, purpose: Purpose, answer: Answer, env: &mut Env) {
match (purpose, answer) {
(Purpose::PickKeep(other, candidates), Answer::Choice(i, _)) => {
if let Some(keep) = candidates.get(i).cloned() {
self.ask_label(env.fm, keep, other);
}
}
(Purpose::Label { keep, other }, Answer::Text(label)) => {
match env.fm.merge_as_version(&keep, &other, &label, None) {
Ok(_) => {
env.events.push(Event::Refresh);
self.reload(env.fm);
}
Err(e) => env.warn("Sloučení se nezdařilo", e.to_string()),
}
}
(Purpose::Remove(p), Answer::Yes) => {
if let Err(e) = env.fm.remove_movie(&p) {
env.warn("Chyba", e.to_string());
return;
}
env.events.push(Event::Refresh);
self.reload(env.fm);
}
_ => {}
}
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut act = None;
let fm: &FileManager = env.fm;
let (_, close) = modal(ctx, "duplicates", "Kontrola duplicit", 860.0, |ui| {
if self.groups.is_empty() {
ui.label("✅ Žádné duplicity — každý film je v poolu jen jednou.");
} else {
ui.add(egui::Label::new(format!(
"Nalezeno {} skupin možných duplicit. Vyber soubor a buď ho slouč jako verzi do druhého filmu (např. 2D + 3D), nebo smaž, pokud je to jen kopie.",
self.groups.len()
)).wrap());
}
ui.add_space(6.0);
egui::ScrollArea::vertical()
.max_height(420.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (gi, g) in self.groups.iter().enumerate() {
let first = display_name(fm, &g.movies[0]);
ui.label(
RichText::new(format!("{first} — {}", g.reasons.join(", "))).strong(),
);
egui::Grid::new(("dup", gi))
.striped(true)
.num_columns(4)
.show(ui, |ui| {
for (mi, m) in g.movies.iter().enumerate() {
let versions = fm.list_movie_versions(m);
let grouped = fm.is_movie_group_member(m);
let name = if grouped {
format!(
"{}/",
m.parent().map(paths::name).unwrap_or_default()
)
} else {
paths::name(m)
};
let size: u64 = versions
.iter()
.map(|v| std::fs::metadata(v).map(|x| x.len()).unwrap_or(0))
.sum();
let r = ui.selectable_label(
self.selected == Some((gi, mi)),
format!(" {name}"),
);
if r.clicked() {
self.selected = Some((gi, mi));
}
r.on_hover_text(
versions
.iter()
.map(|v| v.display().to_string())
.collect::<Vec<_>>()
.join("\n"),
);
ui.label(fm.library_of(m).unwrap_or_else(|| "—".into()));
ui.label(format!("{} verzí", versions.len()));
ui.label(format_size(size));
ui.end_row();
}
});
ui.add_space(4.0);
}
});
ui.separator();
ui.horizontal(|ui| {
let picked = self.selected.is_some();
if ui
.add_enabled(picked, egui::Button::new("Sloučit jako verzi do…"))
.on_hover_text("Přesune vybraný soubor do složky druhého filmu jako další verzi; metadata druhého filmu zůstanou, štítky a ČSFD odkaz se doplní.")
.clicked()
{
act = Some("merge");
}
if ui.add_enabled(picked, egui::Button::new("Smazat vybraný z poolu…")).clicked() {
act = Some("remove");
}
if ui.button("Zavřít").clicked() {
act = Some("close");
}
});
});
match act {
Some("close") => return DialogState::Closed,
Some("merge") => self.start_merge(env),
Some("remove") => {
if let Some((gi, mi)) = self.selected {
let m = self.groups[gi].movies[mi].clone();
let n = env.fm.list_movie_versions(&m).len();
let extra = if n > 1 {
format!(" (všech {n} verzí)")
} else {
String::new()
};
self.prompt = Some(Prompt::confirm(
"Smazat z poolu",
format!(
"Smazat „{}“{extra} z poolu včetně souboru, titulků a metadat?",
paths::name(&m)
),
Purpose::Remove(m),
));
}
}
_ => {}
}
if let Some(mut p) = self.prompt.take() {
match p.show(ctx, egui::Id::unique("dup_prompt")) {
Some((purpose, answer)) => self.handle(purpose, answer, env),
None => self.prompt = Some(p),
}
}
if close && self.prompt.is_none() {
return DialogState::Closed;
}
DialogState::Open
}
}
+377
View File
@@ -0,0 +1,377 @@
//! Import episode files into a series (`Series/Season NN/SxxEyy - Series.ext`).
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use super::{DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::file_manager::{EpisodeCode, OnConflict};
use crate::core::naming::{parse_pool_stem, parse_tmdb_id};
use crate::core::paths;
use crate::core::series::{
collect_episode_files, parse_episode, parse_episode_code, parse_episode_part,
};
use crate::core::tmdb::{self, TmdbResult};
use crate::ui::common::{
Answer, GREEN, Job, Message, Prompt, RED, bullet_list, busy_button, digit_edit, sized,
};
use crate::ui::task::Outcome;
struct Row {
source: PathBuf,
code: String,
}
impl Row {
fn new(source: PathBuf) -> Self {
let name = paths::name(&source);
let code = match parse_episode(&name) {
Some((s, e)) => {
let mut c = format!("S{s:02}E{e:02}");
if let Some(p) = parse_episode_part(&name) {
c.push_str(&format!("P{p}"));
}
c
}
None => String::new(),
};
Self { source, code }
}
}
pub struct ImportEpisodesDialog {
title: String,
year: String,
tmdb: String,
libraries: Vec<String>,
library: String,
rows: Vec<Row>,
move_source: bool,
job: Option<Job<Result<Option<TmdbResult>, String>>>,
confirm: Option<Prompt<()>>,
}
impl ImportEpisodesDialog {
pub fn new(libraries: Vec<String>) -> Self {
let libraries = if libraries.is_empty() {
vec!["Seriály".to_string()]
} else {
libraries
};
Self {
title: String::new(),
year: String::new(),
tmdb: String::new(),
library: libraries[0].clone(),
libraries,
rows: Vec::new(),
move_source: false,
job: None,
confirm: None,
}
}
fn add_folder(&mut self, env: &mut Env) {
let Some(folder) = rfd::FileDialog::new()
.set_title("Vyber složku se seriálem")
.pick_folder()
else {
return;
};
let have: HashSet<PathBuf> = self.rows.iter().map(|r| r.source.clone()).collect();
let new: Vec<PathBuf> = collect_episode_files(&folder)
.into_iter()
.filter(|p| !have.contains(p))
.collect();
if new.is_empty() {
env.info(
"Import epizod",
"Ve složce (ani podsložkách) nejsou žádné nové video soubory.",
);
return;
}
self.rows.extend(new.into_iter().map(Row::new));
let name = paths::name(&folder);
if self.title.trim().is_empty() {
match parse_pool_stem(&name) {
Some((t, y)) => {
self.title = t;
if self.year.trim().is_empty() {
self.year = y.to_string();
}
}
None => self.title = name.clone(),
}
}
if let Some(id) = parse_tmdb_id(&name)
&& self.tmdb.trim().is_empty()
{
self.tmdb = id.to_string();
}
}
fn start_lookup(&mut self, ctx: &egui::Context, env: &mut Env) {
if !tmdb::api_available() {
env.warn("TMDb", "Není nastaven TMDB_API_KEY (.env).");
return;
}
let (title, year, id) = (
self.title.clone(),
self.year.trim().to_string(),
self.tmdb.trim().to_string(),
);
self.job = Some(Job::spawn(ctx, move || {
if !id.is_empty() {
id.parse()
.map_err(|_| "Neplatné ID".to_string())
.and_then(|n| tmdb::lookup(n, tmdb::Kind::Tv).map_err(|e| e.to_string()))
} else {
tmdb::search_tv(&title, Some(year.as_str()).filter(|y| !y.is_empty()), 1)
.map(|h| h.into_iter().next())
.map_err(|e| e.to_string())
}
}));
}
fn codes(&self) -> HashMap<PathBuf, EpisodeCode> {
self.rows
.iter()
.filter_map(|r| Some((r.source.clone(), parse_episode_code(r.code.trim())?)))
.collect()
}
fn start_import(&self, env: &mut Env) {
let sources: Vec<PathBuf> = self.rows.iter().map(|r| r.source.clone()).collect();
let codes = self.codes();
let (title, year, library, move_source) = (
self.title.trim().to_string(),
self.year.trim().to_string(),
self.library.clone(),
self.move_source,
);
let tmdb_id: Option<i64> = self.tmdb.trim().parse().ok();
env.events.push(Event::Task {
title: "Importuji epizody".into(),
work: Box::new(move |fm, progress| {
progress.set(0, sources.len(), "Kopíruji…");
let year = Some(year.as_str()).filter(|y| !y.is_empty());
match fm.import_episodes(
&sources,
&title,
year,
tmdb_id,
Some(&library),
move_source,
OnConflict::Suffix,
&codes,
) {
Ok(r) => {
let msg = format!(
"Naimportováno {} epizod. Nerozpoznáno (bez SxxEyy): {}.",
r.imported,
r.skipped_no_code.len()
);
let m = if r.errors.is_empty() {
Message::info("Import epizod", msg.clone())
} else {
Message::warning(
"Import epizod s chybami",
format!(
"{msg}\n\nChyby:\n{}",
r.errors
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join("\n")
),
)
};
Outcome {
messages: vec![m],
status: Some(msg),
reload_series: true,
select: Vec::new(),
}
}
Err(e) => Outcome {
messages: vec![Message::warning("Import epizod", e.to_string())],
..Default::default()
},
}
}),
});
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
if let Some(job) = &self.job {
if let Some(res) = job.poll() {
self.job = None;
match res {
Err(e) => env.warn("TMDb", e),
Ok(None) => env.info("TMDb", "Nic nenalezeno."),
Ok(Some(hit)) => {
if self.tmdb.trim().is_empty() {
self.tmdb = hit.tmdb_id.to_string();
}
self.title = hit.title;
if let Some(y) = hit.year {
self.year = y;
}
}
}
} else if job.finished() {
self.job = None;
}
}
let busy = self.job.is_some();
let mut ok = None;
let mut remove = None;
let mut add_files = false;
let mut add_folder = false;
let mut lookup = false;
let (_, close) = modal(
ctx,
"import_episodes",
"Importovat epizody seriálu",
760.0,
|ui| {
ui.horizontal(|ui| {
ui.label("Seriál:");
sized(
ui,
260.0,
egui::TextEdit::singleline(&mut self.title).hint_text("Název seriálu"),
);
ui.label("Rok:");
digit_edit(ui, &mut self.year, 4, 48.0, "rok");
ui.label("TMDb ID:");
digit_edit(ui, &mut self.tmdb, 9, 80.0, "TV ID");
lookup = busy_button(ui, busy, "🔎 Dohledat");
});
ui.horizontal(|ui| {
ui.label("Knihovna:");
egui::ComboBox::from_id_salt("ep_library")
.selected_text(self.library.clone())
.show_ui(ui, |ui| {
for l in &self.libraries {
ui.selectable_value(&mut self.library, l.clone(), l);
}
});
});
ui.add(egui::Label::new(
"Epizody se uloží jako „SxxEyy - Název.ext“ do Season složek. Číslo sezóny/epizody se předvyplní z názvu souboru — nerozpoznané (nebo špatně určené) dopiš ve sloupci SxxEyy (např. S01E05 / 1x05). Je-li epizoda rozdělená na víc souborů, přidej part: S01E05P2. „Přidat složku…“ projde i všechny podsložky.",
).wrap());
egui::ScrollArea::vertical().max_height(320.0).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("ep_rows").striped(true).num_columns(3).show(ui, |ui| {
ui.strong("Soubor");
ui.strong("SxxEyy");
ui.label("");
ui.end_row();
for r in &mut self.rows {
ui.label(paths::name(&r.source));
let text = r.code.trim().to_string();
let (color, tip) = if text.is_empty() {
(None, "Prázdné — epizoda se přeskočí. Zadej např. S01E05.")
} else if parse_episode_code(&text).is_none() {
(Some(RED), "Nerozpoznaný formát — použij S01E05 nebo 1x05 (část epizody: S01E05P2).")
} else {
(Some(GREEN), "")
};
let mut edit = egui::TextEdit::singleline(&mut r.code).hint_text("SxxEyy");
if let Some(c) = color {
edit = edit.text_color(c);
}
let resp = sized(ui, 100.0, edit);
if !tip.is_empty() {
resp.on_hover_text(tip);
}
if ui.button("✕").clicked() {
remove = Some(r.source.clone());
}
ui.end_row();
}
});
});
ui.horizontal(|ui| {
add_files = ui.button("➕ Přidat epizody…").clicked();
add_folder = ui
.button("📁 Přidat složku…")
.on_hover_text("Vyber složku seriálu — Curator projde i všechny podsložky a přidá každý video soubor v nich.")
.clicked();
});
ui.checkbox(
&mut self.move_source,
"Přesunout soubory do poolu (jinak zkopírovat)",
);
ok = ok_cancel_row(ui);
},
);
if let Some(src) = remove {
self.rows.retain(|r| r.source != src);
}
if add_files
&& let Some(files) = rfd::FileDialog::new()
.set_title("Vyber epizody")
.pick_files()
{
let have: HashSet<PathBuf> = self.rows.iter().map(|r| r.source.clone()).collect();
self.rows.extend(
files
.into_iter()
.filter(|f| !have.contains(f))
.map(Row::new),
);
}
if add_folder {
self.add_folder(env);
}
if lookup {
self.start_lookup(ctx, env);
}
if let Some(mut c) = self.confirm.take() {
match c.show(ctx, egui::Id::unique("ep_confirm")) {
Some((_, Answer::Yes)) => {
self.start_import(env);
return DialogState::Closed;
}
Some(_) => {}
None => self.confirm = Some(c),
}
}
match ok {
Some(false) => return DialogState::Closed,
Some(true) => {
if self.rows.is_empty() {
env.warn("Seriál", "Přidej aspoň jednu epizodu (soubor nebo složku).");
} else if self.title.trim().is_empty() {
env.warn("Seriál", "Zadej název seriálu.");
} else {
let missing: Vec<String> = self
.rows
.iter()
.filter(|r| parse_episode_code(r.code.trim()).is_none())
.map(|r| paths::name(&r.source))
.collect();
if missing.is_empty() {
self.start_import(env);
return DialogState::Closed;
}
self.confirm = Some(Prompt::confirm(
"Chybí číslování",
format!(
"{} epizod nemá platné SxxEyy a při importu se přeskočí:\n{}\n\nPokračovat?",
missing.len(),
bullet_list(&missing, 10)
),
(),
));
}
}
None => {}
}
if close && self.confirm.is_none() {
return DialogState::Closed;
}
DialogState::Open
}
}
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
//! The library organizer (name + kind of each top-level pool folder) and the
//! choice of libraries feeding miniDLNA generation.
use std::collections::HashSet;
use std::fs;
use super::{DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::file_manager::{FileManager, Library, LibraryKind};
use crate::ui::common::{GREY, RED};
pub struct LibraryOrganizerDialog {
rows: Vec<(String, LibraryKind)>,
selected: Option<usize>,
}
impl LibraryOrganizerDialog {
pub fn new(fm: &FileManager) -> Self {
Self {
rows: fm
.libraries()
.into_iter()
.map(|l| (l.name, l.kind))
.collect(),
selected: None,
}
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut ok = None;
let (_, close) = modal(ctx, "libraries", "Knihovny poolu", 560.0, |ui| {
ui.label("Knihovny = složky v kořeni poolu, každá je jedna knihovna v Jellyfinu.\nTyp určuje, jak Curator složku zpracuje.");
ui.add_space(6.0);
egui::Grid::new("lib_rows")
.striped(true)
.num_columns(3)
.show(ui, |ui| {
ui.strong("");
ui.strong("Název složky");
ui.strong("Typ");
ui.end_row();
for (i, (name, kind)) in self.rows.iter_mut().enumerate() {
ui.radio_value(&mut self.selected, Some(i), "");
crate::ui::common::sized(ui, 240.0, egui::TextEdit::singleline(name));
egui::ComboBox::from_id_salt(("kind", i))
.selected_text(kind.label())
.show_ui(ui, |ui| {
for k in LibraryKind::ALL {
ui.selectable_value(kind, k, k.label());
}
});
ui.end_row();
}
});
ui.horizontal(|ui| {
if ui.button("➕ Přidat knihovnu").clicked() {
self.rows.push((String::new(), LibraryKind::Movie));
}
if ui.button("➖ Odebrat").clicked()
&& let Some(i) = self.selected.take().filter(|i| *i < self.rows.len())
{
self.rows.remove(i);
}
});
ui.add(egui::Label::new(egui::RichText::new(
"Pozn.: „Mix“ zpracuje filmy (v kořeni) i seriály (v podsložkách); „Domácí videa“ se do miniDLNA zrcadlí celé. Po uložení nabídnu smazání prázdných složek, které už nejsou knihovnou (s obsahem nechám být).",
).color(GREY)).wrap());
ok = ok_cancel_row(ui);
});
match ok {
Some(true) => {
let specs: Vec<Library> = self
.rows
.iter()
.filter(|(n, _)| !n.trim().is_empty())
.map(|(n, k)| Library::new(n.trim(), *k))
.collect();
if let Err(e) = env.fm.set_libraries(specs) {
env.warn("Knihovny", e.to_string());
return DialogState::Open;
}
if let Some(pool) = env.fm.pool_dir() {
for lib in env.fm.libraries() {
let _ = fs::create_dir_all(pool.join(&lib.name));
}
}
let names: Vec<String> = env
.fm
.libraries()
.iter()
.map(|l| format!("{} ({})", l.name, l.kind.as_str()))
.collect();
env.events
.push(Event::Status(format!("Knihovny: {}", names.join(", "))));
env.events.push(Event::LibrariesChanged);
DialogState::Closed
}
Some(false) => DialogState::Closed,
None if close => DialogState::Closed,
None => DialogState::Open,
}
}
}
pub struct MinidlnaLibrariesDialog {
rows: Vec<(String, LibraryKind, bool)>,
}
impl MinidlnaLibrariesDialog {
pub fn new(fm: &FileManager) -> Self {
let chosen: Option<HashSet<String>> =
fm.minidlna_libraries().map(|v| v.into_iter().collect());
Self {
rows: fm
.libraries()
.into_iter()
.map(|l| {
let on = chosen.as_ref().is_none_or(|c| c.contains(&l.name));
(l.name, l.kind, on)
})
.collect(),
}
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut ok = None;
let (_, close) = modal(
ctx,
"minidlna_libs",
"Knihovny pro generování miniDLNA",
440.0,
|ui| {
ui.add(egui::Label::new("Které knihovny se mají promítnout do miniDLNA stromu (žánry, Tipy dne, Nově přidané, zrcadlené seriály)?").wrap());
ui.add(egui::Label::new(egui::RichText::new("⚠ Odškrtnutá knihovna: její odkazy ve stromu se při dalším generování odeberou jako zastaralé, dokud ji znovu nezaškrtneš.").color(RED)).wrap());
ui.add_space(6.0);
for (name, kind, on) in &mut self.rows {
ui.checkbox(on, format!("{name} ({})", kind.as_str()));
}
ui.horizontal(|ui| {
if ui.button("Vše").clicked() {
self.rows.iter_mut().for_each(|r| r.2 = true);
}
if ui.button("Nic").clicked() {
self.rows.iter_mut().for_each(|r| r.2 = false);
}
});
ok = ok_cancel_row(ui);
},
);
match ok {
Some(true) => {
let chosen: Vec<String> = self
.rows
.iter()
.filter(|r| r.2)
.map(|r| r.0.clone())
.collect();
let all = chosen.len() == self.rows.len();
if let Err(e) =
env.fm
.set_minidlna_libraries(if all { None } else { Some(chosen.clone()) })
{
env.warn("miniDLNA", e.to_string());
}
env.events.push(Event::Status(format!(
"miniDLNA: {}/{} knihoven vybráno pro generování.",
chosen.len(),
self.rows.len()
)));
DialogState::Closed
}
Some(false) => DialogState::Closed,
None if close => DialogState::Closed,
None => DialogState::Open,
}
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Review TMDb match proposals for an existing pool and apply the ids.
use std::collections::BTreeMap;
use super::{DialogState, Env, Event, modal};
use crate::core::migrate::{MatchProposal, MatchStatus};
use crate::ui::common::{BLUE, GREEN, RED};
pub struct MigrateDialog {
proposals: Vec<MatchProposal>,
/// Chosen candidate per proposal (`None` = skip).
choice: Vec<Option<usize>>,
}
impl MigrateDialog {
pub fn new(proposals: Vec<MatchProposal>) -> Self {
let choice = proposals
.iter()
.map(|p| {
if p.status == MatchStatus::Auto && !p.candidates.is_empty() {
p.chosen
} else {
None
}
})
.collect();
Self { proposals, choice }
}
fn apply(&mut self, env: &mut Env, only_auto: bool) {
let mut done = 0;
let mut errors = Vec::new();
for (p, choice) in self.proposals.iter().zip(&self.choice) {
if only_auto && p.status != MatchStatus::Auto {
continue;
}
let Some(c) = choice.and_then(|i| p.candidates.get(i)) else {
continue;
};
let Some(path) = env.fm.path_by_filename(&p.current_name) else {
errors.push(format!("{}: soubor už v poolu není", p.current_name));
continue;
};
match env.fm.assign_tmdb_id(&path, c.tmdb_id, c.year.as_deref()) {
Ok(_) => done += 1,
Err(e) => errors.push(format!("{}: {e}", p.current_name)),
}
}
let msg = format!("Přejmenováno {done} filmů.");
if errors.is_empty() {
env.info("Migrace", msg);
} else {
env.warn(
"Migrace s chybami",
format!(
"{msg}\n\nChyby:\n{}",
errors
.iter()
.take(8)
.cloned()
.collect::<Vec<_>>()
.join("\n")
),
);
}
env.events.push(Event::Refresh);
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut act = None;
let mut close_clicked = false;
let (_, close) = modal(
ctx,
"migrate",
"Spárovat pool s TMDb (migrace)",
860.0,
|ui| {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for p in &self.proposals {
*counts.entry(p.status.label()).or_default() += 1;
}
let summary: Vec<String> =
counts.iter().map(|(k, v)| format!("{k}: {v}")).collect();
ui.add(egui::Label::new(format!(
"Návrhy párování (zelené jisté jsou předvybrané). Zkontroluj, uprav u nejednoznačných a přejmenuj. {}",
summary.join(" · ")
)).wrap());
ui.add_space(6.0);
egui::ScrollArea::vertical()
.max_height(440.0)
.auto_shrink([false, true])
.show(ui, |ui| {
egui::Grid::new("migrate_rows")
.striped(true)
.num_columns(3)
.show(ui, |ui| {
ui.strong("Soubor");
ui.strong("Stav");
ui.strong("Kandidát z TMDb");
ui.end_row();
for (i, (p, choice)) in self
.proposals
.iter()
.zip(self.choice.iter_mut())
.enumerate()
{
let name = if p.note.is_empty() {
egui::RichText::new(&p.current_name)
} else {
egui::RichText::new(&p.current_name).color(BLUE)
};
let r = ui.label(name);
if !p.note.is_empty() {
r.on_hover_text(&p.note);
}
let color = match p.status {
MatchStatus::Auto => Some(GREEN),
MatchStatus::NoMatch | MatchStatus::Error => Some(RED),
MatchStatus::Ambiguous => None,
};
let st = egui::RichText::new(p.status.label());
ui.label(match color {
Some(c) => st.color(c),
None => st,
});
if p.candidates.is_empty() {
ui.label(if p.error.is_empty() {
"nic nenalezeno"
} else {
&p.error
});
} else {
let text = |c: &crate::core::tmdb::TmdbResult| {
format!(
"{} ({}) · tmdb {}",
c.title,
c.year.as_deref().unwrap_or("?"),
c.tmdb_id
)
};
let selected = choice
.and_then(|i| p.candidates.get(i))
.map_or("(přeskočit)".to_string(), text);
egui::ComboBox::from_id_salt(("cand", i))
.selected_text(selected)
.width(420.0)
.show_ui(ui, |ui| {
for (ci, c) in p.candidates.iter().enumerate() {
ui.selectable_value(choice, Some(ci), text(c));
}
ui.selectable_value(choice, None, "(přeskočit)");
});
}
ui.end_row();
}
});
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("Přejmenovat jen jisté").clicked() {
act = Some(true);
}
if ui.button("Přejmenovat vybrané").clicked() {
act = Some(false);
}
if ui.button("Zavřít").clicked() {
close_clicked = true;
}
});
},
);
if let Some(only_auto) = act {
self.apply(env, only_auto);
return DialogState::Closed;
}
if close || close_clicked {
return DialogState::Closed;
}
DialogState::Open
}
}
+193
View File
@@ -0,0 +1,193 @@
//! The modal dialogs. Each one owns its form state, talks to the
//! `FileManager` through [`Env`] and reports back to the main window with
//! [`Event`]s.
mod assign_tags;
mod duplicates;
mod import_episodes;
mod import_movies;
mod libraries;
mod migrate;
mod movie_edit;
mod series;
mod stats;
mod tag_schema;
use std::collections::HashSet;
use std::path::PathBuf;
use crate::core::file_manager::{FileManager, Stats};
use crate::core::migrate::MatchProposal;
use crate::core::series::Series;
use super::common::Message;
/// What a dialog can touch while it is open.
pub struct Env<'a> {
pub fm: &'a mut FileManager,
pub messages: &'a mut Vec<Message>,
pub events: &'a mut Vec<Event>,
}
impl Env<'_> {
pub fn info(&mut self, title: &str, text: impl Into<String>) {
self.messages.push(Message::info(title, text));
}
pub fn warn(&mut self, title: &str, text: impl Into<String>) {
self.messages.push(Message::warning(title, text));
}
}
/// Requests from a dialog to the main window.
pub enum Event {
Refresh,
ReloadSeries,
Status(String),
/// Select these movies in the table.
Select(Vec<PathBuf>),
/// The library list changed (refresh + offer orphan cleanup).
LibrariesChanged,
/// Replace the current dialog with another one.
OpenDialog(Box<Dialog>),
/// Run work in the background (the dialog is closed by then).
Task {
title: String,
work: crate::ui::task::Work,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogState {
Open,
Closed,
}
pub enum Dialog {
ImportMovies(Box<import_movies::ImportMoviesDialog>),
ImportFlow(Box<import_movies::ImportFlow>),
ImportEpisodes(Box<import_episodes::ImportEpisodesDialog>),
SeriesOverview(Box<series::SeriesOverviewDialog>),
SeriesEdit(Box<series::SeriesEditDialog>),
MovieEdit(Box<movie_edit::MovieEditDialog>),
Duplicates(Box<duplicates::DuplicatesDialog>),
Libraries(Box<libraries::LibraryOrganizerDialog>),
MinidlnaLibraries(Box<libraries::MinidlnaLibrariesDialog>),
Migrate(Box<migrate::MigrateDialog>),
AssignTags(Box<assign_tags::AssignTagsDialog>),
TagSchema(Box<tag_schema::TagSchemaDialog>),
Stats(Box<stats::StatsDialog>),
}
impl Dialog {
pub fn import_movies(
files: Vec<PathBuf>,
existing: HashSet<String>,
libraries: Vec<String>,
) -> Self {
Self::ImportMovies(Box::new(import_movies::ImportMoviesDialog::new(
files, existing, libraries,
)))
}
pub fn import_episodes(libraries: Vec<String>) -> Self {
Self::ImportEpisodes(Box::new(import_episodes::ImportEpisodesDialog::new(
libraries,
)))
}
pub fn series_overview(fm: &FileManager) -> Self {
Self::SeriesOverview(Box::new(series::SeriesOverviewDialog::new(fm)))
}
pub fn series_edit(fm: &FileManager, ser: Series) -> Self {
Self::SeriesEdit(Box::new(series::SeriesEditDialog::new(fm, ser)))
}
pub fn movie_edit(fm: &FileManager, path: &std::path::Path) -> Self {
Self::MovieEdit(Box::new(movie_edit::MovieEditDialog::new(fm, path)))
}
pub fn duplicates(fm: &FileManager) -> Self {
Self::Duplicates(Box::new(duplicates::DuplicatesDialog::new(fm)))
}
pub fn libraries(fm: &FileManager) -> Self {
Self::Libraries(Box::new(libraries::LibraryOrganizerDialog::new(fm)))
}
pub fn minidlna_libraries(fm: &FileManager) -> Self {
Self::MinidlnaLibraries(Box::new(libraries::MinidlnaLibrariesDialog::new(fm)))
}
pub fn migrate(proposals: Vec<MatchProposal>) -> Self {
Self::Migrate(Box::new(migrate::MigrateDialog::new(proposals)))
}
pub fn assign_tags(fm: &FileManager, files: Vec<PathBuf>) -> Self {
Self::AssignTags(Box::new(assign_tags::AssignTagsDialog::new(fm, files)))
}
pub fn tag_schema(fm: &FileManager) -> Self {
Self::TagSchema(Box::new(tag_schema::TagSchemaDialog::new(fm)))
}
pub fn stats(stats: Stats) -> Self {
Self::Stats(Box::new(stats::StatsDialog::new(stats)))
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
match self {
Self::ImportMovies(d) => d.show(ctx, env),
Self::ImportFlow(d) => d.show(ctx, env),
Self::ImportEpisodes(d) => d.show(ctx, env),
Self::SeriesOverview(d) => d.show(ctx, env),
Self::SeriesEdit(d) => d.show(ctx, env),
Self::MovieEdit(d) => d.show(ctx, env),
Self::Duplicates(d) => d.show(ctx, env),
Self::Libraries(d) => d.show(ctx, env),
Self::MinidlnaLibraries(d) => d.show(ctx, env),
Self::Migrate(d) => d.show(ctx, env),
Self::AssignTags(d) => d.show(ctx, env),
Self::TagSchema(d) => d.show(ctx, env),
Self::Stats(d) => d.show(ctx, env),
}
}
}
/// A standard dialog frame: a modal with a heading and a scrollable body
/// limited to most of the screen.
pub fn modal<R>(
ctx: &egui::Context,
id: &str,
title: &str,
min_width: f32,
body: impl FnOnce(&mut egui::Ui) -> R,
) -> (R, bool) {
let screen = ctx.content_rect();
let resp = egui::Modal::new(egui::Id::unique(id)).show(ctx, |ui| {
ui.set_width(min_width.min(screen.width() - 40.0));
ui.set_max_height(screen.height() - 60.0);
ui.heading(title);
ui.add_space(6.0);
body(ui)
});
let close = resp.should_close();
(resp.inner, close)
}
/// The OK / Cancel row at the bottom of a dialog: `Some(true)` = OK.
pub fn ok_cancel_row(ui: &mut egui::Ui) -> Option<bool> {
let mut out = None;
ui.add_space(6.0);
ui.separator();
ui.horizontal(|ui| {
if ui.button("OK").clicked() {
out = Some(true);
}
if ui.button("Zrušit").clicked() {
out = Some(false);
}
});
out
}
+604
View File
@@ -0,0 +1,604 @@
//! One dialog for every editable parameter of a movie: identity, date, ČSFD
//! link, 3D formát, subtitles, and its media (versions or parts).
use std::path::{Path, PathBuf};
use super::{DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::file_manager::{FileManager, GroupMode, MovieEdits, label_of};
use crate::core::movie::{DEFAULT_VERSION_LABEL, normalize_part_label};
use crate::core::naming::{THREE_D_FORMATS, parse_3d_format, parse_tmdb_id};
use crate::core::paths;
use crate::core::subtitles::SubtitleFile;
use crate::core::tmdb::{self, TmdbResult};
use crate::ui::common::{Answer, Job, Prompt, busy_button, digit_edit};
#[derive(Debug, Clone)]
enum Purpose {
SubLanguage(PathBuf),
SubForced(PathBuf, Option<String>),
RemoveSub(PathBuf),
AddVersion(PathBuf),
RenameVersion(PathBuf),
RemoveMedia(PathBuf),
ToParts,
/// Collecting the popisek of extra file `n` when switching to versions.
ToVersions(usize, Vec<String>),
}
pub struct MovieEditDialog {
path: PathBuf,
movie_key: Option<String>,
title: String,
year: String,
tmdb: String,
libraries: Vec<String>,
library: String,
date: String,
csfd: String,
format_3d: Option<String>,
subtitles: Vec<SubtitleFile>,
selected_sub: Option<usize>,
media: Vec<PathBuf>,
selected_media: Option<usize>,
/// The folder mode chosen in the radio (for a flat film: what the next
/// added medium becomes).
mode_parts: bool,
prompt: Option<Prompt<Purpose>>,
job: Option<Job<Result<Option<TmdbResult>, String>>>,
}
impl MovieEditDialog {
pub fn new(fm: &FileManager, path: &Path) -> Self {
let file = fm
.file(path)
.cloned()
.unwrap_or_else(|| crate::core::file::File::bare(path));
let ctx = file.name_context();
let mut d = Self {
path: path.to_path_buf(),
movie_key: file.movie_key.clone(),
title: file
.title
.clone()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| paths::stem(path)),
year: ctx.get("year").cloned().unwrap_or_default(),
tmdb: String::new(),
libraries: fm.libraries_accepting_movies(),
library: fm.library_of(path).unwrap_or_default(),
date: file.date.clone().unwrap_or_default(),
csfd: file.csfd_url.clone().unwrap_or_default(),
format_3d: None,
subtitles: Vec::new(),
selected_sub: None,
media: Vec::new(),
selected_media: None,
mode_parts: false,
prompt: None,
job: None,
};
d.refresh_identity(fm);
d.reload(fm, None);
d
}
/// After a media change the edited file may have moved (promotion to a
/// folder, demotion, relabel) — find it again through its movie record.
fn resync_path(&mut self, fm: &FileManager) {
if self.path.exists() {
return;
}
let Some(key) = &self.movie_key else { return };
let siblings: Vec<PathBuf> = fm
.filelist
.iter()
.filter(|f| f.movie_key.as_ref() == Some(key))
.map(|f| f.file_path.clone())
.collect();
if !siblings.is_empty() {
self.path = fm.primary_version(&siblings);
}
}
fn grouped(&self, fm: &FileManager) -> bool {
fm.is_movie_group_member(&self.path)
}
fn identity_stem(&self, fm: &FileManager) -> String {
if self.grouped(fm) {
self.path.parent().map(paths::name).unwrap_or_default()
} else {
paths::stem(&self.path)
}
}
fn refresh_identity(&mut self, fm: &FileManager) {
let stem = self.identity_stem(fm);
self.tmdb = parse_tmdb_id(&stem)
.map(|i| i.to_string())
.unwrap_or_default();
self.format_3d = if self.grouped(fm) {
None
} else {
parse_3d_format(&stem)
};
}
fn reload(&mut self, fm: &FileManager, select: Option<usize>) {
self.resync_path(fm);
self.subtitles = fm.list_subtitles(&self.path);
self.selected_sub = None;
self.media = fm.ordered_media(&self.path);
let mode = fm.movie_group_mode(&self.path);
if mode != GroupMode::Flat {
self.mode_parts = mode == GroupMode::Parts;
}
self.selected_media = select.filter(|i| *i < self.media.len());
}
fn after_change(&mut self, fm: &FileManager, select: Option<usize>) {
self.reload(fm, select);
self.refresh_identity(fm);
}
fn media_row(&self, fm: &FileManager, p: &Path) -> (String, String) {
let grouped = fm.movie_group_mode(&self.path) != GroupMode::Flat;
let label = if grouped { label_of(p) } else { String::new() };
match normalize_part_label(&label) {
Some(n) => (format!("díl {n}"), String::new()),
None if !label.is_empty() && label != DEFAULT_VERSION_LABEL => ("verze".into(), label),
None => (DEFAULT_VERSION_LABEL.into(), String::new()),
}
}
fn start_lookup(&mut self, ctx: &egui::Context, env: &mut Env) {
if !tmdb::api_available() {
env.warn("TMDb", "Není nastaven TMDB_API_KEY (.env).");
return;
}
let (title, year, id) = (
self.title.clone(),
self.year.trim().to_string(),
self.tmdb.trim().to_string(),
);
self.job = Some(Job::spawn(ctx, move || {
if !id.is_empty() {
let n: i64 = id.parse().map_err(|_| "Neplatné ID".to_string())?;
tmdb::lookup_movie(n).map_err(|e| e.to_string())
} else {
tmdb::search_movie(&title, Some(year.as_str()).filter(|y| !y.is_empty()), 1)
.map(|h| h.into_iter().next())
.map_err(|e| e.to_string())
}
}));
}
fn on_mode_clicked(&mut self, env: &mut Env, wanted_parts: bool) {
let current = env.fm.movie_group_mode(&self.path);
self.mode_parts = wanted_parts;
if current == GroupMode::Flat || (current == GroupMode::Parts) == wanted_parts {
return;
}
if wanted_parts {
let order: Vec<String> = self
.media
.iter()
.enumerate()
.map(|(n, p)| format!(" díl {}: {}", n + 1, paths::name(p)))
.collect();
self.prompt = Some(Prompt::confirm(
"Přepnout na díly",
format!(
"Soubory se přejmenují na díly v tomto pořadí (pak jde pořadí změnit šipkami):\n{}",
order.join("\n")
),
Purpose::ToParts,
));
} else {
self.ask_version_label_for_switch(0, Vec::new());
}
}
fn ask_version_label_for_switch(&mut self, n: usize, labels: Vec<String>) {
let Some(p) = self.media.get(n + 1) else {
return;
};
self.prompt = Some(Prompt::version_label(
format!(
"Popisek verze pro „{}“ (první soubor bude výchozí verze).",
paths::name(p)
),
"",
Purpose::ToVersions(n, labels),
));
}
fn handle_prompt(&mut self, purpose: Purpose, answer: Answer, env: &mut Env) {
let fm_err = |env: &mut Env, title: &str, e: crate::core::error::CoreError| {
env.warn(title, e.to_string())
};
match (purpose, answer) {
(Purpose::SubLanguage(src), Answer::Text(lang)) => {
let lang = lang.trim().to_lowercase();
if !lang.is_empty()
&& !(lang.len() >= 2
&& lang.len() <= 3
&& lang.chars().all(|c| c.is_ascii_lowercase()))
{
env.warn("Titulky", "Kód jazyka zadej jako 2–3 písmena (např. cs).");
return;
}
self.prompt = Some(Prompt::confirm(
"Titulky",
"Jsou to vynucené (forced) titulky?",
Purpose::SubForced(src, Some(lang).filter(|l| !l.is_empty())),
));
}
(Purpose::SubForced(src, lang), answer) => {
let flag = (answer == Answer::Yes).then_some("forced");
if let Err(e) =
env.fm
.attach_subtitle(&self.path, &src, lang.as_deref(), flag, false)
{
env.warn("Titulky", format!("Nelze přidat titulky: {e}"));
}
self.reload(env.fm, self.selected_media);
}
(Purpose::RemoveSub(p), Answer::Yes) => {
if let Err(e) = env.fm.remove_subtitle(&p) {
fm_err(env, "Titulky", e);
}
self.reload(env.fm, self.selected_media);
}
(Purpose::AddVersion(src), Answer::Text(label)) => {
match env
.fm
.add_movie_version(&self.path, &src, &label, false, None)
{
Ok(added) => {
self.after_change(env.fm, None);
self.selected_media = self.media.iter().position(|p| p == &added);
}
Err(e) => env.warn("Média", format!("Nelze přidat: {e}")),
}
env.events.push(Event::Refresh);
}
(Purpose::RenameVersion(p), Answer::Text(label)) => {
match env.fm.rename_movie_version(&p, &label) {
Ok(new) => {
if self.path == p {
self.path = new.clone();
}
self.after_change(env.fm, None);
self.selected_media = self.media.iter().position(|m| m == &new);
}
Err(e) => env.warn("Verze", format!("Nelze přejmenovat verzi: {e}")),
}
env.events.push(Event::Refresh);
}
(Purpose::RemoveMedia(p), Answer::Yes) => {
let sibling = self.media.iter().find(|m| **m != p).cloned();
if let Err(e) = env.fm.remove_movie_version(&p) {
fm_err(env, "Média", e);
return;
}
if self.path == p
&& let Some(s) = sibling
{
self.path = s;
}
self.after_change(env.fm, None);
env.events.push(Event::Refresh);
}
(Purpose::ToParts, Answer::Yes) => {
if let Err(e) = env
.fm
.set_movie_group_mode(&self.path, GroupMode::Parts, &[])
{
fm_err(env, "Režim složky", e);
}
self.after_change(env.fm, None);
env.events.push(Event::Refresh);
}
(Purpose::ToVersions(n, mut labels), Answer::Text(label)) if !label.is_empty() => {
labels.push(label);
if n + 2 < self.media.len() {
self.ask_version_label_for_switch(n + 1, labels);
} else {
if let Err(e) =
env.fm
.set_movie_group_mode(&self.path, GroupMode::Versions, &labels)
{
fm_err(env, "Režim složky", e);
}
self.after_change(env.fm, None);
env.events.push(Event::Refresh);
}
}
_ => {
// cancelled: the radio goes back to the folder's actual mode
self.reload(env.fm, self.selected_media);
}
}
}
fn accept(&mut self, env: &mut Env) -> bool {
let edits = MovieEdits {
title: self.title.trim().to_string(),
year: Some(self.year.trim().to_string()).filter(|y| !y.is_empty()),
tmdb_id: Some(self.tmdb.trim().to_string()).filter(|t| !t.is_empty()),
library: Some(self.library.clone()).filter(|l| !l.is_empty()),
date: Some(self.date.trim().to_string()),
csfd_url: Some(self.csfd.trim().to_string()),
format_3d: Some(self.format_3d.clone().unwrap_or_default()),
..Default::default()
};
match env.fm.apply_movie_edits(&self.path, edits) {
Ok(new) => {
env.events.push(Event::Refresh);
env.events.push(Event::Select(vec![new.clone()]));
env.events
.push(Event::Status(format!("Upraveno: {}", paths::name(&new))));
true
}
Err(e) => {
env.warn("Úprava selhala", e.to_string());
false
}
}
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
if let Some(job) = &self.job {
if let Some(res) = job.poll() {
self.job = None;
match res {
Err(e) => env.warn("TMDb", e),
Ok(None) => env.info("TMDb", "Nic nenalezeno."),
Ok(Some(hit)) => {
self.tmdb = hit.tmdb_id.to_string();
self.title = hit.title;
if let Some(y) = hit.year {
self.year = y;
}
}
}
} else if job.finished() {
self.job = None;
}
}
let busy = self.job.is_some();
let grouped = self.grouped(env.fm);
let mode = env.fm.movie_group_mode(&self.path);
let rows: Vec<(String, String, String)> = self
.media
.iter()
.map(|p| {
let (kind, label) = self.media_row(env.fm, p);
(paths::name(p), kind, label)
})
.collect();
let mut ok = None;
let mut act: Option<&str> = None;
let mut mode_click = None;
let (_, close) = modal(ctx, "movie_edit", "Upravit film", 600.0, |ui| {
let note = "Film má víc verzí — název/rok/TMDb ID/knihovna se zatím needitují společně (odeber verzi, nebo uprav soubory ručně).";
egui::Grid::new("movie_form").num_columns(2).show(ui, |ui| {
let l = ui.label("Název:");
ui.add_enabled_ui(!grouped, |ui| crate::ui::common::sized(ui, 380.0, egui::TextEdit::singleline(&mut self.title))).inner.labelled_by(l.id).on_disabled_hover_text(note);
ui.end_row();
let l = ui.label("Rok:");
ui.add_enabled_ui(!grouped, |ui| digit_edit(ui, &mut self.year, 4, 60.0, "rok").labelled_by(l.id));
ui.end_row();
let l = ui.label("TMDb ID:");
ui.add_enabled_ui(!grouped, |ui| {
ui.horizontal(|ui| {
digit_edit(ui, &mut self.tmdb, 9, 100.0, "TMDb ID").labelled_by(l.id);
if busy_button(ui, busy, "Dohledat z TMDb") {
act = Some("lookup");
}
});
});
ui.end_row();
ui.label("Knihovna:");
ui.add_enabled_ui(!grouped, |ui| {
egui::ComboBox::from_id_salt("me_lib").selected_text(self.library.clone()).show_ui(ui, |ui| {
for l in &self.libraries {
ui.selectable_value(&mut self.library, l.clone(), l);
}
});
});
ui.end_row();
let l = ui.label("Datum přidání:");
crate::ui::common::sized(ui, 140.0, egui::TextEdit::singleline(&mut self.date).hint_text("YYYY-MM-DD")).labelled_by(l.id);
ui.end_row();
let l = ui.label("ČSFD odkaz:");
crate::ui::common::sized(ui, 380.0, egui::TextEdit::singleline(&mut self.csfd).hint_text("https://www.csfd.cz/film/… (jen uloží se)")).labelled_by(l.id);
ui.end_row();
ui.label("3D formát:");
ui.add_enabled_ui(!grouped, |ui| {
egui::ComboBox::from_id_salt("me_3d")
.selected_text(self.format_3d.clone().unwrap_or_else(|| "— žádný —".into()))
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.format_3d, None, "— žádný —");
for f in THREE_D_FORMATS {
ui.selectable_value(&mut self.format_3d, Some(f.to_string()), f);
}
})
.response
.on_hover_text(if grouped {
"U filmu s víc verzemi patří 3D do popisku té konkrétní verze (viz „Média“ níže), ne sem."
} else {
"Jellyfin pozná 3D podle „.3D.<FORMÁT>“ v názvu souboru — HSBS/FSBS = side-by-side (half/full), HTAB/FTAB = top-and-bottom (half/full), MVC = nativní 3D blu-ray stream."
});
});
ui.end_row();
});
ui.add_space(8.0);
ui.label("Titulky (externí soubor vedle videa — Jellyfin je přehraje bez muxování; sledují film i při přejmenování):");
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_min_height(60.0);
egui::ScrollArea::vertical()
.id_salt("subs")
.max_height(100.0)
.show(ui, |ui| {
for (i, s) in self.subtitles.iter().enumerate() {
if ui
.selectable_label(
self.selected_sub == Some(i),
format!("{} {}", paths::name(&s.path), s.label()),
)
.clicked()
{
self.selected_sub = Some(i);
}
}
});
});
ui.horizontal(|ui| {
if ui.button("➕ Přidat titulky…").clicked() {
act = Some("add_sub");
}
if ui.button("➖ Odebrat").clicked() {
act = Some("remove_sub");
}
});
ui.add_space(8.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.strong("Média filmu");
let text = match mode {
GroupMode::Flat => "Samostatný film — jeden soubor v kořeni knihovny (+ titulky). Přidáním dalšího média se film přesune do vlastní složky jako verze, nebo jako díl — podle volby níže.".to_string(),
GroupMode::Parts => format!("Složka filmu — {} médií (díly, přehrají se za sebou).", self.media.len()),
GroupMode::Versions => format!("Složka filmu — {} médií (verze, Jellyfin nabídne výběr).", self.media.len()),
};
ui.add(egui::Label::new(text).wrap());
ui.horizontal(|ui| {
ui.label("Ve složce:");
if ui.radio(!self.mode_parts, "Verze — alternativy k výběru").on_hover_text("Každý soubor je celý film, Jellyfin nabídne výběr mezi nimi — kinoverze, režisérská verze, 3D, 4K…").clicked() {
mode_click = Some(false);
}
if ui.radio(self.mode_parts, "Díly — přehrají se za sebou").on_hover_text("Film rozdělený do víc souborů (díl 1, díl 2…) — Jellyfin je přehraje za sebou jako jeden film.").clicked() {
mode_click = Some(true);
}
});
egui::Grid::new("media_rows").striped(true).num_columns(3).show(ui, |ui| {
ui.strong("Soubor");
ui.strong("Typ");
ui.strong("Popisek");
ui.end_row();
for (i, (name, kind, label)) in rows.iter().enumerate() {
if ui.selectable_label(self.selected_media == Some(i), name).clicked() {
self.selected_media = Some(i);
}
ui.label(kind);
ui.label(label);
ui.end_row();
}
});
let parts = self.mode_parts;
let is_grouped = mode != GroupMode::Flat;
ui.horizontal(|ui| {
if ui.button("➕ Přidat médium…").on_hover_text("Přidá další soubor jako verzi, nebo jako díl — podle volby „Ve složce“. Samostatný film se tím přesune do vlastní složky.").clicked() {
act = Some("add_media");
}
if ui.add_enabled(is_grouped && !parts, egui::Button::new("✏ Popisek / 3D…")).clicked() {
act = Some("label");
}
if ui.add_enabled(is_grouped && parts, egui::Button::new("↑")).on_hover_text("Posunout díl dopředu").clicked() {
act = Some("up");
}
if ui.add_enabled(is_grouped && parts, egui::Button::new("↓")).on_hover_text("Posunout díl dozadu").clicked() {
act = Some("down");
}
if ui.button("➖ Odebrat").clicked() {
act = Some("remove_media");
}
});
});
ok = ok_cancel_row(ui);
});
if let Some(wanted) = mode_click {
self.on_mode_clicked(env, wanted);
}
match act {
Some("lookup") => self.start_lookup(ctx, env),
Some("add_sub") => {
if let Some(src) = rfd::FileDialog::new().set_title("Vyber soubor s titulky").add_filter("Titulky", &["srt", "ass", "ssa", "sub", "vtt"]).pick_file() {
self.prompt = Some(Prompt::text("Jazyk titulků", "Kód jazyka (např. cs, en; prázdné = bez tagu):", "", Purpose::SubLanguage(src)));
}
}
Some("remove_sub") => match self.selected_sub.and_then(|i| self.subtitles.get(i)) {
Some(s) => self.prompt = Some(Prompt::confirm("Odebrat titulky", format!("Smazat soubor „{}“?", paths::name(&s.path)), Purpose::RemoveSub(s.path.clone()))),
None => env.info("Titulky", "Vyber titulky k odebrání."),
},
Some("add_media") => {
if let Some(src) = rfd::FileDialog::new().set_title("Vyber další médium filmu").pick_file() {
if self.mode_parts {
match env.fm.add_movie_part(&self.path, &src, false) {
Ok(added) => {
self.after_change(env.fm, None);
self.selected_media = self.media.iter().position(|p| p == &added);
}
Err(e) => env.warn("Média", format!("Nelze přidat: {e}")),
}
env.events.push(Event::Refresh);
} else {
self.prompt = Some(Prompt::version_label("", "", Purpose::AddVersion(src)));
}
}
}
Some("label") => match self.selected_media.and_then(|i| self.media.get(i)).cloned() {
Some(p) if mode == GroupMode::Versions => {
let (_, label) = self.media_row(env.fm, &p);
self.prompt = Some(Prompt::version_label("", &label, Purpose::RenameVersion(p)));
}
Some(_) => {}
None => env.info("Verze", "Vyber verzi."),
},
Some(dir @ ("up" | "down")) => match self.selected_media.and_then(|i| self.media.get(i).map(|p| (i, p.clone()))) {
Some((i, p)) => {
let delta = if dir == "up" { -1 } else { 1 };
if let Err(e) = env.fm.move_movie_part(&p, delta) {
env.warn("Díly", e.to_string());
}
let target = (i as i64 + i64::from(delta)).clamp(0, self.media.len() as i64 - 1) as usize;
self.after_change(env.fm, Some(target));
env.events.push(Event::Refresh);
}
None => env.info("Díly", "Vyber díl k posunutí."),
},
Some("remove_media") => match self.selected_media.and_then(|i| self.media.get(i)).cloned() {
None => env.info("Média", "Vyber médium k odebrání."),
Some(_) if self.media.len() < 2 => env.info("Média", "Film má jen tenhle jeden soubor — celý film odebereš z hlavní tabulky (Odebrat z poolu)."),
Some(p) => self.prompt = Some(Prompt::confirm("Odebrat médium", format!("Smazat soubor „{}“?", paths::name(&p)), Purpose::RemoveMedia(p))),
},
_ => {}
}
if let Some(mut prompt) = self.prompt.take() {
match prompt.show(ctx, egui::Id::unique("movie_edit_prompt")) {
Some((purpose, answer)) => self.handle_prompt(purpose, answer, env),
None => self.prompt = Some(prompt),
}
}
match ok {
Some(true) => {
if self.accept(env) {
return DialogState::Closed;
}
}
Some(false) => {
env.events.push(Event::Refresh); // versions may have changed meanwhile
return DialogState::Closed;
}
None => {}
}
if close && self.prompt.is_none() && !busy {
env.events.push(Event::Refresh);
return DialogState::Closed;
}
DialogState::Open
}
}
+471
View File
@@ -0,0 +1,471 @@
//! Series overview (Series → Season → Episode tree) and the series editor.
use std::path::PathBuf;
use egui::RichText;
use super::{Dialog, DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::file_manager::FileManager;
use crate::core::paths;
use crate::core::series::{Series, is_canonical_episode, season_folder_name};
use crate::core::tmdb::{self, Kind, TmdbResult};
use crate::ui::common::{Answer, Job, Prompt, RED, busy_button, digit_edit};
#[derive(Debug, Clone, PartialEq)]
enum Sel {
Series(usize),
Season(usize, i64),
Episode(usize, PathBuf),
}
impl Sel {
fn series_index(&self) -> usize {
match self {
Self::Series(i) | Self::Season(i, _) | Self::Episode(i, _) => *i,
}
}
}
pub struct SeriesOverviewDialog {
series: Vec<Series>,
selected: Option<Sel>,
confirm: Option<Prompt<Sel>>,
}
impl SeriesOverviewDialog {
pub fn new(fm: &FileManager) -> Self {
Self {
series: fm.load_series(),
selected: None,
confirm: None,
}
}
fn reload(&mut self, fm: &FileManager) {
self.series = fm.load_series();
self.selected = None;
}
fn ask_remove(&mut self, env: &mut Env) {
let Some(sel) = self.selected.clone() else {
env.info("Seriály", "Vyber seriál, sezónu nebo epizodu.");
return;
};
let ser = &self.series[sel.series_index()];
let question = match &sel {
Sel::Series(_) => format!(
"Smazat seriál „{}“ z poolu včetně všech sezón a epizod?",
ser.title
),
Sel::Season(_, season) => {
let eps = ser.seasons().get(season).map_or(0, Vec::len);
let name = if *season >= 0 {
season_folder_name(*season as u32)
} else {
"epizody bez rozpoznané sezóny".into()
};
format!("Smazat „{name}“ ({eps} dílů) seriálu „{}“?", ser.title)
}
Sel::Episode(_, path) => {
let code = ser
.episodes
.iter()
.find(|e| &e.path == path)
.map(|e| e.code())
.unwrap_or_default();
format!(
"Smazat epizodu {code} „{}“ ze seriálu „{}“?",
paths::name(path),
ser.title
)
}
};
self.confirm = Some(Prompt::confirm("Odebrat z poolu", question, sel));
}
fn remove(&mut self, sel: Sel, env: &mut Env) {
let ser = self.series[sel.series_index()].clone();
let result = match sel {
Sel::Series(_) => env.fm.remove_series(&ser),
Sel::Season(_, season) => env.fm.remove_season(&ser, season),
Sel::Episode(_, path) => {
let eps: Vec<_> = ser
.episodes
.iter()
.filter(|e| e.path == path)
.cloned()
.collect();
env.fm.remove_episodes(&eps);
Ok(())
}
};
if let Err(e) = result {
env.warn("Chyba", e.to_string());
}
self.reload(env.fm);
env.events.push(Event::ReloadSeries);
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut action = None;
let (_, close) = modal(ctx, "series_overview", "Přehled seriálů", 760.0, |ui| {
egui::ScrollArea::vertical()
.max_height(460.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (i, ser) in self.series.iter().enumerate() {
let mut label = ser.title.clone();
if let Some(y) = &ser.year {
label.push_str(&format!(" ({y})"));
}
if let Some(id) = ser.tmdb_id {
label.push_str(&format!(" [tmdb-{id}]"));
}
let header = format!("{label} — {} epizod", ser.episodes.len());
let id = ui.make_persistent_id(("ser", i));
egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
id,
true,
)
.show_header(ui, |ui| {
let r = ui.selectable_label(
self.selected == Some(Sel::Series(i)),
RichText::new(header).strong(),
);
if r.clicked() {
self.selected = Some(Sel::Series(i));
}
if r.double_clicked() {
action = Some("edit");
}
})
.body(|ui| {
for (season, eps) in ser.seasons() {
let sname = if season >= 0 {
season_folder_name(season as u32)
} else {
"? bez sezóny".into()
};
let sid = ui.make_persistent_id(("season", i, season));
egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
sid,
false,
)
.show_header(ui, |ui| {
if ui
.selectable_label(
self.selected == Some(Sel::Season(i, season)),
format!("{sname} — {} dílů", eps.len()),
)
.clicked()
{
self.selected = Some(Sel::Season(i, season));
}
})
.body(|ui| {
for ep in eps {
let ok = is_canonical_episode(&ep.path, &ser.title);
ui.horizontal(|ui| {
let sel = Sel::Episode(i, ep.path.clone());
if ui
.selectable_label(
self.selected.as_ref() == Some(&sel),
format!(
"{} {}",
ep.code(),
paths::name(&ep.path)
),
)
.clicked()
{
self.selected = Some(sel);
}
if ok {
ui.label("OK");
} else {
ui.colored_label(RED, "přejmenovat");
}
});
}
});
}
});
}
if self.series.is_empty() {
ui.label("Žádné seriály v poolu.");
}
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("Upravit seriál…").clicked() {
action = Some("edit");
}
if ui.button("Přejmenovat epizody").clicked() {
action = Some("rename");
}
if ui
.button("Odebrat z poolu…")
.on_hover_text("Smaže to, co je ve stromu vybrané — celý seriál, jen jednu sezónu, nebo jen jednu epizodu.")
.clicked()
{
action = Some("remove");
}
if ui.button("Zavřít").clicked() {
action = Some("close");
}
});
});
match action {
Some("close") => return DialogState::Closed,
Some("edit") => match &self.selected {
Some(sel) => {
let ser = self.series[sel.series_index()].clone();
let mut d = SeriesEditDialog::new(env.fm, ser);
d.back_to_overview = true;
env.events
.push(Event::OpenDialog(Box::new(Dialog::SeriesEdit(Box::new(d)))));
return DialogState::Closed;
}
None => env.info("Seriály", "Vyber seriál."),
},
Some("rename") => match &self.selected {
Some(sel) => {
let mut ser = self.series[sel.series_index()].clone();
let r = env.fm.rename_episodes_to_canonical(&mut ser);
self.reload(env.fm);
env.events.push(Event::ReloadSeries);
env.info(
"Přejmenování epizod",
format!(
"Přejmenováno: {}, přeskočeno: {}, kolize: {}.",
r.renamed.len(),
r.skipped.len(),
r.conflicts.len()
),
);
}
None => env.info("Seriály", "Vyber seriál."),
},
Some("remove") => self.ask_remove(env),
_ => {}
}
if let Some(mut c) = self.confirm.take() {
match c.show(ctx, egui::Id::unique("series_confirm")) {
Some((sel, Answer::Yes)) => self.remove(sel, env),
Some(_) => {}
None => self.confirm = Some(c),
}
}
if close && self.confirm.is_none() {
return DialogState::Closed;
}
DialogState::Open
}
}
pub struct SeriesEditDialog {
series: Series,
title: String,
year: String,
tmdb: String,
/// TMDb lists this "series" as a movie: lookups use the movie endpoints and
/// the id is not written into the series folder name.
movie_id: bool,
libraries: Vec<String>,
library: String,
/// `(season, episode)` per episode, editable.
numbers: Vec<(u32, u32)>,
job: Option<Job<Result<Option<TmdbResult>, String>>>,
pub back_to_overview: bool,
}
impl SeriesEditDialog {
pub fn new(fm: &FileManager, ser: Series) -> Self {
let numbers = ser
.episodes
.iter()
.map(|e| (e.season.unwrap_or(1), e.episode.unwrap_or(1)))
.collect();
Self {
title: ser.title.clone(),
year: ser.year.clone().unwrap_or_default(),
tmdb: ser.tmdb_id.map(|i| i.to_string()).unwrap_or_default(),
movie_id: false,
libraries: fm.series_libraries(),
library: ser.library.clone(),
numbers,
series: ser,
job: None,
back_to_overview: false,
}
}
fn lookup(&mut self, ctx: &egui::Context, env: &mut Env) {
if !tmdb::api_available() {
env.warn("TMDb", "Není nastaven TMDB_API_KEY (.env).");
return;
}
let kind = if self.movie_id { Kind::Movie } else { Kind::Tv };
let (title, year, id) = (
self.title.clone(),
self.year.trim().to_string(),
self.tmdb.trim().to_string(),
);
self.job = Some(Job::spawn(ctx, move || {
if !id.is_empty() {
let n: i64 = id.parse().map_err(|_| "Neplatné ID".to_string())?;
tmdb::lookup(n, kind).map_err(|e| e.to_string())
} else {
let year = Some(year.as_str()).filter(|y| !y.is_empty());
let hits = match kind {
Kind::Movie => tmdb::search_movie(&title, year, 1),
Kind::Tv => tmdb::search_tv(&title, year, 1),
};
hits.map(|h| h.into_iter().next())
.map_err(|e| e.to_string())
}
}));
}
fn apply(&mut self, env: &mut Env) {
let mut ser = self.series.clone();
for (ep, (s, e)) in ser.episodes.iter_mut().zip(&self.numbers) {
ep.season = Some(*s);
ep.episode = Some(*e);
}
env.fm.rename_episodes_to_canonical(&mut ser);
let tmdb_id = if self.movie_id {
None
} else {
self.tmdb.trim().parse().ok()
};
let year = Some(self.year.trim()).filter(|y| !y.is_empty());
if let Err(e) =
env.fm
.apply_series_edits(&ser, self.title.trim(), year, tmdb_id, Some(&self.library))
{
env.warn("Úprava seriálu selhala", e.to_string());
}
env.events.push(Event::ReloadSeries);
env.events.push(Event::Status(format!(
"Seriál upraven: {}",
self.title.trim()
)));
}
fn finish(&self, env: &mut Env) -> DialogState {
if self.back_to_overview {
env.events
.push(Event::OpenDialog(Box::new(Dialog::series_overview(env.fm))));
}
DialogState::Closed
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
if let Some(job) = &self.job {
if let Some(res) = job.poll() {
self.job = None;
match res {
Err(e) => env.warn("TMDb", e),
Ok(None) => env.info(
"TMDb",
if self.movie_id {
"Film nenalezen."
} else {
"Seriál nenalezen."
},
),
Ok(Some(hit)) => {
self.title = hit.title;
if let Some(y) = hit.year {
self.year = y;
}
if self.tmdb.trim().is_empty() {
self.tmdb = hit.tmdb_id.to_string();
}
}
}
} else if job.finished() {
self.job = None;
}
}
let busy = self.job.is_some();
let mut ok = None;
let mut lookup = false;
let title = format!("Upravit seriál — {}", self.series.title);
let (_, close) = modal(ctx, "series_edit", &title, 660.0, |ui| {
egui::Grid::new("series_form").num_columns(2).show(ui, |ui| {
ui.label("Název:");
crate::ui::common::sized(ui, 380.0, egui::TextEdit::singleline(&mut self.title));
ui.end_row();
ui.label("Rok:");
digit_edit(ui, &mut self.year, 4, 60.0, "rok");
ui.end_row();
ui.label("TMDb ID:");
ui.horizontal(|ui| {
digit_edit(ui, &mut self.tmdb, 9, 100.0, "TMDb ID");
lookup = busy_button(ui, busy, "🔎 Dohledat");
});
ui.end_row();
ui.label("");
ui.checkbox(&mut self.movie_id, "Metadata projednou z ID filmu (ne seriálu)").on_hover_text(
"TMDb vede tohle jako film, ne seriál. „Dohledat“ pak použije filmové ID / vyhledávání filmů. Filmové ID se do názvu složky seriálu neuloží — Jellyfin by ho hledal jako ID seriálu a spároval něco jiného; seriál pak spáruje podle názvu a roku.",
);
ui.end_row();
ui.label("Knihovna:");
egui::ComboBox::from_id_salt("series_lib").selected_text(self.library.clone()).show_ui(ui, |ui| {
for l in &self.libraries {
ui.selectable_value(&mut self.library, l.clone(), l);
}
});
ui.end_row();
});
ui.add_space(6.0);
ui.label("Epizody — uprav sezónu/díl (uloží se jako SxxEyy):");
egui::ScrollArea::vertical()
.max_height(320.0)
.auto_shrink([false, true])
.show(ui, |ui| {
egui::Grid::new("series_eps")
.striped(true)
.num_columns(3)
.show(ui, |ui| {
ui.strong("Soubor");
ui.strong("Sezóna");
ui.strong("Díl");
ui.end_row();
for (ep, (s, e)) in
self.series.episodes.iter().zip(self.numbers.iter_mut())
{
ui.label(paths::name(&ep.path));
ui.add(egui::DragValue::new(s).range(0..=99));
ui.add(egui::DragValue::new(e).range(0..=999));
ui.end_row();
}
});
});
ok = ok_cancel_row(ui);
});
if lookup {
self.lookup(ctx, env);
}
match ok {
Some(true) => {
if self.title.trim().is_empty() {
env.warn("Seriál", "Zadej název seriálu.");
} else {
self.apply(env);
return self.finish(env);
}
}
Some(false) => return self.finish(env),
None => {}
}
if close && !busy {
return self.finish(env);
}
DialogState::Open
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Read-only overview of the pool: totals + per-category tag breakdown.
use super::{DialogState, Env, modal};
use crate::core::file_manager::Stats;
use crate::ui::common::format_size;
pub struct StatsDialog {
stats: Stats,
}
impl StatsDialog {
pub fn new(stats: Stats) -> Self {
Self { stats }
}
pub fn show(&mut self, ctx: &egui::Context, _env: &mut Env) -> DialogState {
let s = &self.stats;
let mut closed = false;
let (_, close) = modal(ctx, "stats", "Statistiky knihovny", 460.0, |ui| {
egui::Grid::new("stats_summary")
.num_columns(2)
.show(ui, |ui| {
ui.strong("Filmů v poolu:");
ui.label(s.count.to_string());
ui.end_row();
ui.strong("Celková velikost:");
ui.label(format_size(s.total_size));
ui.end_row();
ui.strong("Průměrné hodnocení:");
ui.label(s.avg_rating.map_or("—".to_string(), |a| format!("{a} %")));
ui.end_row();
ui.strong("S NFO metadaty:");
ui.label(format!("{} · bez: {}", s.with_nfo, s.without_nfo));
ui.end_row();
ui.strong("Bez štítků:");
ui.label(s.untagged.to_string());
ui.end_row();
});
ui.add_space(6.0);
egui::ScrollArea::vertical()
.max_height(420.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (cat, entries) in &s.categories {
egui::CollapsingHeader::new(format!("{cat} ({})", entries.len())).show(
ui,
|ui| {
egui::Grid::new(("stats_cat", cat))
.striped(true)
.num_columns(2)
.show(ui, |ui| {
for (name, count) in entries {
ui.label(name);
ui.label(count.to_string());
ui.end_row();
}
});
},
);
}
});
ui.separator();
closed = ui.button("Zavřít").clicked();
});
if closed || close {
DialogState::Closed
} else {
DialogState::Open
}
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Editor for the tag schema: categories, their NFO source and folder rules.
use super::{DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::config::TagSchemaEntry;
use crate::core::file_manager::FileManager;
use crate::ui::common::sized;
const FIELD_CHOICES: &[(&str, Option<&str>)] = &[
("(žádné — uživatelská)", None),
("Žánry (genres)", Some("genres")),
("Rok (year)", Some("year")),
("Země (countries)", Some("countries")),
("Hodnocení (rating)", Some("rating")),
];
const TRANSFORM_CHOICES: &[(&str, Option<&str>)] = &[
("(žádný)", None),
("Pásmo po 10 (decade_band)", Some("decade_band")),
];
struct Row {
category: String,
field: Option<String>,
transform: Option<String>,
make_folders: bool,
folder: String,
template: String,
}
fn label_of(choices: &[(&str, Option<&str>)], value: &Option<String>) -> String {
choices
.iter()
.find(|(_, v)| v.map(String::from) == *value)
.map_or(choices[0].0, |(l, _)| l)
.to_string()
}
pub struct TagSchemaDialog {
rows: Vec<Row>,
selected: Option<usize>,
}
impl TagSchemaDialog {
pub fn new(fm: &FileManager) -> Self {
let rows = fm
.tag_schema()
.into_iter()
.map(|e| Row {
make_folders: e.filmoteka_root.is_some(),
folder: e.filmoteka_root.clone().unwrap_or_default(),
template: e.filename_template.clone().unwrap_or_default(),
category: e.category,
field: e.csfd_field,
transform: e.transform,
})
.collect();
Self {
rows,
selected: None,
}
}
fn schema(&self) -> Vec<TagSchemaEntry> {
self.rows
.iter()
.filter(|r| !r.category.trim().is_empty())
.map(|r| TagSchemaEntry {
category: r.category.trim().to_string(),
csfd_field: r.field.clone(),
transform: r.transform.clone(),
filmoteka_root: r.make_folders.then(|| r.folder.trim().to_string()),
filename_template: Some(r.template.trim().to_string()).filter(|t| !t.is_empty()),
})
.collect()
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut ok = None;
let (_, close) = modal(
ctx,
"tag_schema",
"Tag schéma — kategorie a pravidla",
1060.0,
|ui| {
ui.add(egui::Label::new(
"Kategorie tagů, jejich zdroj z NFO a jak se z nich tvoří složky miniDLNA.\nSložka: prázdné = v kořeni výstupu, „Dle…“ = seskupit, vypnuté „Tvořit složky“ = jen filtr bez složek.\nŠablona názvu: jak pojmenovat hardlink jen v této kategorii, např. „{year} - {title}{ext}“ (pole: title/year/rating/ext/stem/filename).",
).wrap());
ui.add_space(6.0);
egui::ScrollArea::both()
.max_height(380.0)
.auto_shrink([false, true])
.show(ui, |ui| {
egui::Grid::new("schema_rows")
.striped(true)
.num_columns(7)
.show(ui, |ui| {
for h in [
"",
"Kategorie",
"Zdroj (NFO)",
"Transform",
"Tvořit složky",
"Složka",
"Šablona názvu",
] {
ui.strong(h);
}
ui.end_row();
for (i, r) in self.rows.iter_mut().enumerate() {
ui.radio_value(&mut self.selected, Some(i), "");
sized(ui, 140.0, egui::TextEdit::singleline(&mut r.category));
egui::ComboBox::from_id_salt(("field", i))
.selected_text(label_of(FIELD_CHOICES, &r.field))
.show_ui(ui, |ui| {
for (l, v) in FIELD_CHOICES {
ui.selectable_value(
&mut r.field,
v.map(String::from),
*l,
);
}
});
egui::ComboBox::from_id_salt(("transform", i))
.selected_text(label_of(TRANSFORM_CHOICES, &r.transform))
.show_ui(ui, |ui| {
for (l, v) in TRANSFORM_CHOICES {
ui.selectable_value(
&mut r.transform,
v.map(String::from),
*l,
);
}
});
ui.checkbox(&mut r.make_folders, "");
sized(
ui,
160.0,
egui::TextEdit::singleline(&mut r.folder)
.hint_text("prázdné = kořen výstupu"),
);
sized(
ui,
180.0,
egui::TextEdit::singleline(&mut r.template)
.hint_text("výchozí název souboru"),
);
ui.end_row();
}
});
});
ui.horizontal(|ui| {
if ui.button("➕ Přidat kategorii").clicked() {
self.rows.push(Row {
category: String::new(),
field: None,
transform: None,
make_folders: true,
folder: String::new(),
template: String::new(),
});
}
if ui.button("➖ Odebrat vybranou").clicked()
&& let Some(i) = self.selected.take().filter(|i| *i < self.rows.len())
{
self.rows.remove(i);
}
});
ok = ok_cancel_row(ui);
},
);
match ok {
Some(true) => {
let schema = self.schema();
if let Err(e) = env.fm.set_tag_schema(&schema) {
env.warn("Tag schéma", e.to_string());
return DialogState::Open;
}
env.events.push(Event::Refresh);
env.events.push(Event::Status(format!(
"Tag schéma uloženo ({} kategorií). Pro nové tagy spusťte „Načíst žánry z TMDb“ a přegenerujte miniDLNA.",
schema.len()
)));
DialogState::Closed
}
Some(false) => DialogState::Closed,
None if close => DialogState::Closed,
None => DialogState::Open,
}
}
}
+26
View File
@@ -0,0 +1,26 @@
//! egui front end. `run` opens the main window.
pub mod app;
pub mod common;
pub mod dialogs;
pub mod task;
use crate::constants;
use crate::core::file_manager::FileManager;
/// Open the main window and block until it is closed.
pub fn run(fm: FileManager) -> eframe::Result {
let size = app::initial_size(&fm);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title(format!("{} — Jellyfin knihovna", constants::app_title()))
.with_inner_size(size)
.with_min_inner_size([800.0, 500.0]),
..Default::default()
};
eframe::run_native(
constants::APP_NAME,
options,
Box::new(move |cc| Ok(Box::new(app::CuratorApp::new(fm, cc.egui_ctx.clone())))),
)
}
-4431
View File
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
//! Long-running operations that need the `FileManager` (TMDb refresh, integrity
//! scan, bulk renames, miniDLNA generation). The manager is moved into a worker
//! thread for the duration and handed back with the result, so the window keeps
//! repainting and can show progress and a cancel button.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex};
use std::thread;
use crate::core::file_manager::FileManager;
use super::common::Message;
/// The generic result of a task started from a dialog.
#[derive(Default)]
pub struct Outcome {
pub messages: Vec<Message>,
pub status: Option<String>,
pub reload_series: bool,
/// Movies to select in the table afterwards.
pub select: Vec<std::path::PathBuf>,
}
/// Work a dialog hands to the main window to run in the background.
pub type Work = Box<dyn FnOnce(&mut FileManager, &Progress) -> Outcome + Send>;
/// Progress shared between the worker and the UI.
#[derive(Default)]
pub struct Progress {
pub done: AtomicUsize,
pub total: AtomicUsize,
pub label: Mutex<String>,
pub cancel: AtomicBool,
}
impl Progress {
pub fn set(&self, done: usize, total: usize, label: impl Into<String>) {
self.done.store(done, Ordering::Relaxed);
self.total.store(total, Ordering::Relaxed);
if let Ok(mut l) = self.label.lock() {
*l = label.into();
}
}
pub fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
}
/// A running task; `R` is its result.
pub struct Task<R> {
pub title: String,
pub progress: Arc<Progress>,
rx: Receiver<(FileManager, R)>,
}
impl<R: Send + 'static> Task<R> {
pub fn spawn(
ctx: &egui::Context,
title: impl Into<String>,
mut fm: FileManager,
work: impl FnOnce(&mut FileManager, &Progress) -> R + Send + 'static,
) -> Self {
let progress = Arc::new(Progress::default());
let (tx, rx) = channel();
let p = progress.clone();
let ctx2 = ctx.clone();
thread::spawn(move || {
let result = work(&mut fm, &p);
let _ = tx.send((fm, result));
ctx2.request_repaint();
});
Self {
title: title.into(),
progress,
rx,
}
}
/// `Some` once finished (the manager comes back with the result).
pub fn poll(&self) -> Option<(FileManager, R)> {
self.rx.try_recv().ok()
}
/// Draw the progress modal.
pub fn show(&self, ctx: &egui::Context) {
egui::Modal::new(egui::Id::unique("task_progress")).show(ctx, |ui| {
ui.set_min_width(420.0);
ui.heading(&self.title);
let done = self.progress.done.load(Ordering::Relaxed);
let total = self.progress.total.load(Ordering::Relaxed);
let label = self
.progress
.label
.lock()
.map(|l| l.clone())
.unwrap_or_default();
ui.label(label);
let frac = if total > 0 {
done as f32 / total as f32
} else {
0.0
};
ui.add(
egui::ProgressBar::new(frac)
.show_percentage()
.animate(total == 0),
);
if self.progress.cancelled() {
ui.label("Ruším…");
} else if ui.button("Zrušit").clicked() {
self.progress.cancel.store(true, Ordering::Relaxed);
}
});
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}