//! 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, library: String, rows: Vec, move_source: bool, job: Option, String>>>, confirm: Option>, } impl ImportEpisodesDialog { pub fn new(libraries: Vec) -> 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 = self.rows.iter().map(|r| r.source.clone()).collect(); let new: Vec = 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 { 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 = 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 = 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::>() .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 = 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 = 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 } }