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
+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
}
}