feat(web): mirror tv results in search client

This commit is contained in:
Miguel Palhas
2026-08-23 17:37:09 +01:00
parent 9f8d58fd58
commit 58f2f8361a
3 changed files with 130 additions and 20 deletions
+18 -11
View File
@@ -804,7 +804,9 @@ mod tests {
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
@@ -833,14 +835,16 @@ mod tests {
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search/movie"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
"id": 827_28, "name": "Bluey", "original_language": "en",
"id": 82_728, "name": "Bluey", "original_language": "en",
"first_air_date": "2018-10-01"
}]})),
)
@@ -861,12 +865,13 @@ mod tests {
.fetch_one(pool)
.await
.expect("series");
let season_id: i64 =
sqlx::query_scalar("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id")
.bind(series_id)
.fetch_one(pool)
.await
.expect("season");
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id",
)
.bind(series_id)
.fetch_one(pool)
.await
.expect("season");
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')")
.bind(season_id)
.execute(pool)
@@ -898,7 +903,7 @@ mod tests {
.expect("json");
assert_eq!(response["library"][0]["kind"], "series");
assert_eq!(response["library"][0]["title"], "Bluey");
assert_eq!(response["library"][0]["tmdb_id"], 827_28);
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
}
#[tokio::test]
@@ -926,7 +931,9 @@ mod tests {
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
+11 -4
View File
@@ -406,7 +406,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
return;
}
const inLibrary = new Set(response.library.map((movie) => movie.tmdb_id));
// Series and episode hits render in issue 130; movies keep the current row.
const libraryMovies = response.library.filter(
(hit): hit is LibraryMovie => hit.kind === "movie",
);
const inLibrary = new Set(libraryMovies.map((movie) => movie.tmdb_id));
if (response.library.length === 0 && response.tmdb.length === 0) {
setStatus("no matches in library or on tmdb");
return;
@@ -416,7 +420,7 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
if (response.library.length > 0) {
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of response.library) {
for (const movie of libraryMovies) {
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
@@ -428,8 +432,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
if (response.tmdb.length > 0) {
refs.groups.tmdb.section.hidden = false;
refs.groups.tmdb.count.textContent = String(response.tmdb.length);
for (const movie of response.tmdb) {
refs.groups.tmdb.rows.append(tmdbRow(movie, inLibrary.has(movie.tmdb_id), fetchRoots));
for (const hit of response.tmdb) {
if (hit.kind !== "movie") {
continue;
}
refs.groups.tmdb.rows.append(tmdbRow(hit, inLibrary.has(hit.tmdb_id), fetchRoots));
}
}
}
+101 -5
View File
@@ -1,10 +1,11 @@
// Hand-written mirror of arr-api's /api/search, /api/roots and POST
// /api/movies schemas — same reasoning as health.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
// Hand-written mirror of arr-api's /api/search, /api/roots, POST /api/movies
// and POST /api/series schemas — same reasoning as health.ts: the generated
// client (src/api/) is uncommitted, so CI's tsc cannot see it.
export type SearchInputKind = "text" | "tmdb_id" | "imdb_id" | "magnet" | "torrent_url";
export interface LibraryMovie {
kind: "movie";
id: number;
tmdb_id: number;
title: string;
@@ -18,7 +19,32 @@ export interface LibraryMovie {
waiver?: unknown;
}
export interface LibrarySeriesHit {
kind: "series";
id: number;
tmdb_id: number;
title: string;
year: number | null;
original_language: string | null;
root_id: number;
blocked: boolean;
}
export interface LibraryEpisodeHit {
kind: "episode";
episode_id: number;
series_id: number;
series_title: string;
/** `SxxEyy`, for context next to the episode title. */
tag: string;
/** The episode title — what the search matched on. */
title: string;
}
export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit;
export interface TmdbMovie {
kind: "movie";
tmdb_id: number;
title: string;
original_title: string;
@@ -27,10 +53,21 @@ export interface TmdbMovie {
overview: string | null;
}
export interface TmdbSeries {
kind: "series";
tmdb_id: number;
title: string;
original_language: string;
year: number | null;
overview: string | null;
}
export type TmdbResult = TmdbMovie | TmdbSeries;
export interface SearchResponse {
kind: SearchInputKind;
library: LibraryMovie[];
tmdb: TmdbMovie[];
library: LibraryResult[];
tmdb: TmdbResult[];
manual: string | null;
}
@@ -97,6 +134,29 @@ export async function movieRoots(): Promise<Root[]> {
return (await allRoots()).filter((root) => root.kind === "movie");
}
/** The TV roots. The series add flow pre-fills from these. */
export async function tvRoots(): Promise<Root[]> {
return (await allRoots()).filter((root) => root.kind === "tv");
}
/** The stored series a successful POST /api/series returns. */
export interface ApiSeries {
id: number;
tmdb_id: number;
tvdb_id: number | null;
title: string;
year: number | null;
original_language: string | null;
root_id: number;
auto_track: boolean;
overrides: Record<string, unknown>;
upstream_ended: boolean;
blocked: boolean;
status: string;
wanted_episodes: number;
available_episodes: number;
}
export type AddOutcome =
| { kind: "added"; movie: LibraryMovie }
| { kind: "conflict" }
@@ -127,6 +187,42 @@ export async function addMovie(movie: TmdbMovie, rootId: number): Promise<AddOut
}
}
export type AddSeriesOutcome =
| { kind: "added"; series: ApiSeries }
| { kind: "conflict" }
| { kind: "error"; detail: string };
/** Add a series to a TV root, with the §4.1 initial auto_track choice. */
export async function addSeries(
series: TmdbSeries,
rootId: number,
autoTrack: boolean,
): Promise<AddSeriesOutcome> {
try {
const response = await fetch("/api/series", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tmdb_id: series.tmdb_id,
title: series.title,
year: series.year,
original_language: series.original_language,
root_id: rootId,
auto_track: autoTrack,
}),
});
if (response.status === 409) {
return { kind: "conflict" };
}
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "added", series: (await response.json()) as ApiSeries };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };