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:
@@ -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 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,
|
||||
|
||||
+24
-14
@@ -210,6 +210,27 @@ fn failed_all(items: Vec<FetchQuery>, error: &str) -> Vec<FetchResult> {
|
||||
.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
|
||||
/// 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<FetchQuery>, error: &str) -> Vec<FetchResult> {
|
||||
/// using, elsewhere in this file).
|
||||
#[tauri::command]
|
||||
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user