Skip to main content

crispy_stalker/
client.rs

1//! Session-stateful async Stalker portal client.
2//!
3//! Expanded with features from:
4//! - Python `stalker.py`: parallel pagination, series/seasons/episodes, EPG dual endpoint,
5//!   token refresh, device identity, 404 retry with prehash
6//! - TypeScript `stalker-client.ts`: token refresh locking, parallel 4-page pagination,
7//!   create_link URL sanitization, fallback portal detection
8
9use 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
26/// Default connect timeout in seconds.
27const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
28
29/// Default request timeout in seconds.
30const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
31
32/// Default concurrency for parallel pagination.
33const DEFAULT_CONCURRENCY: usize = 4;
34
35/// Async client for Stalker/MAG middleware portals.
36///
37/// The client is stateful — after calling [`authenticate()`](Self::authenticate),
38/// it retains the session token and cookies for subsequent API calls.
39pub struct StalkerClient {
40    credentials: StalkerCredentials,
41    http: Client,
42    session: Option<StalkerSession>,
43    /// Token refresh lock — prevents concurrent token refreshes.
44    /// TypeScript: `tokenRefreshPromise: Promise<void> | null`
45    #[allow(dead_code)]
46    token_refresh_lock: Mutex<()>,
47    /// Backoff configuration for retries.
48    backoff: BackoffConfig,
49    /// Concurrency limit for parallel pagination.
50    concurrency: usize,
51    /// Token validity period in seconds.
52    token_validity_secs: u64,
53}
54
55impl StalkerClient {
56    /// Create a new client for the given portal credentials.
57    ///
58    /// # Arguments
59    /// * `credentials` — Portal base URL and MAC address.
60    /// * `accept_invalid_certs` — Whether to accept self-signed TLS certificates.
61    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    /// Create a client with a pre-built `reqwest::Client` (for testing or
83    /// connection pool sharing).
84    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    /// Set the backoff configuration for retries.
97    pub fn with_backoff(mut self, backoff: BackoffConfig) -> Self {
98        self.backoff = backoff;
99        self
100    }
101
102    /// Set the concurrency limit for parallel pagination.
103    pub fn with_concurrency(mut self, concurrency: usize) -> Self {
104        self.concurrency = concurrency.max(1);
105        self
106    }
107
108    /// Set the token validity period in seconds.
109    pub fn with_token_validity(mut self, secs: u64) -> Self {
110        self.token_validity_secs = secs;
111        self
112    }
113
114    /// Discover the portal, perform handshake, and authenticate.
115    ///
116    /// Must be called before any data-fetching methods.
117    pub async fn authenticate(&mut self) -> Result<(), StalkerError> {
118        // Step 1: Discover portal URL
119        let portal_url = discover_portal(&self.http, &self.credentials.base_url).await?;
120        debug!(portal_url = %portal_url, "discovered portal");
121
122        // Step 2: Handshake — obtain token (with 404 retry + prehash)
123        let token = self.handshake(&portal_url).await?;
124        debug!("handshake successful, token obtained");
125
126        // Step 3: Create session with device identity
127        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        // Step 4: Authenticate with do_auth
136        self.do_auth(&session).await?;
137        debug!("authentication successful");
138
139        self.session = Some(session);
140
141        // Step 5: Get profile to fully activate session
142        self.get_profile_internal().await?;
143        debug!("profile fetched, session fully active");
144
145        Ok(())
146    }
147
148    /// Perform the handshake to obtain a session token.
149    ///
150    /// Handles 404 by generating a token and SHA-1 prehash, then retrying.
151    /// Python: `handshake()` with 404 handling
152    /// TypeScript: `handshake()` with fallback URL support
153    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        // First attempt
162        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            // 404 retry with prehash — Python: generate token + SHA1(token) as prehash
176            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    /// Perform the `do_auth` call to activate the session.
205    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        // Check for auth failure
222        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    /// Internal profile fetch — sends device metrics per Python/TypeScript sources.
232    ///
233    /// Python: `get_profile()` with sn, device_id, signature, metrics params
234    /// TypeScript: `getProfile()` with full device parameters
235    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        // Profile request does NOT include token in cookie
273        // (TypeScript: `includeTokenInCookie = !this.isStalkerPortalEndpoint()`)
274        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(&params).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        // Update token if returned in profile response
297        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    /// Ensure the token is still valid; re-authenticate if expired.
309    ///
310    /// Python: `ensure_token()` — checks `(now - timestamp) > validity_period`
311    /// TypeScript: `ensureToken()` — with promise-based locking
312    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    /// Get the current session, or return `NotAuthenticated`.
328    fn session(&self) -> Result<&StalkerSession, StalkerError> {
329        self.session.as_ref().ok_or(StalkerError::NotAuthenticated)
330    }
331
332    /// Send an authenticated GET request to the portal with retry + backoff.
333    ///
334    /// Expanded with exponential backoff from Python `make_request_with_retries`.
335    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    /// Get account information.
396    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    /// Get profile information.
419    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    /// Get channel categories / genres.
433    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    /// Get VOD categories.
448    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    /// Get series categories.
463    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    /// Get a single page of channels for a genre.
478    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    /// Get all channels for a genre, auto-paginating with parallel fetching.
489    ///
490    /// Python: `ThreadPoolExecutor` with `num_threads` workers
491    /// TypeScript: `Promise.all` with `BATCH_SIZE = 4`
492    ///
493    /// `on_progress` receives `(completed_pages, total_pages)` after each page.
494    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    /// Get a single page of VOD items for a category.
508    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    /// Get all VOD items for a category (movies only, excluding series).
519    ///
520    /// Python: `get_vod_in_category()` — filters by `is_series != "1"`
521    ///
522    /// `on_progress` receives `(completed_pages, total_pages)` after each page.
523    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        // Filter: keep only non-series items
537        Ok(all
538            .into_iter()
539            .filter(|(_, is_series)| !is_series)
540            .map(|(item, _)| item)
541            .collect())
542    }
543
544    /// Get all series items for a category (series only, `is_series = "1"`).
545    ///
546    /// Python: `get_series_in_category()` — filters by `is_series == "1"`
547    ///
548    /// `on_progress` receives `(completed_pages, total_pages)` after each page.
549    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        // Filter: keep only series items
563        Ok(all
564            .into_iter()
565            .filter(|(_, is_series)| *is_series)
566            .map(|(item, _)| item)
567            .collect())
568    }
569
570    /// Get a single page of series items for a category.
571    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    /// Get seasons for a series/movie.
582    ///
583    /// Python: `get_seasons(movie_id)` — fetches with `movie_id={id}&season_id=0&episode_id=0`
584    /// TypeScript: `getSeasons(movieId)` — same query pattern
585    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                // Python: `item.get("is_season")` in truthy values
605                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                // Python fix: if video_id == season_id, use the parent movie_id
624                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    /// Get episodes for a season.
649    ///
650    /// Python: `get_episodes(movie_id, season_id)` — `movie_id={id}&season_id={sid}&episode_id=0`
651    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    /// Fetch full series detail: seasons + episodes for each season.
699    ///
700    /// `series` is the pre-fetched series metadata. The method calls
701    /// `get_seasons` and then `get_episodes` for each season, returning
702    /// everything in a single [`StalkerSeriesDetail`].
703    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    /// Get EPG data for a channel using dual endpoint fallback.
723    ///
724    /// Python `Epg.py`: tries `get_short_epg` first, falls back to `get_epg_info`.
725    /// TypeScript: same pattern.
726    pub async fn get_epg(
727        &self,
728        channel_id: &str,
729        size: u32,
730    ) -> Result<Vec<StalkerEpgEntry>, StalkerError> {
731        // Try get_short_epg first
732        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        // Fallback to get_epg_info
748        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    /// Resolve a channel's stream URL via the `create_link` endpoint.
763    ///
764    /// This calls the portal to resolve `cmd` into a playable URL.
765    /// For simple URLs, prefer [`resolve_stream_url()`](crate::url::resolve_stream_url)
766    /// which is a pure function.
767    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        // Response can be {"js":{"cmd":"http://...","streamer_id":0,...}}
781        // TypeScript also checks for "url" field first
782        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        // Strip known prefixes and sanitize
794        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    /// Send a keepalive / watchdog event to prevent session timeout.
806    pub async fn keepalive(&self) -> Result<(), StalkerError> {
807        let body = self.portal_get("type=watchdog&action=get_events").await?;
808
809        // Some portals return {"js":1} for success
810        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    /// Whether the client has an active session.
822    pub fn is_authenticated(&self) -> bool {
823        self.session.is_some()
824    }
825
826    /// Get the discovered portal URL, if authenticated.
827    pub fn portal_url(&self) -> Option<&str> {
828        self.session.as_ref().map(|s| s.portal_url.as_str())
829    }
830
831    /// Whether the token is expired and needs refresh.
832    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    /// Fetch all pages for a paginated query using parallel fetching.
840    ///
841    /// Python: `ThreadPoolExecutor(max_workers=self.num_threads)` with `as_completed`
842    /// TypeScript: `Promise.all` with `BATCH_SIZE = 4`
843    ///
844    /// `on_progress` receives `(completed_pages, total_pages)` after each page.
845    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        // Fetch first page to determine total
852        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        // Fetch remaining pages in parallel batches
877        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            // Fetch pages in this batch sequentially but with concurrent intent
883            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            // Sort by page to maintain order
894            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    /// Fetch all pages sequentially (for backward compatibility).
918    #[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
953// ── Parsing helpers ──────────────────────────────────────────────────
954
955/// Characters to percent-encode in cookie values.
956/// Encodes everything except unreserved characters per RFC 3986.
957const COOKIE_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
958    .remove(b'-')
959    .remove(b'_')
960    .remove(b'.')
961    .remove(b'~');
962
963/// Build the MAC cookie header value.
964fn 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
971/// Extract the token from a handshake response.
972fn 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
980/// Parse a paginated API response.
981fn 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
1020/// Extract a string field from a JSON value, handling both string and number types.
1021fn 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
1035/// Extract a u32 from a JSON value, handling both number and string types.
1036fn 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
1044/// Extract a bool from a JSON value, handling "0"/"1" strings.
1045fn 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
1059/// Extract an i64 from a JSON value.
1060fn 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
1067/// Parse a single channel from a JSON object.
1068fn 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
1083/// Parse a single VOD item from a JSON object.
1084fn 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
1102/// Parse a VOD item with the `is_series` flag for filtering.
1103///
1104/// Python: uses `is_series` field to separate movies from series.
1105fn 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
1111/// Parse a single series item from a JSON object.
1112fn 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
1127/// Parse a series item with `is_series` flag for filtering.
1128fn 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
1134/// Parse a category from a JSON object.
1135fn 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
1143/// Parse an EPG response body into entries.
1144///
1145/// Python `Epg.py`: `_extract_items` + `_normalize_items`
1146/// Handles both `get_short_epg` and `get_epg_info` response formats.
1147fn parse_epg_response(body: &Value) -> Vec<StalkerEpgEntry> {
1148    let Some(js) = body.get("js") else {
1149        return Vec::new();
1150    };
1151
1152    // Extract items — can be in js directly (as array), js.epg, or js.data
1153    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            // Timestamp parsing — epoch seconds/ms with string fallback
1172            // Python: `_safe_int` + `_epoch_to_local` + `_parse_dt_str`
1173            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                    // Derive from timestamps if both exist
1181                    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                    // Derive end from start + duration
1203                    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
1216/// Parse an EPG timestamp from multiple possible fields.
1217///
1218/// Handles epoch seconds, epoch milliseconds (>10B → divide by 1000),
1219/// and string format fallback.
1220/// Python: `_safe_int` + `_epoch_to_local` (ms detection)
1221fn parse_epg_timestamp(item: &Value, keys: &[&str]) -> Option<i64> {
1222    for &key in keys {
1223        if let Some(val) = item.get(key) {
1224            // Try numeric first
1225            if let Some(n) = val.as_i64() {
1226                // Python: if ts > 10_000_000_000 → ms, divide by 1000
1227                return Some(if n > 10_000_000_000 { n / 1000 } else { n });
1228            }
1229            if let Some(s) = val.as_str() {
1230                // Try parsing as integer
1231                if let Ok(n) = s.parse::<i64>() {
1232                    return Some(if n > 10_000_000_000 { n / 1000 } else { n });
1233                }
1234                // Try parsing as datetime string "YYYY-mm-dd HH:MM:SS"
1235                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        // MAC colons should be percent-encoded
1396        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        // Simulate the progress callback logic used by fetch_all_pages_parallel
1582        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        // Simulate: first page
1592        callback(1, total_pages);
1593        // Simulate: remaining pages
1594        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}