feat(web): mirror tv results in search client
This commit is contained in:
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user