Give movies their own naming scheme, fixing the "- x -" blank-preview bug
The Rename panel used a single shared naming-scheme expression for
every item regardless of media kind. A movie item never gets
"show name"/"season number"/"episode number"/"episode name" bindings
(guess_movie sets "n"/"y"/"movie name"/"year" instead) - so rendering
a movie against a TV-oriented scheme like
"{show name} - {season number}x{episode number} - {episode name}"
left nothing but the scheme's own literal text: "- x -", exactly as
reported (screenshot of a real movie file after Match).
TV and movie items now render against two independent naming
schemes (`expression` for TV, a new `movieExpression` for movies),
each persisted separately (new `last_movie_naming_scheme` settings
field/commands, mirroring the existing TV ones) and each editable
through a "TV Shows" / "Movies" toggle added to the Naming Scheme
editor - presets, field chips, and the 1-9 hotkeys all act on
whichever one is currently selected. The movie scheme needs no
onboarding auto-upgrade chain (unlike TV's sample -> matched ->
fetched staging): a movie item only ever has a title and year, both
already available right after local Match, so it just starts at a
sensible default ("{movie name} ({year})") instead.
A mixed batch of TV episodes and movies now renders both kinds
correctly at the same time, each against its own scheme. Verified
with a new Playwright script reproducing the exact reported scenario
(load a movie, click Match) plus the toggle/independent-persistence
behavior; all prior naming-scheme/match-fetch/hash-files suites,
`cargo test --workspace` (162 tests), and `npm run build` still pass
clean.
This commit is contained in:
@@ -121,6 +121,10 @@ pub fn run() {
|
||||
settings::save_opensubtitles_login,
|
||||
settings::get_last_naming_scheme,
|
||||
settings::save_last_naming_scheme,
|
||||
settings::get_last_movie_naming_scheme,
|
||||
settings::save_last_movie_naming_scheme,
|
||||
settings::get_theme,
|
||||
settings::save_theme,
|
||||
subtitles::search_subtitles,
|
||||
subtitles::download_subtitle,
|
||||
episodes::browse_episodes,
|
||||
|
||||
@@ -88,8 +88,27 @@ struct StoredSettings {
|
||||
/// scheme survives closing and reopening the app instead of resetting to the
|
||||
/// built-in sample expression every time. Distinct from `presets.rs`'s 1-9 saved
|
||||
/// slots: this is the *current* scheme, not an explicitly named save.
|
||||
///
|
||||
/// Used for TV episodes specifically - see [`last_movie_naming_scheme`] for the
|
||||
/// separate scheme movies use. The two are independent because TV and movie items
|
||||
/// carry entirely different binding vocabularies (season/episode numbers and an
|
||||
/// episode title vs. just a movie title and year); a single shared scheme text
|
||||
/// can only ever render one of the two kinds correctly; a batch that mixes TV and
|
||||
/// movies would leave the other kind's fields blank.
|
||||
#[serde(default)]
|
||||
last_naming_scheme: Option<String>,
|
||||
/// The naming-scheme expression the Rename panel's editor last held while in
|
||||
/// "Movies" mode - same persistence behavior as [`last_naming_scheme`], just for
|
||||
/// movie items. See that field's doc for why these are two separate settings.
|
||||
#[serde(default)]
|
||||
last_movie_naming_scheme: Option<String>,
|
||||
/// Which visual theme the app renders with - a plain string id ("aurora"/"light"/
|
||||
/// "ocean"/"sunset") matching a `[data-theme="..."]` block in `src/theme.css` on
|
||||
/// the frontend. Kept as an opaque string here rather than a Rust enum so a new
|
||||
/// theme can be added purely in CSS/TS without touching this file too; an
|
||||
/// unrecognized or missing value just falls back to the frontend's own default.
|
||||
#[serde(default)]
|
||||
theme: Option<String>,
|
||||
}
|
||||
|
||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -327,6 +346,49 @@ pub fn save_last_naming_scheme(app: AppHandle, expression: String) -> Result<(),
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the last-saved *movie* naming-scheme expression, or `None` if nothing (or
|
||||
/// only blank text) has been saved yet - the frontend falls back to its own built-in
|
||||
/// movie default in that case. See [`last_naming_scheme`] for why movies get their
|
||||
/// own separate saved scheme instead of sharing the TV one.
|
||||
pub fn last_movie_naming_scheme(app: &AppHandle) -> Option<String> {
|
||||
load(app).last_movie_naming_scheme.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_last_movie_naming_scheme(app: AppHandle) -> Option<String> {
|
||||
last_movie_naming_scheme(&app)
|
||||
}
|
||||
|
||||
/// Saves the movie naming-scheme editor's current text so it survives an app restart.
|
||||
/// Mirrors [`save_last_naming_scheme`] exactly, just for the separate movie scheme.
|
||||
#[tauri::command]
|
||||
pub fn save_last_movie_naming_scheme(app: AppHandle, expression: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = expression.trim().to_string();
|
||||
settings.last_movie_naming_scheme = if trimmed.is_empty() { None } else { Some(expression) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the stored theme id, or `None` if nothing's been saved yet (the frontend
|
||||
/// then falls back to its own built-in default theme).
|
||||
pub fn theme(app: &AppHandle) -> Option<String> {
|
||||
load(app).theme.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_theme(app: AppHandle) -> Option<String> {
|
||||
theme(&app)
|
||||
}
|
||||
|
||||
/// Saves which theme id the user picked in Settings.
|
||||
#[tauri::command]
|
||||
pub fn save_theme(app: AppHandle, theme: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = theme.trim().to_string();
|
||||
settings.theme = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -414,6 +476,45 @@ mod tests {
|
||||
assert_eq!(parsed.last_naming_scheme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_settings_without_movie_scheme_or_theme_fields_still_deserializes() {
|
||||
// Same schema-evolution guarantee, for the two fields the movie-naming-scheme
|
||||
// split and the theming feature added.
|
||||
let old_json = r#"{"tmdb_token":"abc123","last_naming_scheme":"{show name}"}"#;
|
||||
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(parsed.last_naming_scheme, Some("{show name}".to_string()));
|
||||
assert_eq!(parsed.last_movie_naming_scheme, None);
|
||||
assert_eq!(parsed.theme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_movie_naming_scheme_round_trips_and_ignores_pure_whitespace() {
|
||||
// Mirrors last_naming_scheme_round_trips_and_ignores_pure_whitespace below,
|
||||
// for the separate movie scheme.
|
||||
let mut settings = StoredSettings::default();
|
||||
settings.last_movie_naming_scheme = Some("{movie name} ({year})".to_string());
|
||||
assert_eq!(
|
||||
settings.last_movie_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()),
|
||||
Some("{movie name} ({year})")
|
||||
);
|
||||
|
||||
settings.last_movie_naming_scheme = Some(" ".to_string());
|
||||
assert_eq!(
|
||||
settings.last_movie_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_round_trips_and_ignores_pure_whitespace() {
|
||||
let mut settings = StoredSettings::default();
|
||||
settings.theme = Some("ocean".to_string());
|
||||
assert_eq!(settings.theme.as_deref().filter(|t| !t.trim().is_empty()), Some("ocean"));
|
||||
|
||||
settings.theme = Some(" ".to_string());
|
||||
assert_eq!(settings.theme.as_deref().filter(|t| !t.trim().is_empty()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_naming_scheme_round_trips_and_ignores_pure_whitespace() {
|
||||
// Pure merge logic (load/save both need a real AppHandle, exercised end to end
|
||||
|
||||
+115
-34
@@ -330,6 +330,16 @@ const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}";
|
||||
// What Match + Fetch Data together can fill in.
|
||||
const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}";
|
||||
|
||||
// The separate naming scheme used for movie items (see `movieExpression` state below
|
||||
// for why TV and movies need their own independent scheme rather than sharing one):
|
||||
// unlike the TV scheme above, this one has no multi-stage "showcase the next field"
|
||||
// onboarding chain, because there's no comparably rich vocabulary to stage - a movie
|
||||
// item only ever has a title and a year (`{movie name}`/`{year}`), both of which are
|
||||
// already available right after local Match, and Fetch Data only refines them rather
|
||||
// than adding a genuinely new field the way it adds `{episode name}` for TV. So this
|
||||
// is just always the useful pattern from the start, not a milestone in a chain.
|
||||
const MOVIE_SAMPLE_EXPRESSION = "{movie name} ({year})";
|
||||
|
||||
// Copy for each RenameAction, used in the confirmation modal's segmented selector,
|
||||
// subtitle, confirm-button label, and the post-confirm status message.
|
||||
const RENAME_ACTION_LABELS: Record<
|
||||
@@ -470,6 +480,16 @@ export default function RenamePanel() {
|
||||
// below) has finished - gates the debounced save effect so it can never fire before
|
||||
// the load does and stomp a saved scheme with the SAMPLE_EXPRESSION default.
|
||||
const [schemeLoaded, setSchemeLoaded] = useState(false);
|
||||
// The separate naming scheme for movie items - see MOVIE_SAMPLE_EXPRESSION's doc
|
||||
// comment above for why movies get their own independent scheme instead of sharing
|
||||
// `expression`. Persisted and restored the same way as the TV scheme, just to its
|
||||
// own settings field (`last_movie_naming_scheme`) so switching between the two
|
||||
// never overwrites the other.
|
||||
const [movieExpression, setMovieExpression] = useState(MOVIE_SAMPLE_EXPRESSION);
|
||||
const [movieSchemeLoaded, setMovieSchemeLoaded] = useState(false);
|
||||
// Which of the two schemes the Naming Scheme editor is currently showing/editing.
|
||||
// Purely a UI selection, not persisted - the editor always opens back on "tv".
|
||||
const [editingKind, setEditingKind] = useState<"tv" | "movie">("tv");
|
||||
const [editingFormat, setEditingFormat] = useState(false);
|
||||
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
||||
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
||||
@@ -616,14 +636,42 @@ export default function RenamePanel() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [schemeLoaded, expression]);
|
||||
|
||||
// Restores/persists the movie naming scheme the same way as the TV one just above,
|
||||
// to its own separate settings field. No restoredCustomSchemeRef equivalent is
|
||||
// needed here - unlike the TV scheme, this one has no auto-upgrade checkpoints for
|
||||
// a restored value to collide with (see MOVIE_SAMPLE_EXPRESSION's doc comment).
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_last_movie_naming_scheme")
|
||||
.then((saved) => {
|
||||
if (saved && saved.trim()) setMovieExpression(saved);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setMovieSchemeLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!movieSchemeLoaded) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
invoke("save_last_movie_naming_scheme", { expression: movieExpression }).catch(() => {});
|
||||
}, 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [movieSchemeLoaded, movieExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (!item.bindings) return [item.id, null] as const;
|
||||
// Movie items render against their own separate scheme - see
|
||||
// MOVIE_SAMPLE_EXPRESSION's doc comment for why: the TV scheme's fields
|
||||
// (show name/season number/episode number/episode name) are never set on a
|
||||
// movie item, so rendering a movie against the TV scheme leaves nothing but
|
||||
// the scheme's own literal text (e.g. "{show name} - {season number}x
|
||||
// {episode number} - {episode name}" renders as the bare "- x -").
|
||||
const activeExpr = item.mediaKind === "movie" ? movieExpression : expression;
|
||||
const rendered = await invoke<string>("render_expression", {
|
||||
expression,
|
||||
expression: activeExpr,
|
||||
bindings: item.bindings,
|
||||
});
|
||||
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
|
||||
@@ -646,7 +694,7 @@ export default function RenamePanel() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [expression, items]);
|
||||
}, [expression, movieExpression, items]);
|
||||
|
||||
const notImplemented = (feature: string) => {
|
||||
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
|
||||
@@ -1509,6 +1557,13 @@ export default function RenamePanel() {
|
||||
});
|
||||
};
|
||||
|
||||
// Whichever of the two schemes the editor is currently showing - presets, the field
|
||||
// chips, and typing into the box all act on this one rather than always the TV
|
||||
// scheme, so switching the "TV Shows"/"Movies" toggle changes what a 1-9 hotkey or a
|
||||
// clicked field chip actually affects.
|
||||
const activeExpression = editingKind === "movie" ? movieExpression : expression;
|
||||
const setActiveExpression = editingKind === "movie" ? setMovieExpression : setExpression;
|
||||
|
||||
// Presets panel + 1-9 hotkeys. Applying a preset just swaps the naming-scheme
|
||||
// expression (same as typing a new one into the format editor) - it doesn't touch
|
||||
// `items` at all, so the live preview recomputes for whatever's already loaded.
|
||||
@@ -1525,7 +1580,7 @@ export default function RenamePanel() {
|
||||
const applyPreset = (slot: string) => {
|
||||
const value = presets?.[slot];
|
||||
if (!value) return;
|
||||
setExpression(value);
|
||||
setActiveExpression(value);
|
||||
setStatus(`Applied preset ${slot}.`);
|
||||
window.clearTimeout((applyPreset as any)._t);
|
||||
(applyPreset as any)._t = window.setTimeout(() => setStatus(null), 2500);
|
||||
@@ -1534,8 +1589,8 @@ export default function RenamePanel() {
|
||||
const saveCurrentAsPreset = async (slot: string) => {
|
||||
setPresetsBusySlot(slot);
|
||||
try {
|
||||
await invoke("save_preset", { slot, expression });
|
||||
setPresets((prev) => ({ ...(prev ?? {}), [slot]: expression }));
|
||||
await invoke("save_preset", { slot, expression: activeExpression });
|
||||
setPresets((prev) => ({ ...(prev ?? {}), [slot]: activeExpression }));
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't save preset ${slot}: ${err}`);
|
||||
} finally {
|
||||
@@ -1616,13 +1671,13 @@ export default function RenamePanel() {
|
||||
if (!el) {
|
||||
// No live input to read a caret position from (shouldn't normally happen, since
|
||||
// this is only reachable while the editor is open) - fall back to appending.
|
||||
setExpression((prev) => prev + token);
|
||||
setActiveExpression((prev) => prev + token);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? el.value.length;
|
||||
const end = el.selectionEnd ?? el.value.length;
|
||||
const next = el.value.slice(0, start) + token + el.value.slice(end);
|
||||
setExpression(next);
|
||||
setActiveExpression(next);
|
||||
// Restore focus and put the caret right after what was just inserted, so clicking
|
||||
// several fields in a row inserts them left-to-right rather than all piling up at
|
||||
// the same spot. Deferred a tick so this runs after React re-renders the input
|
||||
@@ -1715,17 +1770,37 @@ export default function RenamePanel() {
|
||||
|
||||
{editingFormat && (
|
||||
<div className="format-editor">
|
||||
<label htmlFor="expression">Naming scheme</label>
|
||||
<span className="modal-field-label">Naming scheme for</span>
|
||||
<div className="action-segmented" role="radiogroup" aria-label="Which kind of item this naming scheme applies to">
|
||||
<button
|
||||
type="button"
|
||||
className={`action-segmented-option${editingKind === "tv" ? " selected" : ""}`}
|
||||
onClick={() => setEditingKind("tv")}
|
||||
>
|
||||
TV Shows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`action-segmented-option${editingKind === "movie" ? " selected" : ""}`}
|
||||
onClick={() => setEditingKind("movie")}
|
||||
>
|
||||
Movies
|
||||
</button>
|
||||
</div>
|
||||
<label htmlFor="expression">
|
||||
{editingKind === "movie" ? "Movie naming scheme" : "TV naming scheme"}
|
||||
</label>
|
||||
<input
|
||||
id="expression"
|
||||
ref={expressionInputRef}
|
||||
value={expression}
|
||||
value={activeExpression}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
onChange={(e) => setActiveExpression(e.target.value)}
|
||||
/>
|
||||
{computeError && <p className="format-error">{computeError}</p>}
|
||||
<p className="format-hint">
|
||||
This field is fully yours to edit — type any combination of literal text and{" "}
|
||||
TV episodes and movies each get their own naming scheme, saved separately — switch the toggle above to
|
||||
edit the other one. This field is fully yours to edit — type any combination of literal text and{" "}
|
||||
<code>{"{field}"}</code> placeholders and the preview updates live against {matchedCount} of{" "}
|
||||
{items.length} file{items.length === 1 ? "" : "s"} (the real Rust naming-expression engine, through
|
||||
Tauri). Every field below works as a plain <code>{"{field name}"}</code>, spaces and all — no coding
|
||||
@@ -1742,29 +1817,35 @@ export default function RenamePanel() {
|
||||
<FieldChip token="{name}" />
|
||||
<FieldChip token="{ext}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Match</strong> (local, offline)
|
||||
</span>
|
||||
<FieldChip token="{show name}" />
|
||||
<FieldChip token="{season number}" />
|
||||
<FieldChip token="{episode number}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
||||
</span>
|
||||
<FieldChip token="{episode name}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
||||
</span>
|
||||
<FieldChip token="{movie name}" />
|
||||
<FieldChip token="{year}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
{editingKind === "tv" && (
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Match</strong> (local, offline)
|
||||
</span>
|
||||
<FieldChip token="{show name}" />
|
||||
<FieldChip token="{season number}" />
|
||||
<FieldChip token="{episode number}" />
|
||||
</div>
|
||||
)}
|
||||
{editingKind === "tv" && (
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
||||
</span>
|
||||
<FieldChip token="{episode name}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
)}
|
||||
{editingKind === "movie" && (
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
||||
</span>
|
||||
<FieldChip token="{movie name}" />
|
||||
<FieldChip token="{year}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
)}
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Release tags, after <strong>Match</strong> (TV or movies, local, offline)
|
||||
|
||||
Reference in New Issue
Block a user