has/src-tauri/src/cinema.rs

450 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CinemaPlayback {
pub title_id: String,
pub title_name: String,
pub poster_path: Option<String>,
pub backdrop_path: Option<String>,
pub season: u32,
pub episode: u32,
pub progress_seconds: f64,
pub duration_seconds: f64,
pub last_watched: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CinemaState {
pub bookmarks: Vec<String>,
pub history: Vec<CinemaPlayback>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CinemaConfig {
pub tmdb_api_key: Option<String>,
}
fn get_storage_file_path(app: &AppHandle) -> PathBuf {
let data_dir = app
.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."));
if !data_dir.exists() {
let _ = fs::create_dir_all(&data_dir);
}
data_dir.join("cinema_state.json")
}
// Built-in verified TMDB API key used as fallback in production release builds (when .env is absent)
pub const DEFAULT_TMDB_API_KEY: &str = "31a7712e584811e0751d15b653efbe2f";
#[tauri::command]
pub fn get_cinema_config() -> CinemaConfig {
crate::auth::load_env();
let key = std::env::var("TMDB_API_KEY")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_TMDB_API_KEY.to_string());
CinemaConfig { tmdb_api_key: Some(key) }
}
#[tauri::command]
pub fn get_cinema_state(app: AppHandle) -> Result<CinemaState, String> {
let path = get_storage_file_path(&app);
if !path.exists() {
return Ok(CinemaState::default());
}
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<CinemaState>(&content) {
Ok(state) => Ok(state),
Err(e) => {
eprintln!("Error parsing cinema_state.json: {}", e);
Ok(CinemaState::default())
}
},
Err(e) => Err(format!("Не удалось прочитать локальный файл истории кинотеатра: {}", e)),
}
}
#[tauri::command]
pub fn save_cinema_state(app: AppHandle, state: CinemaState) -> Result<bool, String> {
let path = get_storage_file_path(&app);
match serde_json::to_string_pretty(&state) {
Ok(json) => match fs::write(&path, json) {
Ok(_) => Ok(true),
Err(e) => Err(format!("Не удалось сохранить локальный файл кинотеатра: {}", e)),
},
Err(e) => Err(format!("Ошибка сериализации данных: {}", e)),
}
}
#[tauri::command]
pub fn update_cinema_progress(
app: AppHandle,
item: CinemaPlayback,
) -> Result<CinemaState, String> {
let mut state = get_cinema_state(app.clone()).unwrap_or_default();
// Remove older entry for the same title/season/episode if exists
state.history.retain(|h| !(h.title_id == item.title_id && h.season == item.season && h.episode == item.episode));
// Prepend latest entry so it's top of continue watching
state.history.insert(0, item);
// Keep max 50 items in local history
if state.history.len() > 50 {
state.history.truncate(50);
}
let _ = save_cinema_state(app, state.clone());
Ok(state)
}
#[tauri::command]
pub fn toggle_cinema_bookmark(app: AppHandle, title_id: String) -> Result<CinemaState, String> {
let mut state = get_cinema_state(app.clone()).unwrap_or_default();
if let Some(pos) = state.bookmarks.iter().position(|id| id == &title_id) {
state.bookmarks.remove(pos);
} else {
state.bookmarks.push(title_id);
}
let _ = save_cinema_state(app, state.clone());
Ok(state)
}
// Universal TMDB proxy in Rust that bypasses Russian IP / Cloudflare blocks and CORS
#[tauri::command]
pub async fn tmdb_query(endpoint: String) -> Result<serde_json::Value, String> {
crate::auth::load_env();
let api_key = std::env::var("TMDB_API_KEY")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_TMDB_API_KEY.to_string());
let unblocked_addrs = [
"108.156.107.41:443".parse().unwrap(),
"108.156.107.114:443".parse().unwrap(),
"18.67.111.45:443".parse().unwrap(),
"13.225.103.55:443".parse().unwrap(),
"99.84.191.109:443".parse().unwrap(),
];
let client = reqwest::Client::builder()
.resolve_to_addrs("api.themoviedb.org", &unblocked_addrs)
.resolve_to_addrs("api.tmdb.org", &unblocked_addrs)
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.timeout(std::time::Duration::from_secs(8))
.build()
.map_err(|e| format!("Ошибка создания HTTP клиента: {}", e))?;
let is_bearer = api_key.starts_with("ey");
let query_param = if is_bearer {
String::new()
} else if endpoint.contains('?') {
format!("&api_key={}", api_key)
} else {
format!("?api_key={}", api_key)
};
// Fast working TMDB endpoints
let mirrors = [
"https://api.themoviedb.org/3",
"https://api.tmdb.org/3",
];
let mut last_err = String::from("Не удалось получить ответ от серверов TMDB");
for base in mirrors {
let url = format!("{}{}{}", base, endpoint, query_param);
let mut req = client.get(&url);
if is_bearer {
req = req.header("Authorization", format!("Bearer {}", api_key));
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
match resp.json::<serde_json::Value>().await {
Ok(val) => return Ok(val),
Err(e) => last_err = format!("Ошибка разбора ответа от {}: {}", base, e),
}
} else {
last_err = format!("Сервер {} вернул код: {}", base, status);
}
}
Err(e) => {
last_err = format!("Ошибка сети к {}: {}", base, e);
}
}
}
Err(last_err)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RussianMediaInfo {
pub kp_id: Option<u64>,
pub imdb_id: Option<String>,
pub iframe_url: Option<String>,
pub russian_title: Option<String>,
}
fn parse_u64_or_str(v: Option<&serde_json::Value>) -> Option<u64> {
v.and_then(|val| {
val.as_u64()
.or_else(|| val.as_str().and_then(|s| s.parse::<u64>().ok()))
})
}
fn parse_str(v: Option<&serde_json::Value>) -> Option<String> {
v.and_then(|val| val.as_str().map(|s| s.to_string()))
}
// Queries Alloha / Kinopoisk and TMDB external IDs to provide direct Russian dubbing streams
#[tauri::command]
pub async fn get_russian_media_info(
tmdb_id: Option<u64>,
is_tv: bool,
title: Option<String>,
) -> Result<RussianMediaInfo, String> {
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.timeout(std::time::Duration::from_secs(6))
.build()
.map_err(|e| e.to_string())?;
let mut info = RussianMediaInfo::default();
// 1. Try Alloha API by TMDB ID
if let Some(tmdb) = tmdb_id {
let tmdb_str = tmdb.to_string();
let query_params = [
("token", "45e20a5f584becf7a64dffb7174ddf"),
("tmdb", tmdb_str.as_str()),
];
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success");
if is_ok {
let data_opt = json.get("data");
let item_opt = if let Some(arr) = data_opt.and_then(|d| d.as_array()) {
arr.first()
} else {
data_opt
};
if let Some(data) = item_opt {
info.kp_id = parse_u64_or_str(data.get("id_kp"));
info.imdb_id = parse_str(data.get("id_imdb"));
info.iframe_url = parse_str(data.get("iframe"));
info.russian_title = parse_str(data.get("name"));
if info.kp_id.is_some() || info.iframe_url.is_some() {
return Ok(info);
}
}
}
}
}
}
}
// 2. Try Alloha API by Russian / Original title if TMDB didn't return a match
if let Some(ref t) = title {
let clean_title = t.trim();
if !clean_title.is_empty() {
let query_params = [
("token", "45e20a5f584becf7a64dffb7174ddf"),
("name", clean_title),
];
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success");
if is_ok {
let data_opt = json.get("data");
let item_opt = if let Some(arr) = data_opt.and_then(|d| d.as_array()) {
arr.first()
} else {
data_opt
};
if let Some(data) = item_opt {
info.kp_id = parse_u64_or_str(data.get("id_kp"));
info.imdb_id = parse_str(data.get("id_imdb"));
info.iframe_url = parse_str(data.get("iframe"));
info.russian_title = parse_str(data.get("name"));
if info.kp_id.is_some() || info.iframe_url.is_some() {
return Ok(info);
}
}
}
}
}
}
}
}
// 3. Fallback: Query TMDB for external IMDB ID (for Embess & Smotrim IMDB routing)
if let Some(tmdb) = tmdb_id {
let ep = if is_tv {
format!("/tv/{}/external_ids", tmdb)
} else {
format!("/movie/{}/external_ids", tmdb)
};
if let Ok(val) = tmdb_query(ep).await {
if let Some(imdb) = val.get("imdb_id").and_then(|v| v.as_str()) {
info.imdb_id = Some(imdb.to_string());
}
}
}
Ok(info)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayableCheckItem {
pub tmdb_id: u64,
pub title: String,
pub original_title: Option<String>,
pub media_type: String,
pub vote_count: Option<u32>,
pub release_year: Option<String>,
}
// Verifies that media items actually have a playable player/stream available on balancers
#[tauri::command]
pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<u64>, String> {
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.timeout(std::time::Duration::from_millis(3500))
.build()
.map_err(|e| e.to_string())?;
let mut tasks = Vec::new();
for item in items {
let client_clone = client.clone();
tasks.push(tokio::spawn(async move {
let tmdb_id = item.tmdb_id;
let votes = item.vote_count.unwrap_or(0);
// 1. High-confidence threshold: global titles with 100+ votes on TMDB
// have confirmed releases and are indexed across VidLink, VidSrc, and major CDNs.
if votes >= 100 {
return Some(tmdb_id);
}
// 2. For items with < 100 votes (including Russian titles and indie releases):
// Check Alloha balancer API:
let tmdb_str = tmdb_id.to_string();
let alloha_res = client_clone
.get("https://api.apbugall.org/")
.query(&[
("token", "45e20a5f584becf7a64dffb7174ddf"),
("tmdb", tmdb_str.as_str()),
])
.send()
.await;
if let Ok(resp) = alloha_res {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
let has_data = json.get("data").map_or(false, |d| {
if let Some(arr) = d.as_array() {
!arr.is_empty()
} else {
d.is_object()
}
});
if has_data {
return Some(tmdb_id);
}
}
}
}
}
// 3. Check Kodik balancer API:
let clean_title = item.title.trim();
if !clean_title.is_empty() {
let kodik_res = client_clone
.get("https://kodik-api.com/search")
.query(&[
("token", "41dd95f84c21719b09d6c71182237a25"),
("title", clean_title),
])
.send()
.await;
if let Ok(resp) = kodik_res {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
if !results.is_empty() {
return Some(tmdb_id);
}
}
}
}
}
}
// 4. Try title search on Alloha as last check for Russian titles
if !clean_title.is_empty() {
let alloha_title_res = client_clone
.get("https://api.apbugall.org/")
.query(&[
("token", "45e20a5f584becf7a64dffb7174ddf"),
("name", clean_title),
])
.send()
.await;
if let Ok(resp) = alloha_title_res {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
return Some(tmdb_id);
}
}
}
}
}
// If neither Alloha nor Kodik has it, and votes < 100, this title has NO working player!
None
}));
}
let mut playable_ids = Vec::new();
for task in tasks {
if let Ok(Some(id)) = task.await {
playable_ids.push(id);
}
}
Ok(playable_ids)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_tmdb_query() {
let res = tmdb_query("/movie/157336?language=ru-RU".to_string()).await;
println!("Test TMDB Query result: {:?}", res);
assert!(res.is_ok(), "Expected TMDB query to succeed: {:?}", res);
}
}