Files
Curator/src/ui/dialogs/import_episodes.rs
T

378 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
}
}