Add Episodes panel: search a series, browse a season's episode list
New src-tauri/src/episodes.rs exposes browse_episodes, reusing the existing AnyTvClient::fetch_season machinery Fetch Data already built (via a build_tv_client helper factored out of metadata.rs) so this panel needs no credentials of its own - it reads whatever TV source (TMDB/TVmaze/OMDb) is already configured in Fetch Data Settings. New EpisodesPanel.tsx/.css: series name + season stepper, an episode list (number/title/air date), and a Copy list button that copies the season as plain text. Wired into App.tsx's sidebar routing as the second stub panel (after Subtitles) to become a real screen. 145/145 Rust tests passing (+1), npm run build clean, 15/15 Playwright checks against npm run dev (mocked Tauri bridge) covering search, the empty-season case, a not-found series, the season stepper, Copy list, and the blank-query guard.
This commit is contained in:
@@ -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
|
[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
|
(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
|
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
|
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
|
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
|
download against OpenSubtitles' REST API; search only needs an
|
||||||
app-level API key, download additionally needs the end user's own
|
app-level API key, download additionally needs the end user's own
|
||||||
account login, since downloads are quota-limited per account —
|
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,
|
rename engine (`rename/` — Move/Copy/Symlink actions,
|
||||||
Skip/Auto-number/Override conflict policies, can also organize
|
Skip/Auto-number/Override conflict policies, can also organize
|
||||||
into (auto-created) subfolders when
|
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),
|
returns to the frontend, only whether one is saved),
|
||||||
`subtitles.rs` (search/download commands wrapping
|
`subtitles.rs` (search/download commands wrapping
|
||||||
`aurora-curator-core::web::opensubtitles`, saving a downloaded
|
`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
|
`history.rs` (rename history/undo
|
||||||
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
||||||
stored as JSON in the OS app-config directory — `cli.rs` (the
|
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)
|
panel (its own Subtitle Settings modal, its own credential state)
|
||||||
reached from the sidebar's "Subtitles" entry — the first sidebar
|
reached from the sidebar's "Subtitles" entry — the first sidebar
|
||||||
entry besides Rename to be a real screen rather than a stub.
|
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/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
|
||||||
(`scripts/logo/icon-source.png`, cropped from the supplied
|
(`scripts/logo/icon-source.png`, cropped from the supplied
|
||||||
lockup) — replace that source image to change the look.
|
lockup) — replace that source image to change the look.
|
||||||
@@ -227,7 +243,7 @@ npm run tauri dev
|
|||||||
npm run tauri build
|
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
|
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
|
against a local mock HTTP server via `mockito`, no live network or API key needed to
|
||||||
build/test), `cargo
|
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
|
organizing into a subfolder, and relaxing that invariant just for `--output` wasn't
|
||||||
judged worth it.
|
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
|
## Subtitles
|
||||||
|
|
||||||
The Subtitles sidebar panel searches and downloads subtitle files from
|
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
|
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
|
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
|
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`,
|
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
|
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
|
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,
|
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,
|
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
|
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) —
|
are 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
|
and the Episodes panel (series search + season browse with a Copy list export, no
|
||||||
four (Episodes/SFV/Filter/List) are still stub screens. Align Up/Down is button-based
|
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
|
(swaps the selected file with its neighbor), not the original's
|
||||||
drag-and-drop-within-a-list interaction.
|
drag-and-drop-within-a-list interaction.
|
||||||
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
|
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "status", rename_all = "camelCase")]
|
||||||
|
pub enum EpisodeListOutcome {
|
||||||
|
Found {
|
||||||
|
series_name: String,
|
||||||
|
episodes: Vec<EpisodeRow>,
|
||||||
|
},
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
|
mod episodes;
|
||||||
mod files;
|
mod files;
|
||||||
mod history;
|
mod history;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
@@ -115,6 +116,7 @@ pub fn run() {
|
|||||||
settings::save_opensubtitles_login,
|
settings::save_opensubtitles_login,
|
||||||
subtitles::search_subtitles,
|
subtitles::search_subtitles,
|
||||||
subtitles::download_subtitle,
|
subtitles::download_subtitle,
|
||||||
|
episodes::browse_episodes,
|
||||||
history::list_rename_history,
|
history::list_rename_history,
|
||||||
history::undo_rename_batch,
|
history::undo_rename_batch,
|
||||||
presets::list_presets,
|
presets::list_presets,
|
||||||
|
|||||||
+24
-14
@@ -210,6 +210,27 @@ fn failed_all(items: Vec<FetchQuery>, error: &str) -> Vec<FetchResult> {
|
|||||||
.collect()
|
.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<AnyTvClient, String> {
|
||||||
|
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
|
/// 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
|
/// 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
|
/// deadlock risk like the dialog commands had - `spawn` here is just to keep it
|
||||||
@@ -221,20 +242,9 @@ fn failed_all(items: Vec<FetchQuery>, error: &str) -> Vec<FetchResult> {
|
|||||||
/// using, elsewhere in this file).
|
/// using, elsewhere in this file).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||||
let client = match settings::tv_metadata_source(&app) {
|
let client = match build_tv_client(&app) {
|
||||||
TvMetadataSource::Tmdb => {
|
Ok(client) => client,
|
||||||
let Some(token) = settings::tmdb_token(&app) else {
|
Err(error) => return failed_all(items, &error),
|
||||||
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))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
tauri::async_runtime::spawn(run_fetch(client, items))
|
tauri::async_runtime::spawn(run_fetch(client, items))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import Sidebar, { PANELS } from "./components/Sidebar";
|
import Sidebar, { PANELS } from "./components/Sidebar";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import RenamePanel from "./components/RenamePanel";
|
import RenamePanel from "./components/RenamePanel";
|
||||||
|
import EpisodesPanel from "./components/EpisodesPanel";
|
||||||
import SubtitlesPanel from "./components/SubtitlesPanel";
|
import SubtitlesPanel from "./components/SubtitlesPanel";
|
||||||
import PanelStub from "./components/PanelStub";
|
import PanelStub from "./components/PanelStub";
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export default function App() {
|
|||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
{selected === "rename" ? (
|
{selected === "rename" ? (
|
||||||
<RenamePanel />
|
<RenamePanel />
|
||||||
|
) : selected === "episodes" ? (
|
||||||
|
<EpisodesPanel />
|
||||||
) : selected === "subtitles" ? (
|
) : selected === "subtitles" ? (
|
||||||
<SubtitlesPanel />
|
<SubtitlesPanel />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [episodes, setEpisodes] = useState<EpisodeRow[]>([]);
|
||||||
|
const [status, setStatus] = useState<string | null>(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<EpisodeListOutcome>("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 (
|
||||||
|
<div className="episodes-panel">
|
||||||
|
<div className="episodes-search-form">
|
||||||
|
<div className="episodes-search-row-inline">
|
||||||
|
<div className="episodes-series-field">
|
||||||
|
<label htmlFor="episodes-series">Series name</label>
|
||||||
|
<input
|
||||||
|
id="episodes-series"
|
||||||
|
className="modal-text-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Breaking Bad"
|
||||||
|
value={seriesQuery}
|
||||||
|
onChange={(e) => setSeriesQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") runBrowse(season);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="episodes-season-field">
|
||||||
|
<label htmlFor="episodes-season">Season</label>
|
||||||
|
<div className="episodes-season-stepper">
|
||||||
|
<button className="action-button" onClick={() => changeSeason(-1)} disabled={searching || season <= 1}>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
id="episodes-season"
|
||||||
|
className="modal-text-input episodes-season-input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={season}
|
||||||
|
onChange={(e) => setSeason(Math.max(1, Number(e.target.value) || 1))}
|
||||||
|
/>
|
||||||
|
<button className="action-button" onClick={() => changeSeason(1)} disabled={searching}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="action-button primary" onClick={() => runBrowse(season)} disabled={searching}>
|
||||||
|
{searching ? "Searching…" : "Browse"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="episodes-hint">
|
||||||
|
Searches whichever TV source (TMDB, TVmaze, or OMDb) is set in the Rename panel's Fetch Data Settings — no
|
||||||
|
separate credentials needed here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="episodes-results">
|
||||||
|
{seriesName && episodes.length > 0 && (
|
||||||
|
<div className="episodes-results-header">
|
||||||
|
<span className="episodes-series-name">
|
||||||
|
{seriesName} — Season {season}
|
||||||
|
</span>
|
||||||
|
<button className="action-button" onClick={copyList}>
|
||||||
|
Copy list
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{episodes.length === 0 && !status && !searching && (
|
||||||
|
<div className="episodes-empty-note">
|
||||||
|
{hasSearched ? "No episodes to show." : "Search a series and season to browse its episode list."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{episodes.map((ep) => (
|
||||||
|
<div className="episodes-row" key={ep.episodeNumber}>
|
||||||
|
<span className="episodes-row-number">{String(ep.episodeNumber).padStart(2, "0")}</span>
|
||||||
|
<span className="episodes-row-title">{ep.title}</span>
|
||||||
|
{ep.airDate && <span className="episodes-row-date">{ep.airDate}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <div className="episodes-status">{status}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import "./App.css";
|
|||||||
import "./components/Sidebar.css";
|
import "./components/Sidebar.css";
|
||||||
import "./components/Header.css";
|
import "./components/Header.css";
|
||||||
import "./components/RenamePanel.css";
|
import "./components/RenamePanel.css";
|
||||||
|
import "./components/EpisodesPanel.css";
|
||||||
import "./components/SubtitlesPanel.css";
|
import "./components/SubtitlesPanel.css";
|
||||||
import "./components/PanelStub.css";
|
import "./components/PanelStub.css";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user