378 lines
14 KiB
Rust
378 lines
14 KiB
Rust
//! 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
|
||
}
|
||
}
|