diff --git a/README.md b/README.md index a0d86af..cd2e06d 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ below. A new Subtitles panel searches and downloads subtitle files from [OpenSubtitles](https://www.opensubtitles.com/) — load a video, its title/season/episode (or movie title/year) auto-fills from the same local matching "Match" uses, pick a result, and the subtitle is saved right next to the video ready for any player to pick -up automatically; see "Subtitles" below. See "Porting roadmap" below. +up automatically; see "Subtitles" below. A new Episodes panel searches a series by +name and browses a full season's episode list — episode number, title, air date — using +whichever TV source (TMDB/TVmaze/OMDb) is already configured in Fetch Data Settings, no +separate credentials needed; see "Episodes" below. See "Porting roadmap" below. The naming scheme is fully user-editable from the app (the "Naming Scheme" button next to the New Names list) — the default is `{show name} - {season number}x{episode @@ -153,7 +156,12 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported), download against OpenSubtitles' REST API; search only needs an app-level API key, download additionally needs the end user's own account login, since downloads are quota-limited per account — - see "Subtitles" below), the on-disk + see "Subtitles" below). The Episodes panel adds no new core-crate + code of its own — it reuses `metadata::AnyTvClient::fetch_season` + (the same season-resolving call `AnyTvClient` already made available + to "Fetch Data" and the CLI) from the Tauri side, so `web/tmdb.rs`/ + `tvmaze.rs`/`omdb.rs`'s existing season-episode-list methods are + exactly what it browses. The on-disk rename engine (`rename/` — Move/Copy/Symlink actions, Skip/Auto-number/Override conflict policies, can also organize into (auto-created) subfolders when @@ -180,7 +188,11 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands returns to the frontend, only whether one is saved), `subtitles.rs` (search/download commands wrapping `aurora-curator-core::web::opensubtitles`, saving a downloaded - subtitle right next to its video file), + subtitle right next to its video file), `episodes.rs` (the + `browse_episodes` command backing the Episodes panel — series + search + one season's episode list, via a `build_tv_client` helper + now shared with `metadata.rs`'s `fetch_metadata`, so both read the + same stored TV-source setting and credential), `history.rs` (rename history/undo log), `presets.rs` (up to 9 saved naming schemes) — all three stored as JSON in the OS app-config directory — `cli.rs` (the @@ -203,6 +215,10 @@ src/ React + TypeScript UI (Vite). Faithful recreation of the panel (its own Subtitle Settings modal, its own credential state) reached from the sidebar's "Subtitles" entry — the first sidebar entry besides Rename to be a real screen rather than a stub. + `EpisodesPanel.tsx` is the second: a series-search + season-browse + screen with no settings modal of its own at all — it deliberately + has no separate credentials UI, reading whatever TV source/token + Fetch Data Settings (in the Rename panel) already has saved. scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark (`scripts/logo/icon-source.png`, cropped from the supplied lockup) — replace that source image to change the look. @@ -227,7 +243,7 @@ npm run tauri dev npm run tauri build ``` -Verified in the environment this was built in: `cargo test --workspace` (all 144 +Verified in the environment this was built in: `cargo test --workspace` (all 145 passing, including the TMDB, TVmaze, OMDb, and OpenSubtitles client tests — those run against a local mock HTTP server via `mockito`, no live network or API key needed to build/test), `cargo @@ -315,6 +331,28 @@ tree (see "Organizing into subfolders" above) — a naming scheme's own `/` alre organizing into a subfolder, and relaxing that invariant just for `--output` wasn't judged worth it. +## Episodes + +The Episodes sidebar panel searches for a TV series by name and browses one season's +full episode list — episode number, title, and air date — as a standalone reference, +independent of whatever files (if any) are loaded in the Rename panel. + +Type a series name, pick a season (the +/− stepper re-queries automatically once you've +searched at least once), and click **Browse**. A season with a real series match but no +episodes of its own (e.g. asking for season 99 of a 5-season show) comes back as a clear +"no episodes found for season N" message rather than an error; a series name that +doesn't match anything at all surfaces the underlying source's own not-found message. A +**Copy list** button copies every row as plain text (`Series Name - SxEE - Episode +Title`, one per line) to the clipboard — handy for pasting into a naming reference or +anywhere outside the app. + +Unlike Subtitles, this panel has **no settings or credentials of its own**: it reuses +whichever TV source — TMDB, TVmaze, or OMDb — is already configured in the Rename +panel's Fetch Data Settings, and the same season-lookup call ("Fetch Data" and the +`-rename --db` CLI both already made) is exactly what it browses. If nothing is +configured yet (only relevant for TMDB/OMDb — TVmaze needs no credential), Browse +reports the same "open Settings to add one" message Fetch Data itself would. + ## Subtitles The Subtitles sidebar panel searches and downloads subtitle files from @@ -411,7 +449,10 @@ ported from, for anyone picking this up: items resolve against respectively (TMDB stays the default for both; a batch can mix sources across its TV and movie halves) — disambiguating multi-result searches via the similarity-scoring pass described in item 3 above rather than blindly trusting - any source's top-ranked hit. **OpenSubtitles is also done, but as a subtitle source + any source's top-ranked hit. The same per-season episode-list methods these three + clients already exposed for Fetch Data are also what the new Episodes panel browses + directly (see "Episodes" above) — no new client code, just a new UI on top of what + was already there. **OpenSubtitles is also done, but as a subtitle source rather than a fourth title-metadata source** — `crates/aurora-curator-core/src/web/opensubtitles.rs`, wired to the new Subtitles panel (see "Subtitles" above) rather than to Fetch Data's TV/movie pickers, since it fetches subtitle files, not episode/movie titles. All @@ -449,9 +490,11 @@ ported from, for anyone picking this up: 7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview, local TV/movie matching, confirmed rename with per-item exclude, rename history/undo, Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so - is the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above) — - the first of the other five sidebar panels to become a real screen. The remaining - four (Episodes/SFV/Filter/List) are still stub screens. Align Up/Down is button-based + are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above) + and the Episodes panel (series search + season browse with a Copy list export, no + credentials of its own, see "Episodes" above) — the first two of the other five + sidebar panels to become real screens. The remaining three (SFV/Filter/List) are + still stub screens. Align Up/Down is button-based (swaps the selected file with its neighbor), not the original's drag-and-drop-within-a-list interaction. 8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a diff --git a/src-tauri/src/episodes.rs b/src-tauri/src/episodes.rs new file mode 100644 index 0000000..a3af326 --- /dev/null +++ b/src-tauri/src/episodes.rs @@ -0,0 +1,93 @@ +//! The Tauri-facing side of the Episodes panel: search a series by name and browse one +//! season's full episode list, independent of any loaded local files. Unlike +//! Subtitles (`subtitles.rs`), this needs no credentials of its own - it reuses the +//! exact same `AnyTvClient`/`fetch_season` machinery "Fetch Data" already built (see +//! `metadata.rs`), via the shared `metadata::build_tv_client` helper, so this panel +//! automatically respects whichever TV source (TMDB/TVmaze/OMDb) is already configured +//! in Settings rather than needing its own. + +use serde::Serialize; +use tauri::AppHandle; + +use crate::metadata::build_tv_client; + +/// One episode row, as shown in the Episodes panel's list. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EpisodeRow { + pub episode_number: i32, + pub title: String, + pub air_date: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum EpisodeListOutcome { + Found { + series_name: String, + episodes: Vec, + }, + Failed { + error: String, + }, +} + +/// Searches for `series_query` against the currently configured TV source and returns +/// every episode of `season`. A season with a valid series match but zero episodes +/// (e.g. a season number past the end of the show) still comes back `Found` with an +/// empty list - it's a normal "nothing here" result, not a failure - so the frontend +/// can tell "found the show, this season is just empty" apart from "couldn't find the +/// show at all" (which `fetch_season` reports as an `Err` and this maps to `Failed`). +#[tauri::command] +pub async fn browse_episodes(app: AppHandle, series_query: String, season: i32) -> EpisodeListOutcome { + let trimmed = series_query.trim(); + if trimmed.is_empty() { + return EpisodeListOutcome::Failed { + error: "Enter a series name to search for.".to_string(), + }; + } + + let client = match build_tv_client(&app) { + Ok(client) => client, + Err(error) => return EpisodeListOutcome::Failed { error }, + }; + + match client.fetch_season(trimmed, season).await { + Ok((series_name, episodes)) => EpisodeListOutcome::Found { + series_name, + episodes: episodes + .into_iter() + .map(|e| EpisodeRow { + episode_number: e.episode_number, + title: e.title, + air_date: e.air_date, + }) + .collect(), + }, + Err(error) => EpisodeListOutcome::Failed { error }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // `browse_episodes` itself needs a running `AppHandle` (for `build_tv_client` to + // read settings), which unit tests here have no way to construct - same reason + // `subtitles.rs`'s `search_subtitles`/`download_subtitle` commands aren't + // unit-tested directly either. It's covered end-to-end (real query text, mocked + // Tauri backend) by the Playwright pass instead; what's unit-testable here is the + // plain data mapping below. + + #[test] + fn episode_row_maps_fields_straight_across() { + let row = EpisodeRow { + episode_number: 5, + title: "Ozymandias".to_string(), + air_date: Some("2013-09-15".to_string()), + }; + assert_eq!(row.episode_number, 5); + assert_eq!(row.title, "Ozymandias"); + assert_eq!(row.air_date.as_deref(), Some("2013-09-15")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b36dfc1..dfca365 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod cli; +mod episodes; mod files; mod history; mod metadata; @@ -115,6 +116,7 @@ pub fn run() { settings::save_opensubtitles_login, subtitles::search_subtitles, subtitles::download_subtitle, + episodes::browse_episodes, history::list_rename_history, history::undo_rename_batch, presets::list_presets, diff --git a/src-tauri/src/metadata.rs b/src-tauri/src/metadata.rs index 090e221..a4e2cca 100644 --- a/src-tauri/src/metadata.rs +++ b/src-tauri/src/metadata.rs @@ -210,6 +210,27 @@ fn failed_all(items: Vec, error: &str) -> Vec { .collect() } +/// Builds whichever TV metadata client `settings::tv_metadata_source` currently has +/// picked, reading that source's saved credential (if it needs one). Shared by +/// `fetch_metadata` below and, from the separate Episodes-browsing panel, +/// `episodes::browse_episodes` - both need "the currently configured TV client" and +/// neither should duplicate this match. `pub(crate)`: see `LocalEpisode`'s doc comment. +pub(crate) fn build_tv_client(app: &AppHandle) -> Result { + match settings::tv_metadata_source(app) { + TvMetadataSource::Tmdb => { + let token = settings::tmdb_token(app) + .ok_or_else(|| "No TMDB API token configured — open Settings to add one.".to_string())?; + Ok(AnyTvClient::Tmdb(TmdbClient::new(token))) + } + TvMetadataSource::Tvmaze => Ok(AnyTvClient::Tvmaze(TvmazeClient::new())), + TvMetadataSource::Omdb => { + let key = settings::omdb_api_key(app) + .ok_or_else(|| "No OMDb API key configured — open Settings to add one.".to_string())?; + Ok(AnyTvClient::Omdb(OmdbClient::new(key))) + } + } +} + /// Resolves a batch of local guesses against real TV metadata. Runs off the main /// thread since it's doing real network I/O; not `blocking_*`-style Tauri API, so no /// deadlock risk like the dialog commands had - `spawn` here is just to keep it @@ -221,20 +242,9 @@ fn failed_all(items: Vec, error: &str) -> Vec { /// using, elsewhere in this file). #[tauri::command] pub async fn fetch_metadata(app: AppHandle, items: Vec) -> Vec { - let client = match settings::tv_metadata_source(&app) { - TvMetadataSource::Tmdb => { - let Some(token) = settings::tmdb_token(&app) else { - return failed_all(items, "No TMDB API token configured — open Settings to add one."); - }; - AnyTvClient::Tmdb(TmdbClient::new(token)) - } - TvMetadataSource::Tvmaze => AnyTvClient::Tvmaze(TvmazeClient::new()), - TvMetadataSource::Omdb => { - let Some(key) = settings::omdb_api_key(&app) else { - return failed_all(items, "No OMDb API key configured — open Settings to add one."); - }; - AnyTvClient::Omdb(OmdbClient::new(key)) - } + let client = match build_tv_client(&app) { + Ok(client) => client, + Err(error) => return failed_all(items, &error), }; tauri::async_runtime::spawn(run_fetch(client, items)) diff --git a/src/App.tsx b/src/App.tsx index 3d733bb..7938c7a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import Sidebar, { PANELS } from "./components/Sidebar"; import Header from "./components/Header"; import RenamePanel from "./components/RenamePanel"; +import EpisodesPanel from "./components/EpisodesPanel"; import SubtitlesPanel from "./components/SubtitlesPanel"; import PanelStub from "./components/PanelStub"; @@ -17,6 +18,8 @@ export default function App() {
{selected === "rename" ? ( + ) : selected === "episodes" ? ( + ) : selected === "subtitles" ? ( ) : ( diff --git a/src/components/EpisodesPanel.css b/src/components/EpisodesPanel.css new file mode 100644 index 0000000..b310711 --- /dev/null +++ b/src/components/EpisodesPanel.css @@ -0,0 +1,134 @@ +.episodes-panel { + height: 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + overflow-y: auto; +} + +.episodes-search-form { + display: flex; + flex-direction: column; + gap: 8px; + background: var(--card); + border: 1px solid var(--surface-deep); + border-radius: 12px; + padding: 14px; +} + +.episodes-search-row-inline { + display: flex; + align-items: flex-end; + gap: 12px; +} + +.episodes-search-row-inline > label { + font-size: 11.5px; + color: var(--muted-text); +} + +.episodes-series-field, +.episodes-season-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.episodes-series-field { + flex: 1; + min-width: 140px; +} + +.episodes-series-field label, +.episodes-season-field label { + font-size: 11.5px; + color: var(--muted-text); +} + +.episodes-season-field { + flex: 0 0 auto; +} + +.episodes-season-stepper { + display: flex; + align-items: center; + gap: 6px; +} + +.episodes-season-input { + width: 56px; + text-align: center; +} + +.episodes-hint { + font-size: 11.5px; + color: var(--muted-text); + margin: 0; +} + +.episodes-results { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.episodes-results-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 2px 2px 4px; +} + +.episodes-series-name { + font-size: 13.5px; + font-weight: 600; +} + +.episodes-row { + display: flex; + align-items: center; + gap: 12px; + background: var(--card); + border: 1px solid var(--surface-deep); + border-radius: 10px; + padding: 9px 12px; +} + +.episodes-row-number { + flex: 0 0 28px; + font-size: 12px; + font-weight: 700; + color: var(--aurora-cyan); +} + +.episodes-row-title { + flex: 1; + min-width: 0; + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.episodes-row-date { + flex: 0 0 auto; + font-size: 11.5px; + color: var(--muted-text); +} + +.episodes-empty-note { + color: var(--muted-text); + font-size: 12.5px; + padding: 8px 2px; +} + +.episodes-status { + font-size: 12px; + color: var(--aurora-cyan); +} diff --git a/src/components/EpisodesPanel.tsx b/src/components/EpisodesPanel.tsx new file mode 100644 index 0000000..07009a9 --- /dev/null +++ b/src/components/EpisodesPanel.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface EpisodeRow { + episodeNumber: number; + title: string; + airDate: string | null; +} + +type EpisodeListOutcome = + | { status: "found"; seriesName: string; episodes: EpisodeRow[] } + | { status: "failed"; error: string }; + +export default function EpisodesPanel() { + const [seriesQuery, setSeriesQuery] = useState(""); + const [season, setSeason] = useState(1); + const [searching, setSearching] = useState(false); + const [seriesName, setSeriesName] = useState(null); + const [episodes, setEpisodes] = useState([]); + const [status, setStatus] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + + async function runBrowse(seasonToUse: number) { + const trimmed = seriesQuery.trim(); + if (!trimmed) { + setStatus("Enter a series name to search for."); + return; + } + + setSearching(true); + setStatus(null); + try { + const outcome = await invoke("browse_episodes", { + seriesQuery: trimmed, + season: seasonToUse, + }); + setHasSearched(true); + if (outcome.status === "found") { + setSeriesName(outcome.seriesName); + setEpisodes(outcome.episodes); + if (outcome.episodes.length === 0) { + setStatus(`${outcome.seriesName} — no episodes found for season ${seasonToUse}.`); + } + } else { + setSeriesName(null); + setEpisodes([]); + setStatus(outcome.error); + } + } catch (err) { + setSeriesName(null); + setEpisodes([]); + setStatus(String(err)); + } finally { + setSearching(false); + } + } + + function changeSeason(delta: number) { + const next = Math.max(1, season + delta); + setSeason(next); + if (hasSearched) { + runBrowse(next); + } + } + + function copyList() { + if (!seriesName || episodes.length === 0) return; + const lines = episodes.map( + (e) => `${seriesName} - ${season}x${String(e.episodeNumber).padStart(2, "0")} - ${e.title}` + ); + navigator.clipboard + .writeText(lines.join("\n")) + .then(() => setStatus(`Copied ${lines.length} episode names to the clipboard.`)) + .catch(() => setStatus("Couldn't copy to the clipboard.")); + } + + return ( +
+
+
+
+ + setSeriesQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") runBrowse(season); + }} + /> +
+
+ +
+ + setSeason(Math.max(1, Number(e.target.value) || 1))} + /> + +
+
+ +
+

+ Searches whichever TV source (TMDB, TVmaze, or OMDb) is set in the Rename panel's Fetch Data Settings — no + separate credentials needed here. +

+
+ +
+ {seriesName && episodes.length > 0 && ( +
+ + {seriesName} — Season {season} + + +
+ )} + + {episodes.length === 0 && !status && !searching && ( +
+ {hasSearched ? "No episodes to show." : "Search a series and season to browse its episode list."} +
+ )} + + {episodes.map((ep) => ( +
+ {String(ep.episodeNumber).padStart(2, "0")} + {ep.title} + {ep.airDate && {ep.airDate}} +
+ ))} +
+ + {status &&
{status}
} +
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index a64a5d5..1430920 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import "./App.css"; import "./components/Sidebar.css"; import "./components/Header.css"; import "./components/RenamePanel.css"; +import "./components/EpisodesPanel.css"; import "./components/SubtitlesPanel.css"; import "./components/PanelStub.css";