1use reqwest::Client;
10use serde_json::Value;
11use std::time::Duration;
12use tokio::sync::Mutex;
13use tracing::{debug, warn};
14
15use crate::backoff::BackoffConfig;
16use crate::device;
17use crate::discovery::discover_portal;
18use crate::error::StalkerError;
19use crate::session::StalkerSession;
20use crate::types::{
21 PaginatedResult, StalkerAccountInfo, StalkerCategory, StalkerChannel, StalkerCredentials,
22 StalkerEpgEntry, StalkerEpisode, StalkerProfile, StalkerSeason, StalkerSeriesItem,
23 StalkerVodItem,
24};
25
26const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
28
29const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
31
32const DEFAULT_CONCURRENCY: usize = 4;
34
35pub struct StalkerClient {
40 credentials: StalkerCredentials,
41 http: Client,
42 session: Option<StalkerSession>,
43 #[allow(dead_code)]
46 token_refresh_lock: Mutex<()>,
47 backoff: BackoffConfig,
49 concurrency: usize,
51 token_validity_secs: u64,
53}
54
55impl StalkerClient {
56 pub fn new(
62 credentials: StalkerCredentials,
63 accept_invalid_certs: bool,
64 ) -> Result<Self, StalkerError> {
65 let http = Client::builder()
66 .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
67 .timeout(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS))
68 .danger_accept_invalid_certs(accept_invalid_certs)
69 .build()?;
70
71 Ok(Self {
72 credentials,
73 http,
74 session: None,
75 token_refresh_lock: Mutex::new(()),
76 backoff: BackoffConfig::default(),
77 concurrency: DEFAULT_CONCURRENCY,
78 token_validity_secs: 3600,
79 })
80 }
81
82 pub fn with_http_client(credentials: StalkerCredentials, http: Client) -> Self {
85 Self {
86 credentials,
87 http,
88 session: None,
89 token_refresh_lock: Mutex::new(()),
90 backoff: BackoffConfig::default(),
91 concurrency: DEFAULT_CONCURRENCY,
92 token_validity_secs: 3600,
93 }
94 }
95
96 pub fn with_backoff(mut self, backoff: BackoffConfig) -> Self {
98 self.backoff = backoff;
99 self
100 }
101
102 pub fn with_concurrency(mut self, concurrency: usize) -> Self {
104 self.concurrency = concurrency.max(1);
105 self
106 }
107
108 pub fn with_token_validity(mut self, secs: u64) -> Self {
110 self.token_validity_secs = secs;
111 self
112 }
113
114 pub async fn authenticate(&mut self) -> Result<(), StalkerError> {
118 let portal_url = discover_portal(&self.http, &self.credentials.base_url).await?;
120 debug!(portal_url = %portal_url, "discovered portal");
121
122 let token = self.handshake(&portal_url).await?;
124 debug!("handshake successful, token obtained");
125
126 let session = StalkerSession::new(
128 token,
129 portal_url.clone(),
130 self.credentials.mac_address.clone(),
131 Some(self.token_validity_secs),
132 self.credentials.timezone.as_deref(),
133 );
134
135 self.do_auth(&session).await?;
137 debug!("authentication successful");
138
139 self.session = Some(session);
140
141 self.get_profile_internal().await?;
143 debug!("profile fetched, session fully active");
144
145 Ok(())
146 }
147
148 async fn handshake(&self, portal_url: &str) -> Result<String, StalkerError> {
154 let url = format!("{portal_url}?type=stb&action=handshake&token=&JsHttpRequest=1-xml");
155
156 let mac_cookie = build_mac_cookie(
157 &self.credentials.mac_address,
158 self.credentials.timezone.as_deref(),
159 );
160
161 let resp = self
163 .http
164 .get(&url)
165 .header("Cookie", &mac_cookie)
166 .header(
167 "User-Agent",
168 "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3",
169 )
170 .header("X-User-Agent", "Model: MAG250; Link: WiFi")
171 .send()
172 .await?;
173
174 if resp.status().as_u16() == 404 {
175 debug!("handshake returned 404, retrying with prehash");
177 let gen_token = device::generate_token();
178 let prehash = device::generate_prehash(&gen_token);
179
180 let retry_url = format!(
181 "{portal_url}?type=stb&action=handshake&token={gen_token}&prehash={prehash}&JsHttpRequest=1-xml"
182 );
183
184 let retry_resp = self
185 .http
186 .get(&retry_url)
187 .header("Cookie", &mac_cookie)
188 .header(
189 "User-Agent",
190 "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3",
191 )
192 .header("X-User-Agent", "Model: MAG250; Link: WiFi")
193 .send()
194 .await?;
195
196 let body: Value = retry_resp.json().await?;
197 return extract_token(&body);
198 }
199
200 let body: Value = resp.json().await?;
201 extract_token(&body)
202 }
203
204 async fn do_auth(&self, session: &StalkerSession) -> Result<(), StalkerError> {
206 let url = format!(
207 "{}?type=stb&action=do_auth&login={}&password=&device_id={}&device_id2={}",
208 session.portal_url, session.device_id, session.device_id, session.device_id2,
209 );
210
211 let resp = self
212 .http
213 .get(&url)
214 .header("Cookie", session.cookie_header())
215 .header("Authorization", session.auth_header())
216 .send()
217 .await?;
218
219 let body: Value = resp.json().await?;
220
221 if let Some(js) = body.get("js")
223 && js.as_bool() == Some(false)
224 {
225 return Err(StalkerError::Auth("do_auth returned false".into()));
226 }
227
228 Ok(())
229 }
230
231 async fn get_profile_internal(&mut self) -> Result<(), StalkerError> {
236 let session = self
237 .session
238 .as_ref()
239 .ok_or(StalkerError::NotAuthenticated)?;
240
241 let timestamp = std::time::SystemTime::now()
242 .duration_since(std::time::UNIX_EPOCH)
243 .unwrap_or_default()
244 .as_secs()
245 .to_string();
246
247 let params = [
248 ("type", "stb"),
249 ("action", "get_profile"),
250 ("hd", "1"),
251 ("num_banks", "2"),
252 ("stb_type", "MAG250"),
253 ("client_type", "STB"),
254 ("image_version", "218"),
255 ("video_out", "hdmi"),
256 ("auth_second_step", "1"),
257 ("hw_version", "1.7-BD-00"),
258 ("not_valid_token", "0"),
259 ("api_signature", "262"),
260 ("prehash", ""),
261 ("JsHttpRequest", "1-xml"),
262 ];
263
264 let sn = session.serial.clone();
265 let device_id = session.device_id.clone();
266 let device_id2 = session.device_id2.clone();
267 let signature = session.signature();
268 let metrics = session.metrics();
269 let hw_version_2 = session.hw_version_2();
270 let portal_url = session.portal_url.clone();
271
272 let headers = session.full_headers(false);
275
276 let ver = "ImageDescription: 0.2.18-r23-250; ImageDate: Thu Sep 13 11:31:16 EEST 2018; PORTAL version: 5.6.2; API Version: JS API version: 343; STB API version: 146; Player Engine version: 0x58c";
277
278 let mut request = self.http.get(&portal_url).query(¶ms).query(&[
279 ("sn", sn.as_str()),
280 ("device_id", device_id.as_str()),
281 ("device_id2", device_id2.as_str()),
282 ("signature", signature.as_str()),
283 ("metrics", metrics.as_str()),
284 ("hw_version_2", hw_version_2.as_str()),
285 ("timestamp", timestamp.as_str()),
286 ("ver", ver),
287 ]);
288
289 for (key, value) in &headers {
290 request = request.header(key.as_str(), value.as_str());
291 }
292
293 let resp = request.send().await?;
294 let body: Value = resp.json().await?;
295
296 if let Some(js) = body.get("js")
298 && let Some(new_token) = js.get("token").and_then(|t| t.as_str())
299 && let Some(session) = self.session.as_mut()
300 {
301 session.refresh_token(new_token.to_string());
302 debug!("token refreshed from profile response");
303 }
304
305 Ok(())
306 }
307
308 pub async fn ensure_token(&mut self) -> Result<(), StalkerError> {
313 let needs_refresh = self
314 .session
315 .as_ref()
316 .map(super::session::StalkerSession::is_token_expired)
317 .unwrap_or(true);
318
319 if needs_refresh {
320 debug!("token expired, re-authenticating");
321 self.authenticate().await?;
322 }
323
324 Ok(())
325 }
326
327 fn session(&self) -> Result<&StalkerSession, StalkerError> {
329 self.session.as_ref().ok_or(StalkerError::NotAuthenticated)
330 }
331
332 async fn portal_get(&self, query: &str) -> Result<Value, StalkerError> {
336 let session = self.session()?;
337 let url = format!("{}?{query}", session.portal_url);
338
339 let mut last_error: Option<StalkerError> = None;
340
341 for attempt in 1..=(self.backoff.max_retries + 1) {
342 let result = self
343 .http
344 .get(&url)
345 .header("Cookie", session.cookie_header_with_token())
346 .header("Authorization", session.auth_header())
347 .header(
348 "User-Agent",
349 "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3",
350 )
351 .header("X-User-Agent", "Model: MAG250; Link: WiFi")
352 .send()
353 .await;
354
355 match result {
356 Ok(resp) => {
357 let status = resp.status();
358 if status.as_u16() == 401 || status.as_u16() == 403 {
359 return Err(StalkerError::SessionExpired);
360 }
361
362 match resp.json::<Value>().await {
363 Ok(body) => return Ok(body),
364 Err(e) => {
365 last_error = Some(StalkerError::Network(e));
366 }
367 }
368 }
369 Err(e) => {
370 last_error = Some(StalkerError::Network(e));
371 }
372 }
373
374 if self.backoff.should_retry(attempt) {
375 let delay = self.backoff.delay_for_attempt(attempt);
376 debug!(
377 attempt = attempt,
378 delay_ms = delay.as_millis(),
379 "retrying after backoff"
380 );
381 tokio::time::sleep(delay).await;
382 }
383 }
384
385 Err(last_error.unwrap_or_else(|| {
386 StalkerError::Network(
387 reqwest::Client::new()
388 .get("http://unreachable")
389 .build()
390 .unwrap_err(),
391 )
392 }))
393 }
394
395 pub async fn get_account_info(&self) -> Result<StalkerAccountInfo, StalkerError> {
397 let body = self
398 .portal_get("type=account_info&action=get_main_info")
399 .await?;
400
401 let js = body
402 .get("js")
403 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
404
405 Ok(StalkerAccountInfo {
406 login: json_str(js, "login"),
407 mac: json_str(js, "mac"),
408 status: json_str(js, "status").or_else(|| {
409 js.get("status")
410 .and_then(serde_json::Value::as_u64)
411 .map(|n| n.to_string())
412 }),
413 expiration: json_str(js, "expire_billing_date").or_else(|| json_str(js, "phone")),
414 subscribed_till: json_str(js, "subscribed_till"),
415 })
416 }
417
418 pub async fn get_profile(&self) -> Result<StalkerProfile, StalkerError> {
420 let body = self.portal_get("type=stb&action=get_profile").await?;
421
422 let js = body
423 .get("js")
424 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
425
426 Ok(StalkerProfile {
427 timezone: json_str(js, "timezone"),
428 locale: json_str(js, "locale"),
429 })
430 }
431
432 pub async fn get_genres(&self) -> Result<Vec<StalkerCategory>, StalkerError> {
434 let body = self.portal_get("type=itv&action=get_genres").await?;
435
436 let js = body
437 .get("js")
438 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
439
440 let arr = js
441 .as_array()
442 .ok_or_else(|| StalkerError::UnexpectedResponse("expected array for genres".into()))?;
443
444 Ok(arr.iter().map(parse_category).collect())
445 }
446
447 pub async fn get_vod_categories(&self) -> Result<Vec<StalkerCategory>, StalkerError> {
449 let body = self.portal_get("type=vod&action=get_categories").await?;
450
451 let js = body
452 .get("js")
453 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
454
455 let arr = js.as_array().ok_or_else(|| {
456 StalkerError::UnexpectedResponse("expected array for vod categories".into())
457 })?;
458
459 Ok(arr.iter().map(parse_category).collect())
460 }
461
462 pub async fn get_series_categories(&self) -> Result<Vec<StalkerCategory>, StalkerError> {
464 let body = self.portal_get("type=series&action=get_categories").await?;
465
466 let js = body
467 .get("js")
468 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
469
470 let arr = js.as_array().ok_or_else(|| {
471 StalkerError::UnexpectedResponse("expected array for series categories".into())
472 })?;
473
474 Ok(arr.iter().map(parse_category).collect())
475 }
476
477 pub async fn get_channels_page(
479 &self,
480 genre_id: &str,
481 page: u32,
482 ) -> Result<PaginatedResult<StalkerChannel>, StalkerError> {
483 let query = format!("type=itv&action=get_ordered_list&genre={genre_id}&p={page}");
484 let body = self.portal_get(&query).await?;
485 parse_paginated(&body, parse_channel)
486 }
487
488 pub async fn get_all_channels(
495 &self,
496 genre_id: &str,
497 on_progress: Option<&dyn Fn(u32, u32)>,
498 ) -> Result<Vec<StalkerChannel>, StalkerError> {
499 self.fetch_all_pages_parallel(
500 &format!("type=itv&action=get_ordered_list&genre={genre_id}"),
501 parse_channel,
502 on_progress,
503 )
504 .await
505 }
506
507 pub async fn get_vod_page(
509 &self,
510 category_id: &str,
511 page: u32,
512 ) -> Result<PaginatedResult<StalkerVodItem>, StalkerError> {
513 let query = format!("type=vod&action=get_ordered_list&category={category_id}&p={page}");
514 let body = self.portal_get(&query).await?;
515 parse_paginated(&body, parse_vod_item)
516 }
517
518 pub async fn get_all_vod(
524 &self,
525 category_id: &str,
526 on_progress: Option<&dyn Fn(u32, u32)>,
527 ) -> Result<Vec<StalkerVodItem>, StalkerError> {
528 let all = self
529 .fetch_all_pages_parallel(
530 &format!("type=vod&action=get_ordered_list&category={category_id}"),
531 parse_vod_item_raw,
532 on_progress,
533 )
534 .await?;
535
536 Ok(all
538 .into_iter()
539 .filter(|(_, is_series)| !is_series)
540 .map(|(item, _)| item)
541 .collect())
542 }
543
544 pub async fn get_all_series(
550 &self,
551 category_id: &str,
552 on_progress: Option<&dyn Fn(u32, u32)>,
553 ) -> Result<Vec<StalkerSeriesItem>, StalkerError> {
554 let all = self
555 .fetch_all_pages_parallel(
556 &format!("type=vod&action=get_ordered_list&category={category_id}"),
557 parse_series_with_flag,
558 on_progress,
559 )
560 .await?;
561
562 Ok(all
564 .into_iter()
565 .filter(|(_, is_series)| *is_series)
566 .map(|(item, _)| item)
567 .collect())
568 }
569
570 pub async fn get_series_page(
572 &self,
573 category_id: &str,
574 page: u32,
575 ) -> Result<PaginatedResult<StalkerSeriesItem>, StalkerError> {
576 let query = format!("type=series&action=get_ordered_list&category={category_id}&p={page}");
577 let body = self.portal_get(&query).await?;
578 parse_paginated(&body, parse_series_item)
579 }
580
581 pub async fn get_seasons(&self, movie_id: &str) -> Result<Vec<StalkerSeason>, StalkerError> {
586 let query = format!(
587 "type=vod&action=get_ordered_list&movie_id={movie_id}&season_id=0&episode_id=0&JsHttpRequest=1-xml"
588 );
589 let body = self.portal_get(&query).await?;
590
591 let js = body
592 .get("js")
593 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
594
595 let data = js
596 .get("data")
597 .and_then(|d| d.as_array())
598 .unwrap_or(&Vec::new())
599 .clone();
600
601 let seasons: Vec<StalkerSeason> = data
602 .iter()
603 .filter(|item| {
604 let is_season = item.get("is_season");
606 matches!(
607 is_season,
608 Some(Value::Bool(true) | Value::Number(_) | Value::String(_))
609 ) && is_season
610 .map(|v| {
611 v.as_bool().unwrap_or(false)
612 || v.as_u64().unwrap_or(0) != 0
613 || v.as_str().map(|s| s == "1" || s == "true").unwrap_or(false)
614 })
615 .unwrap_or(false)
616 })
617 .map(|item| {
618 let season_id = json_str(item, "id").unwrap_or_default();
619 let video_id = json_str(item, "video_id")
620 .or_else(|| json_str(item, "movie_id"))
621 .unwrap_or_else(|| movie_id.to_string());
622
623 let resolved_movie_id = if video_id == season_id {
625 movie_id.to_string()
626 } else {
627 video_id
628 };
629
630 StalkerSeason {
631 id: season_id,
632 name: json_str(item, "name").unwrap_or_default(),
633 movie_id: resolved_movie_id,
634 logo: json_str(item, "screenshot_uri").or_else(|| json_str(item, "logo")),
635 description: json_str(item, "description"),
636 }
637 })
638 .collect();
639
640 debug!(
641 count = seasons.len(),
642 movie_id = movie_id,
643 "fetched seasons"
644 );
645 Ok(seasons)
646 }
647
648 pub async fn get_episodes(
652 &self,
653 movie_id: &str,
654 season_id: &str,
655 ) -> Result<Vec<StalkerEpisode>, StalkerError> {
656 let query = format!(
657 "type=vod&action=get_ordered_list&movie_id={movie_id}&season_id={season_id}&episode_id=0&JsHttpRequest=1-xml"
658 );
659 let body = self.portal_get(&query).await?;
660
661 let js = body
662 .get("js")
663 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
664
665 let data = js
666 .get("data")
667 .and_then(|d| d.as_array())
668 .unwrap_or(&Vec::new())
669 .clone();
670
671 let episodes: Vec<StalkerEpisode> = data
672 .iter()
673 .filter_map(|item| {
674 let id = json_str(item, "id")?;
675 Some(StalkerEpisode {
676 id,
677 name: json_str(item, "name").unwrap_or_default(),
678 movie_id: movie_id.to_string(),
679 season_id: season_id.to_string(),
680 episode_number: json_u32(item, "series_number"),
681 cmd: json_str(item, "cmd").unwrap_or_default(),
682 logo: json_str(item, "screenshot_uri").or_else(|| json_str(item, "logo")),
683 description: json_str(item, "description"),
684 duration: json_str(item, "time").or_else(|| json_str(item, "length")),
685 })
686 })
687 .collect();
688
689 debug!(
690 count = episodes.len(),
691 movie_id = movie_id,
692 season_id = season_id,
693 "fetched episodes"
694 );
695 Ok(episodes)
696 }
697
698 pub async fn get_series_info(
704 &self,
705 series: StalkerSeriesItem,
706 ) -> Result<crate::types::StalkerSeriesDetail, StalkerError> {
707 let seasons = self.get_seasons(&series.id).await?;
708
709 let mut episodes = std::collections::HashMap::new();
710 for season in &seasons {
711 let eps = self.get_episodes(&series.id, &season.id).await?;
712 episodes.insert(season.id.clone(), eps);
713 }
714
715 Ok(crate::types::StalkerSeriesDetail {
716 series,
717 seasons,
718 episodes,
719 })
720 }
721
722 pub async fn get_epg(
727 &self,
728 channel_id: &str,
729 size: u32,
730 ) -> Result<Vec<StalkerEpgEntry>, StalkerError> {
731 let short_query = format!(
733 "type=itv&action=get_short_epg&ch_id={channel_id}&size={size}&JsHttpRequest=1-xml"
734 );
735 if let Ok(body) = self.portal_get(&short_query).await {
736 let entries = parse_epg_response(&body);
737 if !entries.is_empty() {
738 debug!(
739 count = entries.len(),
740 channel_id = channel_id,
741 "EPG from get_short_epg"
742 );
743 return Ok(entries);
744 }
745 }
746
747 debug!(channel_id = channel_id, "falling back to get_epg_info");
749 let info_query =
750 format!("type=itv&action=get_epg_info&ch_id={channel_id}&JsHttpRequest=1-xml");
751 let body = self.portal_get(&info_query).await?;
752 let entries = parse_epg_response(&body);
753
754 debug!(
755 count = entries.len(),
756 channel_id = channel_id,
757 "EPG from get_epg_info"
758 );
759 Ok(entries)
760 }
761
762 pub async fn create_link(&self, cmd: &str) -> Result<String, StalkerError> {
768 let encoded_cmd =
769 percent_encoding::utf8_percent_encode(cmd, percent_encoding::NON_ALPHANUMERIC)
770 .to_string();
771 let query = format!(
772 "type=itv&action=create_link&cmd={encoded_cmd}&forced_storage=undefined&disable_ad=0&JsHttpRequest=1-xml"
773 );
774 let body = self.portal_get(&query).await?;
775
776 let js = body
777 .get("js")
778 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
779
780 let url_value = js
783 .get("url")
784 .and_then(|v| v.as_str())
785 .filter(|s| !s.is_empty());
786
787 let cmd_value = js.get("cmd").and_then(|v| v.as_str());
788
789 let raw_url = url_value.or(cmd_value).ok_or_else(|| {
790 StalkerError::UnexpectedResponse("missing 'cmd' in create_link response".into())
791 })?;
792
793 let base = self
795 .session
796 .as_ref()
797 .map(|s| s.portal_url.as_str())
798 .unwrap_or(&self.credentials.base_url);
799
800 crate::url::resolve_stream_url(raw_url, base).ok_or_else(|| {
801 StalkerError::UnexpectedResponse("create_link returned empty cmd".into())
802 })
803 }
804
805 pub async fn keepalive(&self) -> Result<(), StalkerError> {
807 let body = self.portal_get("type=watchdog&action=get_events").await?;
808
809 if let Some(js) = body.get("js")
811 && js.as_bool() == Some(false)
812 {
813 warn!("keepalive watchdog returned false — session may be expired");
814 return Err(StalkerError::SessionExpired);
815 }
816
817 debug!("keepalive sent successfully");
818 Ok(())
819 }
820
821 pub fn is_authenticated(&self) -> bool {
823 self.session.is_some()
824 }
825
826 pub fn portal_url(&self) -> Option<&str> {
828 self.session.as_ref().map(|s| s.portal_url.as_str())
829 }
830
831 pub fn is_token_expired(&self) -> bool {
833 self.session
834 .as_ref()
835 .map(super::session::StalkerSession::is_token_expired)
836 .unwrap_or(true)
837 }
838
839 async fn fetch_all_pages_parallel<T: Send + 'static>(
846 &self,
847 base_query: &str,
848 parse_fn: fn(&Value) -> T,
849 on_progress: Option<&dyn Fn(u32, u32)>,
850 ) -> Result<Vec<T>, StalkerError> {
851 let first_query = format!("{base_query}&p=1");
853 let first_body = self.portal_get(&first_query).await?;
854 let first_result = parse_paginated(&first_body, parse_fn)?;
855
856 let total_pages = first_result.total_pages();
857 let mut all_items = first_result.items;
858 let mut completed_pages = 1u32;
859
860 if let Some(cb) = on_progress {
861 cb(completed_pages, total_pages);
862 }
863
864 debug!(
865 page = 1,
866 total_pages = total_pages,
867 collected = all_items.len(),
868 total = first_result.total_items,
869 "fetched first page"
870 );
871
872 if total_pages <= 1 {
873 return Ok(all_items);
874 }
875
876 let remaining_pages: Vec<u32> = (2..=total_pages).collect();
878
879 for batch in remaining_pages.chunks(self.concurrency) {
880 let mut results = Vec::with_capacity(batch.len());
881
882 for &page in batch {
884 let query = format!("{base_query}&p={page}");
885 match self.portal_get(&query).await {
886 Ok(body) => results.push((page, body)),
887 Err(e) => {
888 warn!(page = page, error = %e, "failed to fetch page");
889 }
890 }
891 }
892
893 results.sort_by_key(|(page, _)| *page);
895
896 for (page, body) in results {
897 match parse_paginated(&body, parse_fn) {
898 Ok(result) => {
899 debug!(page = page, items = result.items.len(), "fetched page");
900 all_items.extend(result.items);
901 }
902 Err(e) => {
903 warn!(page = page, error = %e, "failed to parse page");
904 }
905 }
906
907 completed_pages += 1;
908 if let Some(cb) = on_progress {
909 cb(completed_pages, total_pages);
910 }
911 }
912 }
913
914 Ok(all_items)
915 }
916
917 #[allow(dead_code)]
919 async fn fetch_all_pages<T>(
920 &self,
921 base_query: &str,
922 parse_fn: fn(&Value) -> T,
923 ) -> Result<Vec<T>, StalkerError> {
924 let mut all_items = Vec::new();
925 let mut page = 1u32;
926
927 loop {
928 let query = format!("{base_query}&p={page}");
929 let body = self.portal_get(&query).await?;
930 let result = parse_paginated(&body, parse_fn)?;
931
932 let total_pages = result.total_pages();
933 all_items.extend(result.items);
934
935 debug!(
936 page = page,
937 total_pages = total_pages,
938 collected = all_items.len(),
939 total = result.total_items,
940 "fetched page"
941 );
942
943 if page >= total_pages || total_pages == 0 {
944 break;
945 }
946 page += 1;
947 }
948
949 Ok(all_items)
950 }
951}
952
953const COOKIE_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
958 .remove(b'-')
959 .remove(b'_')
960 .remove(b'.')
961 .remove(b'~');
962
963fn build_mac_cookie(mac: &str, timezone: Option<&str>) -> String {
965 let encoded = percent_encoding::utf8_percent_encode(mac, COOKIE_ENCODE_SET).to_string();
966 let tz = timezone.filter(|s| !s.is_empty()).unwrap_or("Europe/Paris");
967 let encoded_tz = percent_encoding::utf8_percent_encode(tz, COOKIE_ENCODE_SET).to_string();
968 format!("mac={encoded}; stb_lang=en; timezone={encoded_tz}")
969}
970
971fn extract_token(body: &Value) -> Result<String, StalkerError> {
973 body.get("js")
974 .and_then(|js| js.get("token"))
975 .and_then(|t| t.as_str())
976 .map(std::string::ToString::to_string)
977 .ok_or_else(|| StalkerError::HandshakeFailed(format!("no token in response: {body}")))
978}
979
980fn parse_paginated<T>(
982 body: &Value,
983 parse_fn: fn(&Value) -> T,
984) -> Result<PaginatedResult<T>, StalkerError> {
985 let js = body
986 .get("js")
987 .ok_or_else(|| StalkerError::UnexpectedResponse("missing 'js' field".into()))?;
988
989 #[allow(clippy::cast_possible_truncation)]
990 let total_items = js
991 .get("total_items")
992 .and_then(|v| {
993 v.as_u64()
994 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
995 })
996 .unwrap_or(0) as u32;
997
998 #[allow(clippy::cast_possible_truncation)]
999 let max_page_items = js
1000 .get("max_page_items")
1001 .and_then(|v| {
1002 v.as_u64()
1003 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1004 })
1005 .unwrap_or(20) as u32;
1006
1007 let data = js
1008 .get("data")
1009 .and_then(|d| d.as_array())
1010 .map(|arr| arr.iter().map(parse_fn).collect())
1011 .unwrap_or_default();
1012
1013 Ok(PaginatedResult {
1014 items: data,
1015 total_items,
1016 max_page_items,
1017 })
1018}
1019
1020fn json_str(v: &Value, key: &str) -> Option<String> {
1022 v.get(key).and_then(|val| {
1023 val.as_str()
1024 .map(std::string::ToString::to_string)
1025 .or_else(|| {
1026 if val.is_number() {
1027 Some(val.to_string())
1028 } else {
1029 None
1030 }
1031 })
1032 })
1033}
1034
1035fn json_u32(v: &Value, key: &str) -> Option<u32> {
1037 v.get(key).and_then(|val| {
1038 val.as_u64()
1039 .and_then(|n| u32::try_from(n).ok())
1040 .or_else(|| val.as_str().and_then(|s| s.parse().ok()))
1041 })
1042}
1043
1044fn json_bool(v: &Value, key: &str) -> bool {
1046 v.get(key)
1047 .map(|val| {
1048 val.as_bool().unwrap_or_else(|| {
1049 val.as_u64().map(|n| n != 0).unwrap_or_else(|| {
1050 val.as_str()
1051 .map(|s| s != "0" && !s.is_empty())
1052 .unwrap_or(false)
1053 })
1054 })
1055 })
1056 .unwrap_or(false)
1057}
1058
1059fn json_i64(v: &Value, key: &str) -> Option<i64> {
1061 v.get(key).and_then(|val| {
1062 val.as_i64()
1063 .or_else(|| val.as_str().and_then(|s| s.parse().ok()))
1064 })
1065}
1066
1067fn parse_channel(v: &Value) -> StalkerChannel {
1069 StalkerChannel {
1070 id: json_str(v, "id").unwrap_or_default(),
1071 name: json_str(v, "name").unwrap_or_default(),
1072 number: json_u32(v, "number"),
1073 cmd: json_str(v, "cmd").unwrap_or_default(),
1074 tv_genre_id: json_str(v, "tv_genre_id"),
1075 logo: json_str(v, "logo").filter(|s| !s.is_empty()),
1076 epg_channel_id: json_str(v, "xmltv_id").or_else(|| json_str(v, "epg_channel_id")),
1077 has_archive: json_bool(v, "tv_archive"),
1078 archive_days: json_u32(v, "tv_archive_duration").unwrap_or(0),
1079 is_censored: json_bool(v, "censored"),
1080 }
1081}
1082
1083fn parse_vod_item(v: &Value) -> StalkerVodItem {
1085 StalkerVodItem {
1086 id: json_str(v, "id").unwrap_or_default(),
1087 name: json_str(v, "name").unwrap_or_default(),
1088 cmd: json_str(v, "cmd").unwrap_or_default(),
1089 category_id: json_str(v, "category_id"),
1090 logo: json_str(v, "screenshot_uri").or_else(|| json_str(v, "logo")),
1091 description: json_str(v, "description"),
1092 year: json_str(v, "year"),
1093 genre: json_str(v, "genre_str").or_else(|| json_str(v, "genres_str")),
1094 rating: json_str(v, "rating_imdb").or_else(|| json_str(v, "rating_kinopoisk")),
1095 director: json_str(v, "director"),
1096 cast: json_str(v, "actors"),
1097 duration: json_str(v, "time").or_else(|| json_str(v, "length")),
1098 tmdb_id: json_i64(v, "tmdb_id"),
1099 }
1100}
1101
1102fn parse_vod_item_raw(v: &Value) -> (StalkerVodItem, bool) {
1106 let item = parse_vod_item(v);
1107 let is_series = json_str(v, "is_series").map(|s| s == "1").unwrap_or(false);
1108 (item, is_series)
1109}
1110
1111fn parse_series_item(v: &Value) -> StalkerSeriesItem {
1113 StalkerSeriesItem {
1114 id: json_str(v, "id").unwrap_or_default(),
1115 name: json_str(v, "name").unwrap_or_default(),
1116 category_id: json_str(v, "category_id"),
1117 logo: json_str(v, "screenshot_uri").or_else(|| json_str(v, "logo")),
1118 description: json_str(v, "description"),
1119 year: json_str(v, "year"),
1120 genre: json_str(v, "genre_str").or_else(|| json_str(v, "genres_str")),
1121 rating: json_str(v, "rating_imdb").or_else(|| json_str(v, "rating_kinopoisk")),
1122 director: json_str(v, "director"),
1123 cast: json_str(v, "actors"),
1124 }
1125}
1126
1127fn parse_series_with_flag(v: &Value) -> (StalkerSeriesItem, bool) {
1129 let item = parse_series_item(v);
1130 let is_series = json_str(v, "is_series").map(|s| s == "1").unwrap_or(false);
1131 (item, is_series)
1132}
1133
1134fn parse_category(v: &Value) -> StalkerCategory {
1136 StalkerCategory {
1137 id: json_str(v, "id").unwrap_or_default(),
1138 title: json_str(v, "title").unwrap_or_default(),
1139 is_adult: json_bool(v, "censored"),
1140 }
1141}
1142
1143fn parse_epg_response(body: &Value) -> Vec<StalkerEpgEntry> {
1148 let Some(js) = body.get("js") else {
1149 return Vec::new();
1150 };
1151
1152 let items = if let Some(arr) = js.as_array() {
1154 arr.clone()
1155 } else if let Some(epg) = js.get("epg").and_then(|v| v.as_array()) {
1156 epg.clone()
1157 } else if let Some(data) = js.get("data").and_then(|v| v.as_array()) {
1158 data.clone()
1159 } else {
1160 return Vec::new();
1161 };
1162
1163 items
1164 .iter()
1165 .map(|item| {
1166 let name = json_str(item, "name")
1167 .or_else(|| json_str(item, "title"))
1168 .or_else(|| json_str(item, "progname"))
1169 .unwrap_or_default();
1170
1171 let start_ts = parse_epg_timestamp(item, &["start", "start_timestamp", "from"]);
1174 let end_ts = parse_epg_timestamp(item, &["end", "stop_timestamp", "to"]);
1175
1176 let duration = json_i64(item, "duration")
1177 .or_else(|| json_i64(item, "prog_duration"))
1178 .or_else(|| json_i64(item, "length"))
1179 .or_else(|| {
1180 match (start_ts, end_ts) {
1182 (Some(s), Some(e)) if e > s && (e - s) < 86400 => Some(e - s),
1183 _ => None,
1184 }
1185 });
1186
1187 let description = json_str(item, "descr")
1188 .or_else(|| json_str(item, "description"))
1189 .or_else(|| json_str(item, "desc"))
1190 .or_else(|| json_str(item, "short_description"));
1191
1192 let category = json_str(item, "category").or_else(|| json_str(item, "genre"));
1193
1194 StalkerEpgEntry {
1195 name: if name.is_empty() {
1196 "\u{2014}".to_string()
1197 } else {
1198 name
1199 },
1200 start_timestamp: start_ts,
1201 end_timestamp: end_ts.or_else(|| {
1202 match (start_ts, duration) {
1204 (Some(s), Some(d)) if d > 0 && d < 86400 => Some(s + d),
1205 _ => None,
1206 }
1207 }),
1208 description,
1209 category,
1210 duration,
1211 }
1212 })
1213 .collect()
1214}
1215
1216fn parse_epg_timestamp(item: &Value, keys: &[&str]) -> Option<i64> {
1222 for &key in keys {
1223 if let Some(val) = item.get(key) {
1224 if let Some(n) = val.as_i64() {
1226 return Some(if n > 10_000_000_000 { n / 1000 } else { n });
1228 }
1229 if let Some(s) = val.as_str() {
1230 if let Ok(n) = s.parse::<i64>() {
1232 return Some(if n > 10_000_000_000 { n / 1000 } else { n });
1233 }
1234 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M:%S")
1236 {
1237 return Some(dt.and_utc().timestamp());
1238 }
1239 }
1240 }
1241 }
1242 None
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248
1249 #[test]
1250 fn extract_token_from_handshake_response() {
1251 let body: Value = serde_json::json!({
1252 "js": { "token": "abc123def" }
1253 });
1254 assert_eq!(extract_token(&body).unwrap(), "abc123def");
1255 }
1256
1257 #[test]
1258 fn extract_token_missing_returns_error() {
1259 let body: Value = serde_json::json!({"js": {}});
1260 assert!(extract_token(&body).is_err());
1261 }
1262
1263 #[test]
1264 fn parse_channel_from_json() {
1265 let v: Value = serde_json::json!({
1266 "id": "42",
1267 "name": "Test Channel",
1268 "number": "5",
1269 "cmd": "ffrt http://stream.example.com/live/ch42",
1270 "tv_genre_id": "3",
1271 "logo": "http://example.com/logo.png",
1272 "xmltv_id": "test.channel",
1273 "tv_archive": "1",
1274 "tv_archive_duration": "7",
1275 "censored": "0"
1276 });
1277
1278 let ch = parse_channel(&v);
1279 assert_eq!(ch.id, "42");
1280 assert_eq!(ch.name, "Test Channel");
1281 assert_eq!(ch.number, Some(5));
1282 assert_eq!(ch.cmd, "ffrt http://stream.example.com/live/ch42");
1283 assert_eq!(ch.tv_genre_id.as_deref(), Some("3"));
1284 assert_eq!(ch.logo.as_deref(), Some("http://example.com/logo.png"));
1285 assert_eq!(ch.epg_channel_id.as_deref(), Some("test.channel"));
1286 assert!(ch.has_archive);
1287 assert_eq!(ch.archive_days, 7);
1288 assert!(!ch.is_censored);
1289 }
1290
1291 #[test]
1292 fn parse_vod_item_from_json() {
1293 let v: Value = serde_json::json!({
1294 "id": "100",
1295 "name": "Test Movie",
1296 "cmd": "http://stream.example.com/movie/100.mp4",
1297 "category_id": "5",
1298 "screenshot_uri": "http://example.com/poster.jpg",
1299 "description": "A test movie",
1300 "year": "2024",
1301 "genre_str": "Action",
1302 "rating_imdb": "7.5",
1303 "director": "John Doe",
1304 "actors": "Jane Smith",
1305 "time": "01:45:00",
1306 "tmdb_id": 12345
1307 });
1308
1309 let vod = parse_vod_item(&v);
1310 assert_eq!(vod.id, "100");
1311 assert_eq!(vod.name, "Test Movie");
1312 assert_eq!(vod.logo.as_deref(), Some("http://example.com/poster.jpg"));
1313 assert_eq!(vod.genre.as_deref(), Some("Action"));
1314 assert_eq!(vod.rating.as_deref(), Some("7.5"));
1315 assert_eq!(vod.tmdb_id, Some(12345));
1316 }
1317
1318 #[test]
1319 fn parse_category_from_json() {
1320 let v: Value = serde_json::json!({
1321 "id": "3",
1322 "title": "Sports",
1323 "censored": "0"
1324 });
1325
1326 let cat = parse_category(&v);
1327 assert_eq!(cat.id, "3");
1328 assert_eq!(cat.title, "Sports");
1329 assert!(!cat.is_adult);
1330 }
1331
1332 #[test]
1333 fn parse_paginated_response() {
1334 let body: Value = serde_json::json!({
1335 "js": {
1336 "total_items": "25",
1337 "max_page_items": "10",
1338 "data": [
1339 {"id": "1", "title": "Cat 1", "censored": "0"},
1340 {"id": "2", "title": "Cat 2", "censored": "1"}
1341 ]
1342 }
1343 });
1344
1345 let result = parse_paginated(&body, parse_category).unwrap();
1346 assert_eq!(result.total_items, 25);
1347 assert_eq!(result.max_page_items, 10);
1348 assert_eq!(result.items.len(), 2);
1349 assert_eq!(result.items[0].title, "Cat 1");
1350 assert!(result.items[1].is_adult);
1351 }
1352
1353 #[test]
1354 fn json_bool_handles_string_values() {
1355 let v: Value = serde_json::json!({"flag": "1"});
1356 assert!(json_bool(&v, "flag"));
1357
1358 let v: Value = serde_json::json!({"flag": "0"});
1359 assert!(!json_bool(&v, "flag"));
1360 }
1361
1362 #[test]
1363 fn json_bool_handles_numeric_values() {
1364 let v: Value = serde_json::json!({"flag": 1});
1365 assert!(json_bool(&v, "flag"));
1366
1367 let v: Value = serde_json::json!({"flag": 0});
1368 assert!(!json_bool(&v, "flag"));
1369 }
1370
1371 #[test]
1372 fn json_bool_handles_missing_field() {
1373 let v: Value = serde_json::json!({});
1374 assert!(!json_bool(&v, "missing"));
1375 }
1376
1377 #[test]
1378 fn json_u32_handles_string_numbers() {
1379 let v: Value = serde_json::json!({"num": "42"});
1380 assert_eq!(json_u32(&v, "num"), Some(42));
1381 }
1382
1383 #[test]
1384 fn json_u32_handles_numeric_values() {
1385 let v: Value = serde_json::json!({"num": 42});
1386 assert_eq!(json_u32(&v, "num"), Some(42));
1387 }
1388
1389 #[test]
1390 fn build_mac_cookie_format() {
1391 let cookie = build_mac_cookie("00:1A:79:AB:CD:EF", None);
1392 assert!(cookie.starts_with("mac="));
1393 assert!(cookie.contains("stb_lang=en"));
1394 assert!(cookie.contains("timezone=Europe%2FParis"));
1395 assert!(!cookie[4..].starts_with("00:"));
1397 }
1398
1399 #[test]
1400 fn build_mac_cookie_custom_timezone() {
1401 let cookie = build_mac_cookie("00:1A:79:AB:CD:EF", Some("America/New_York"));
1402 assert!(cookie.contains("timezone=America%2FNew_York"));
1403 assert!(!cookie.contains("Europe%2FParis"));
1404 }
1405
1406 #[test]
1407 fn build_mac_cookie_default_timezone_when_none() {
1408 let cookie = build_mac_cookie("00:1A:79:AB:CD:EF", None);
1409 assert!(cookie.contains("timezone=Europe%2FParis"));
1410 }
1411
1412 #[test]
1413 fn parse_channel_empty_logo_becomes_none() {
1414 let v: Value = serde_json::json!({
1415 "id": "1",
1416 "name": "Ch",
1417 "cmd": "",
1418 "logo": ""
1419 });
1420 let ch = parse_channel(&v);
1421 assert!(ch.logo.is_none());
1422 }
1423
1424 #[test]
1425 fn parse_channel_falls_back_to_epg_channel_id() {
1426 let v: Value = serde_json::json!({
1427 "id": "1",
1428 "name": "Ch",
1429 "cmd": "",
1430 "epg_channel_id": "epg.ch"
1431 });
1432 let ch = parse_channel(&v);
1433 assert_eq!(ch.epg_channel_id.as_deref(), Some("epg.ch"));
1434 }
1435
1436 #[test]
1437 fn is_series_flag_filters_vod_vs_series() {
1438 let movie_json: Value = serde_json::json!({
1439 "id": "1", "name": "Movie", "cmd": "", "is_series": "0"
1440 });
1441 let series_json: Value = serde_json::json!({
1442 "id": "2", "name": "Series", "cmd": "", "is_series": "1"
1443 });
1444
1445 let (_, is_series_movie) = parse_vod_item_raw(&movie_json);
1446 let (_, is_series_series) = parse_vod_item_raw(&series_json);
1447
1448 assert!(!is_series_movie);
1449 assert!(is_series_series);
1450 }
1451
1452 #[test]
1453 fn parse_epg_response_from_short_epg() {
1454 let body: Value = serde_json::json!({
1455 "js": [
1456 {
1457 "name": "News at 10",
1458 "start": 1700000000,
1459 "end": 1700003600,
1460 "descr": "Evening news"
1461 },
1462 {
1463 "name": "Late Show",
1464 "start": 1700003600,
1465 "end": 1700007200
1466 }
1467 ]
1468 });
1469
1470 let entries = parse_epg_response(&body);
1471 assert_eq!(entries.len(), 2);
1472 assert_eq!(entries[0].name, "News at 10");
1473 assert_eq!(entries[0].start_timestamp, Some(1700000000));
1474 assert_eq!(entries[0].end_timestamp, Some(1700003600));
1475 assert_eq!(entries[0].description.as_deref(), Some("Evening news"));
1476 assert_eq!(entries[0].duration, Some(3600));
1477 }
1478
1479 #[test]
1480 fn parse_epg_response_from_epg_info() {
1481 let body: Value = serde_json::json!({
1482 "js": {
1483 "epg": [
1484 {
1485 "title": "Morning Show",
1486 "start_timestamp": 1700000000,
1487 "stop_timestamp": 1700007200,
1488 "category": "Entertainment"
1489 }
1490 ]
1491 }
1492 });
1493
1494 let entries = parse_epg_response(&body);
1495 assert_eq!(entries.len(), 1);
1496 assert_eq!(entries[0].name, "Morning Show");
1497 assert_eq!(entries[0].category.as_deref(), Some("Entertainment"));
1498 }
1499
1500 #[test]
1501 fn parse_epg_timestamp_handles_milliseconds() {
1502 let item: Value = serde_json::json!({"start": 1700000000000_i64});
1503 let ts = parse_epg_timestamp(&item, &["start"]);
1504 assert_eq!(ts, Some(1700000000));
1505 }
1506
1507 #[test]
1508 fn parse_epg_timestamp_handles_string_datetime() {
1509 let item: Value = serde_json::json!({"time": "2023-11-14 22:00:00"});
1510 let ts = parse_epg_timestamp(&item, &["time"]);
1511 assert!(ts.is_some());
1512 }
1513
1514 #[test]
1515 fn parse_epg_fallback_derives_end_from_duration() {
1516 let body: Value = serde_json::json!({
1517 "js": [
1518 {
1519 "name": "Show",
1520 "start": 1700000000,
1521 "duration": 3600
1522 }
1523 ]
1524 });
1525
1526 let entries = parse_epg_response(&body);
1527 assert_eq!(entries.len(), 1);
1528 assert_eq!(entries[0].end_timestamp, Some(1700003600));
1529 }
1530
1531 #[test]
1532 fn parse_account_info_with_mac_and_subscribed_till() {
1533 let body: Value = serde_json::json!({
1534 "js": {
1535 "login": "user123",
1536 "mac": "00:1A:79:AB:CD:EF",
1537 "status": "1",
1538 "expire_billing_date": "2025-12-31",
1539 "subscribed_till": "2025-06-15",
1540 "phone": "+1234567890"
1541 }
1542 });
1543
1544 let js = body.get("js").unwrap();
1545 let info = StalkerAccountInfo {
1546 login: json_str(js, "login"),
1547 mac: json_str(js, "mac"),
1548 status: json_str(js, "status"),
1549 expiration: json_str(js, "expire_billing_date").or_else(|| json_str(js, "phone")),
1550 subscribed_till: json_str(js, "subscribed_till"),
1551 };
1552
1553 assert_eq!(info.login.as_deref(), Some("user123"));
1554 assert_eq!(info.mac.as_deref(), Some("00:1A:79:AB:CD:EF"));
1555 assert_eq!(info.status.as_deref(), Some("1"));
1556 assert_eq!(info.expiration.as_deref(), Some("2025-12-31"));
1557 assert_eq!(info.subscribed_till.as_deref(), Some("2025-06-15"));
1558 }
1559
1560 #[test]
1561 fn parse_account_info_numeric_status() {
1562 let body: Value = serde_json::json!({
1563 "js": {
1564 "login": "user1",
1565 "mac": "00:1A:79:00:00:01",
1566 "status": 0
1567 }
1568 });
1569
1570 let js = body.get("js").unwrap();
1571 let status = json_str(js, "status").or_else(|| {
1572 js.get("status")
1573 .and_then(|v| v.as_u64())
1574 .map(|n| n.to_string())
1575 });
1576 assert_eq!(status.as_deref(), Some("0"));
1577 }
1578
1579 #[test]
1580 fn progress_callback_called_with_correct_values() {
1581 let recorded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1583 let recorded_clone = recorded.clone();
1584
1585 let callback = move |completed: u32, total: u32| {
1586 recorded_clone.lock().unwrap().push((completed, total));
1587 };
1588
1589 let total_pages = 3u32;
1590
1591 callback(1, total_pages);
1593 for completed in 2..=total_pages {
1595 callback(completed, total_pages);
1596 }
1597
1598 let calls = recorded.lock().unwrap();
1599 assert_eq!(calls.len(), 3);
1600 assert_eq!(calls[0], (1, 3));
1601 assert_eq!(calls[1], (2, 3));
1602 assert_eq!(calls[2], (3, 3));
1603 }
1604
1605 #[test]
1606 fn series_detail_type_construction() {
1607 let detail = crate::types::StalkerSeriesDetail {
1608 series: StalkerSeriesItem {
1609 id: "10".into(),
1610 name: "Test Series".into(),
1611 ..Default::default()
1612 },
1613 seasons: vec![
1614 crate::types::StalkerSeason {
1615 id: "s1".into(),
1616 name: "Season 1".into(),
1617 movie_id: "10".into(),
1618 ..Default::default()
1619 },
1620 crate::types::StalkerSeason {
1621 id: "s2".into(),
1622 name: "Season 2".into(),
1623 movie_id: "10".into(),
1624 ..Default::default()
1625 },
1626 ],
1627 episodes: {
1628 let mut map = std::collections::HashMap::new();
1629 map.insert(
1630 "s1".into(),
1631 vec![StalkerEpisode {
1632 id: "e1".into(),
1633 name: "Pilot".into(),
1634 movie_id: "10".into(),
1635 season_id: "s1".into(),
1636 episode_number: Some(1),
1637 cmd: "http://stream/s1e1".into(),
1638 ..Default::default()
1639 }],
1640 );
1641 map.insert(
1642 "s2".into(),
1643 vec![StalkerEpisode {
1644 id: "e2".into(),
1645 name: "Premiere".into(),
1646 movie_id: "10".into(),
1647 season_id: "s2".into(),
1648 episode_number: Some(1),
1649 cmd: "http://stream/s2e1".into(),
1650 ..Default::default()
1651 }],
1652 );
1653 map
1654 },
1655 };
1656
1657 assert_eq!(detail.series.id, "10");
1658 assert_eq!(detail.seasons.len(), 2);
1659 assert_eq!(detail.episodes.len(), 2);
1660 assert_eq!(detail.episodes["s1"].len(), 1);
1661 assert_eq!(detail.episodes["s1"][0].name, "Pilot");
1662 assert_eq!(detail.episodes["s2"][0].name, "Premiere");
1663 }
1664}