1use crate::model::{ConfigDetails, FindResult, Movie, SearchMovie, SearchResult, Tv, TvSeason};
2
3pub trait Executable<T>
4where
5 T: serde::de::DeserializeOwned,
6{
7 fn finish(&self) -> HttpGet<T>;
8}
9
10pub trait Search<'a> {
11 fn title(&mut self, title: &'a str) -> &mut SearchData<'a>;
12 fn year(&mut self, year: u64) -> &mut SearchData<'a>;
13}
14
15#[derive(Clone, Debug)]
16pub struct SearchData<'a> {
17 tmdb: TMDb,
18 title: &'a str,
19 year: Option<u64>,
20}
21
22impl<'a> Search<'a> for SearchData<'a> {
23 fn title(&mut self, title: &'a str) -> &mut SearchData<'a> {
24 self.title = title;
25 self
26 }
27
28 fn year(&mut self, year: u64) -> &mut SearchData<'a> {
29 self.year = Some(year);
30 self
31 }
32}
33
34#[derive(Clone, Debug)]
35pub struct HttpGet<T>
36where
37 T: serde::de::DeserializeOwned,
38{
39 request_url: String,
40 response_ty: std::marker::PhantomData<T>,
41}
42impl<T> HttpGet<T>
43where
44 T: serde::de::DeserializeOwned,
45{
46 fn new(request_url: String) -> Self {
47 Self {
48 request_url,
49 response_ty: std::marker::PhantomData,
50 }
51 }
52
53 #[must_use]
55 pub fn request_url(&self) -> &str {
56 &self.request_url
57 }
58 pub fn receive_response_str(self, response: &str) -> Result<T, Error> {
66 self.receive_response(response.as_bytes())
67 }
68 pub fn receive_response(self, response: impl std::io::Read) -> Result<T, Error> {
74 serde_json::from_reader(response)
75 .map_err(ErrorKind::SerdeDecode)
76 .map_err(|kind| Error { kind })
77 }
78}
79
80#[derive(Debug)]
81pub struct Error {
82 kind: ErrorKind,
83}
84#[derive(Debug)]
85enum ErrorKind {
86 SerdeDecode(serde_json::Error),
87}
88impl std::error::Error for Error {
89 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
90 match &self.kind {
91 ErrorKind::SerdeDecode(e) => Some(e),
92 }
93 }
94}
95impl std::fmt::Display for Error {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 let Self { kind } = self;
98 match kind {
99 ErrorKind::SerdeDecode(_) => write!(f, "failed to decode JSON response"),
100 }
101 }
102}
103
104impl Executable<SearchResult> for SearchData<'_> {
105 fn finish(&self) -> HttpGet<SearchResult> {
106 let relative_url: String = match self.year {
107 None => format!(
108 "/search/movie?api_key={}&language={}&query={}&append_to_response=images",
109 self.tmdb.api_key, self.tmdb.language,
111 self.title
112 ),
113 Some(year) => format!(
114 "/search/movie?api_key={}&language={}&query={}&year={}&append_to_response=images",
115 self.tmdb.api_key, self.tmdb.language,
117 self.title,
118 year
119 ),
120 };
121 let url = build_url(&relative_url);
122 HttpGet::new(url)
123 }
124}
125
126#[derive(Clone, Copy, Debug)]
127pub enum Appendable {
128 Videos,
129 Credits,
130}
131
132pub trait Fetch {
133 fn id(&mut self, id: u64) -> &mut FetchData;
134 fn append_videos(&mut self) -> &mut FetchData;
135 fn append_credits(&mut self) -> &mut FetchData;
136}
137
138#[derive(Clone, Debug)]
139pub struct FetchData {
140 tmdb: TMDb,
141 id: u64,
142 append_to_response: Vec<Appendable>,
143}
144
145impl FetchData {
146 pub fn tv_season(self, season_number: u32) -> FetchDataTvSeason {
147 FetchDataTvSeason {
148 fetch_data: self,
149 season_number,
150 }
151 }
152}
153impl Fetch for FetchData {
154 fn id(&mut self, id: u64) -> &mut FetchData {
155 self.id = id;
156 self
157 }
158
159 fn append_videos(&mut self) -> &mut FetchData {
160 self.append_to_response.push(Appendable::Videos);
161 self
162 }
163
164 fn append_credits(&mut self) -> &mut FetchData {
165 self.append_to_response.push(Appendable::Credits);
166 self
167 }
168}
169
170impl Executable<Movie> for FetchData {
171 fn finish(&self) -> HttpGet<Movie> {
172 let mut relative_url: String = format!(
173 "/movie/{}?api_key={}&language={}",
174 self.id, self.tmdb.api_key,
176 self.tmdb.language
177 );
178
179 if !self.append_to_response.is_empty() {
180 relative_url.push_str("&append_to_response=");
181 for appendable in &self.append_to_response {
182 match appendable {
183 Appendable::Videos => relative_url.push_str("videos,"),
184 Appendable::Credits => relative_url.push_str("credits,"),
185 }
186 }
187 }
188
189 let url = build_url(&relative_url);
190
191 HttpGet::new(url)
192 }
193}
194
195impl Executable<Tv> for FetchData {
196 fn finish(&self) -> HttpGet<Tv> {
197 let mut relative_url: String = format!(
198 "/tv/{}?api_key={}&language={}",
199 self.id, self.tmdb.api_key,
201 self.tmdb.language
202 );
203
204 if !self.append_to_response.is_empty() {
205 relative_url.push_str("&append_to_response=");
206 for appendable in &self.append_to_response {
207 match appendable {
208 Appendable::Videos => relative_url.push_str("videos,"),
209 Appendable::Credits => relative_url.push_str("credits,"),
210 }
211 }
212 }
213
214 let url = build_url(&relative_url);
215
216 HttpGet::new(url)
217 }
218}
219
220#[derive(Clone, Debug)]
221#[must_use]
222pub struct FetchDataTvSeason {
223 fetch_data: FetchData,
224 season_number: u32,
225}
226impl Fetch for FetchDataTvSeason {
227 fn id(&mut self, id: u64) -> &mut FetchData {
228 self.fetch_data.id(id)
229 }
230 fn append_videos(&mut self) -> &mut FetchData {
231 self.fetch_data.append_videos()
232 }
233 fn append_credits(&mut self) -> &mut FetchData {
234 self.fetch_data.append_credits()
235 }
236}
237
238impl Executable<TvSeason> for FetchDataTvSeason {
239 fn finish(&self) -> HttpGet<TvSeason> {
240 let Self {
241 fetch_data:
242 FetchData {
243 tmdb: TMDb { api_key, language },
244 id,
245 append_to_response,
246 },
247 season_number,
248 } = self;
249
250 let mut relative_url: String =
251 format!("/tv/{id}/season/{season_number}?api_key={api_key}&language={language}");
252
253 if !append_to_response.is_empty() {
254 relative_url.push_str("&append_to_response=");
255 for appendable in append_to_response {
256 match appendable {
257 Appendable::Videos => relative_url.push_str("videos,"),
258 Appendable::Credits => relative_url.push_str("credits,"),
259 }
260 }
261 }
262
263 let url = build_url(&relative_url);
264
265 HttpGet::new(url)
266 }
267}
268
269pub trait Find<'a> {
270 fn imdb_id(&mut self, imdb_id: &'a str) -> &mut FindData<'a>;
271}
272
273#[derive(Clone, Debug)]
274pub struct FindData<'a> {
275 tmdb: TMDb,
276 imdb_id: &'a str,
277}
278
279impl<'a> Find<'a> for FindData<'a> {
280 fn imdb_id(&mut self, imdb_id: &'a str) -> &mut FindData<'a> {
281 self.imdb_id = imdb_id;
282 self
283 }
284}
285
286impl Executable<FindResult> for FindData<'_> {
287 fn finish(&self) -> HttpGet<FindResult> {
288 let relative_url = format!(
289 "/find/{}?api_key={}&external_source=imdb_id&language={}&append_to_response=images",
290 self.imdb_id, self.tmdb.api_key,
292 self.tmdb.language
293 );
294 let url = build_url(&relative_url);
295 HttpGet::new(url)
296 }
297}
298
299#[derive(Clone, Debug)]
300pub struct FetchConfig {
301 tmdb: TMDb,
302}
303impl Executable<ConfigDetails> for FetchConfig {
304 fn finish(&self) -> HttpGet<ConfigDetails> {
305 let relative_url = format!(
306 "/configuration?api_key={}&language={}",
307 self.tmdb.api_key, self.tmdb.language
308 );
309 let url = build_url(&relative_url);
310 HttpGet::new(url)
311 }
312}
313
314pub trait TMDbApi {
315 fn search_title<'a>(&self, title: &'a str) -> SearchData<'a>;
316 fn fetch_id(&self, id: u64) -> FetchData;
317 fn find_id<'a>(&self, tmdb_id: &'a str) -> FindData<'a>;
318}
319
320#[derive(Clone, Debug)]
321pub struct TMDb {
322 pub api_key: &'static str,
323 pub language: &'static str,
324}
325
326impl TMDbApi for TMDb {
327 fn search_title<'a>(&self, title: &'a str) -> SearchData<'a> {
328 let tmdb = self.clone();
329 SearchData {
330 tmdb,
331 title,
332 year: None,
333 }
334 }
335
336 fn fetch_id(&self, id: u64) -> FetchData {
337 let tmdb = self.clone();
338 FetchData {
339 tmdb,
340 id,
341 append_to_response: vec![],
342 }
343 }
344
345 fn find_id<'a>(&self, imdb_id: &'a str) -> FindData<'a> {
346 let tmdb = self.clone();
347 FindData { tmdb, imdb_id }
348 }
349}
350impl TMDb {
352 #[must_use]
353 pub fn fetch_config(&self) -> FetchConfig {
354 let tmdb = self.clone();
355 FetchConfig { tmdb }
356 }
357}
358
359pub trait Fetchable {
360 fn fetch(&self, tmdb: &TMDb) -> HttpGet<Movie>;
361}
362
363impl Fetchable for SearchMovie {
364 fn fetch(&self, tmdb: &TMDb) -> HttpGet<Movie> {
365 tmdb.fetch_id(self.id).finish()
366 }
367}
368
369fn build_url(relative_raw: &str) -> String {
370 const QUERY: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
372 .add(b' ')
373 .add(b'"')
374 .add(b'#')
375 .add(b'<')
376 .add(b'>');
377
378 const BASE_URL: &str = "https://api.themoviedb.org/3";
379
380 let absolute_raw = format!("{BASE_URL}{relative_raw}");
381
382 percent_encoding::utf8_percent_encode(&absolute_raw, QUERY).to_string()
383}