2026-08-29 15:35:27 +00:00
|
|
|
//! 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)]
|
2026-08-30 04:12:17 +00:00
|
|
|
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
2026-08-29 15:35:27 +00:00
|
|
|
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"));
|
|
|
|
|
}
|
2026-08-30 04:12:17 +00:00
|
|
|
|
|
|
|
|
// `#[serde(tag = "status", rename_all = "camelCase")]` alone only renames the tag's
|
|
|
|
|
// own values, not the field names inside a struct-like variant - see
|
|
|
|
|
// `metadata.rs`'s `serde_wire_format` tests for the full story and the bug this
|
|
|
|
|
// caught. `rename_all_fields = "camelCase"` on this enum is what actually renames
|
|
|
|
|
// `series_name` to `seriesName` on the wire; this test pins that.
|
|
|
|
|
#[test]
|
|
|
|
|
fn episode_list_outcome_found_serializes_with_camel_case_field_names() {
|
|
|
|
|
let outcome = EpisodeListOutcome::Found {
|
|
|
|
|
series_name: "Breaking Bad".to_string(),
|
|
|
|
|
episodes: vec![],
|
|
|
|
|
};
|
|
|
|
|
let json = serde_json::to_string(&outcome).unwrap();
|
|
|
|
|
assert!(json.contains("\"seriesName\":\"Breaking Bad\""), "got: {json}");
|
|
|
|
|
assert!(!json.contains("series_name"));
|
|
|
|
|
}
|
2026-08-29 15:35:27 +00:00
|
|
|
}
|