Rewrite Curator in Rust with an egui GUI
This commit is contained in:
+2001
File diff suppressed because it is too large
Load Diff
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+118
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user