From 2717aaadad38908c0a3b7e17ffb655910185c85c Mon Sep 17 00:00:00 2001 From: Tunglies Date: Fri, 21 Aug 2026 21:27:27 +0800 Subject: [PATCH] fix: media unlock checking behavior (#7778) * fix: Gemini test * naming unlock * test type * refactor: deduplicate media unlock checks * fix: run media unlock checks individually * fix(media-unlock): satisfy clippy const lint * fix: stream media unlock test results * fix: streamline media unlock checks * fix: force HTTP/1 for media unlock checks * fix: limit concurrent media unlock checks * feat: allow concurrent media unlock item checks --- Cargo.lock | 1 - Changelog.md | 1 + crates/clash-verge-media-unlock/Cargo.toml | 1 - .../clash-verge-media-unlock/src/bahamut.rs | 120 ++--- .../clash-verge-media-unlock/src/bilibili.rs | 77 ++- .../clash-verge-media-unlock/src/chatgpt.rs | 86 +--- crates/clash-verge-media-unlock/src/claude.rs | 45 +- .../src/disney_plus.rs | 457 +++++++----------- crates/clash-verge-media-unlock/src/gemini.rs | 43 +- crates/clash-verge-media-unlock/src/lib.rs | 188 ++++--- .../clash-verge-media-unlock/src/netflix.rs | 205 ++++---- .../src/prime_video.rs | 69 +-- .../clash-verge-media-unlock/src/spotify.rs | 81 ++-- crates/clash-verge-media-unlock/src/tiktok.rs | 106 ++-- crates/clash-verge-media-unlock/src/types.rs | 23 - crates/clash-verge-media-unlock/src/utils.rs | 34 ++ .../clash-verge-media-unlock/src/youtube.rs | 104 ++-- src-tauri/src/cmd/media_unlock_checker/mod.rs | 39 +- src-tauri/src/lib.rs | 1 + src/pages/unlock.tsx | 220 +++------ 20 files changed, 801 insertions(+), 1100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba918a79c..e6309e388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1226,7 +1226,6 @@ dependencies = [ "chrono", "clash-verge-logging", "log", - "regex", "reqwest 0.13.4", "rust_iso3166", "serde", diff --git a/Changelog.md b/Changelog.md index ffce80cc6..567a8b7c9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -17,6 +17,7 @@ 修复部分错误提示显示为 [object Object] 的问题 修复 i18n 清理脚本因导入方式错误而无法运行的问题 修复 Linux 桌面环境下主题切换时应用外观不跟随的问题 +修复 Gemini 测试正确性
✨ 新增功能 diff --git a/crates/clash-verge-media-unlock/Cargo.toml b/crates/clash-verge-media-unlock/Cargo.toml index b59f171c9..eec2c2e0c 100644 --- a/crates/clash-verge-media-unlock/Cargo.toml +++ b/crates/clash-verge-media-unlock/Cargo.toml @@ -7,7 +7,6 @@ edition = "2024" clash-verge-logging = { workspace = true } chrono = { workspace = true } log = { workspace = true } -regex = { workspace = true } reqwest = { workspace = true } rust_iso3166 = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/clash-verge-media-unlock/src/bahamut.rs b/crates/clash-verge-media-unlock/src/bahamut.rs index 21239e896..287b762fa 100644 --- a/crates/clash-verge-media-unlock/src/bahamut.rs +++ b/crates/clash-verge-media-unlock/src/bahamut.rs @@ -1,102 +1,52 @@ -use std::sync::Arc; - -use clash_verge_logging::{Type, logging}; -use regex::Regex; -use reqwest::{Client, cookie::Jar}; +use reqwest::{Client, header::USER_AGENT}; +use serde::Deserialize; use super::UnlockItem; -pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem { - let cookie_store = Arc::new(Jar::default()); +pub(crate) const BAHAMUT_ANIME_NAME: &str = "Bahamut Anime"; +const BAHAMUT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; - let client_with_cookies = match Client::builder() - .use_rustls_tls() - .user_agent( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", - ) - .cookie_provider(Arc::clone(&cookie_store)) - .build() - { - Ok(client) => client, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to create client with cookies for Bahamut Anime: {}", - e - ); - client.clone() - } +#[derive(Deserialize)] +struct DeviceResponse { + deviceid: String, +} + +pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem { + let get = |url: &str| client.get(url).header(USER_AGENT, BAHAMUT_USER_AGENT); + let device = get("https://ani.gamer.com.tw/ajax/getdeviceid.php").send().await.ok(); + + let device = match device { + Some(res) => res.json::().await.ok(), + None => None, }; - let device_url = "https://ani.gamer.com.tw/ajax/getdeviceid.php"; - let device_id = match client_with_cookies.get(device_url).send().await { - Ok(response) => match response.text().await { - Ok(text) => match Regex::new(r#""deviceid"\s*:\s*"([^"]+)"#) { - Ok(re) => re - .captures(&text) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())) - .unwrap_or_default(), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile deviceid regex for Bahamut Anime: {}", - e - ); - String::new() - } - }, - Err(_) => String::new(), - }, - Err(_) => String::new(), + let Some(device) = device else { + return UnlockItem::checked(BAHAMUT_ANIME_NAME, "Failed", None); }; - if device_id.is_empty() { - return UnlockItem::checked("Bahamut Anime", "Failed", None); - } + let token_url = format!( + "https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={}", + device.deviceid + ); - let url = format!("https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={device_id}"); - - let token_result = match client_with_cookies.get(&url).send().await { - Ok(response) => match response.text().await { - Ok(body) => { - if body.contains("animeSn") { - Some(body) - } else { - None - } - } - Err(_) => None, - }, - Err(_) => None, + let unlocked = match get(&token_url).send().await { + Ok(res) => res.text().await.is_ok_and(|body| body.contains("animeSn")), + Err(_) => false, }; - if token_result.is_none() { - return UnlockItem::checked("Bahamut Anime", "No", None); + if !unlocked { + return UnlockItem::checked(BAHAMUT_ANIME_NAME, "No", None); } - let region = match client_with_cookies.get("https://ani.gamer.com.tw/").send().await { - Ok(response) => match response.text().await { - Ok(body) => match Regex::new(r#"data-geo="([^"]+)"#) { - Ok(region_re) => region_re.captures(&body).and_then(|caps| caps.get(1)).map(|m| { - let country_code = m.as_str(); - UnlockItem::region_label(country_code) - }), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile region regex for Bahamut Anime: {}", - e - ); - None - } - }, - Err(_) => None, - }, + let region = match get("https://ani.gamer.com.tw/").send().await { + Ok(res) => res.text().await.ok().and_then(|body| { + body.split_once("data-geo=\"") + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(code, _)| UnlockItem::region_label(code)) + }), Err(_) => None, }; - UnlockItem::checked("Bahamut Anime", "Yes", region) + UnlockItem::checked(BAHAMUT_ANIME_NAME, "Yes", region) } diff --git a/crates/clash-verge-media-unlock/src/bilibili.rs b/crates/clash-verge-media-unlock/src/bilibili.rs index 0264b28ed..ded0ff881 100644 --- a/crates/clash-verge-media-unlock/src/bilibili.rs +++ b/crates/clash-verge-media-unlock/src/bilibili.rs @@ -1,60 +1,39 @@ use reqwest::Client; -use serde_json::Value; +use serde::Deserialize; use super::UnlockItem; -pub(super) async fn check_bilibili_china_mainland(client: &Client) -> UnlockItem { - let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=82846771&qn=0&type=&otype=json&ep_id=307247&fourk=1&fnver=0&fnval=16&module=bangumi"; +pub(crate) const BILIBILI_CHINA_MAINLAND_NAME: &str = "哔哩哔哩大陆"; +const BILIBILI_CHINA_MAINLAND_URL: &str = "https://api.bilibili.com/pgc/player/web/playurl?avid=82846771&qn=0&type=&otype=json&ep_id=307247&fourk=1&fnver=0&fnval=16&module=bangumi"; - match client.get(url).send().await { - Ok(response) => match response.json::().await { - Ok(body) => { - let status = body - .get("code") - .and_then(|v| v.as_i64()) - .map(|code| { - if code == 0 { - "Yes" - } else if code == -10403 { - "No" - } else { - "Failed" - } - }) - .unwrap_or("Failed"); +pub(crate) const BILIBILI_HK_MC_TW_NAME: &str = "哔哩哔哩港澳台"; +const BILIBILI_HK_MC_TW_URL: &str = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi"; - UnlockItem::checked("哔哩哔哩大陆", status, None) - } - Err(_) => UnlockItem::checked("哔哩哔哩大陆", "Failed", None), +#[derive(Deserialize)] +struct BilibiliResponse { + code: i64, +} + +async fn check_bilibili(client: &Client, name: &str, url: &str) -> UnlockItem { + let status = match client.get(url).send().await { + Ok(response) => match response.json::().await { + Ok(body) => match body.code { + 0 => "Yes", + -10403 => "No", + _ => "Failed", + }, + Err(_) => "Failed", }, - Err(_) => UnlockItem::checked("哔哩哔哩大陆", "Failed", None), - } + Err(_) => "Failed", + }; + + UnlockItem::checked(name, status, None) +} + +pub(super) async fn check_bilibili_china_mainland(client: &Client) -> UnlockItem { + check_bilibili(client, BILIBILI_CHINA_MAINLAND_NAME, BILIBILI_CHINA_MAINLAND_URL).await } pub(super) async fn check_bilibili_hk_mc_tw(client: &Client) -> UnlockItem { - let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi"; - - match client.get(url).send().await { - Ok(response) => match response.json::().await { - Ok(body) => { - let status = body - .get("code") - .and_then(|v| v.as_i64()) - .map(|code| { - if code == 0 { - "Yes" - } else if code == -10403 { - "No" - } else { - "Failed" - } - }) - .unwrap_or("Failed"); - - UnlockItem::checked("哔哩哔哩港澳台", status, None) - } - Err(_) => UnlockItem::checked("哔哩哔哩港澳台", "Failed", None), - }, - Err(_) => UnlockItem::checked("哔哩哔哩港澳台", "Failed", None), - } + check_bilibili(client, BILIBILI_HK_MC_TW_NAME, BILIBILI_HK_MC_TW_URL).await } diff --git a/crates/clash-verge-media-unlock/src/chatgpt.rs b/crates/clash-verge-media-unlock/src/chatgpt.rs index ca17351be..08e4f8292 100644 --- a/crates/clash-verge-media-unlock/src/chatgpt.rs +++ b/crates/clash-verge-media-unlock/src/chatgpt.rs @@ -1,80 +1,26 @@ -use std::collections::HashMap; - use reqwest::Client; +use crate::utils::{get_text, get_trace_location}; + use super::UnlockItem; -pub(super) async fn check_chatgpt_combined(client: &Client) -> Vec { - let mut results = Vec::new(); +pub(crate) const CHATGPT_WEB_NAME: &str = "ChatGPT Web"; - let url_country = "https://chat.openai.com/cdn-cgi/trace"; - let result_country = client.get(url_country).send().await; +pub(super) async fn check_chatgpt(client: &Client) -> UnlockItem { + let region = get_trace_location(client, "https://chat.openai.com/cdn-cgi/trace") + .await + .map(|loc| UnlockItem::region_label(&loc)); - let region = match result_country { - Ok(response) => { - if let Ok(body) = response.text().await { - let mut map = HashMap::new(); - for line in body.lines() { - if let Some(index) = line.find('=') { - let key = &line[..index]; - let value = &line[index + 1..]; - map.insert(key.to_string(), value.to_string()); - } - } - - map.get("loc").map(|loc| UnlockItem::region_label(loc)) + let web_status = get_text(client, "https://api.openai.com/compliance/cookie_requirements") + .await + .map(|body| { + if body.to_ascii_lowercase().contains("unsupported_country") { + "Unsupported Country/Region" } else { - None + "Yes" } - } - Err(_) => None, - }; + }) + .unwrap_or("Failed"); - let url_ios = "https://ios.chat.openai.com/"; - let result_ios = client.get(url_ios).send().await; - - let ios_status = match result_ios { - Ok(response) => { - if let Ok(body) = response.text().await { - let body_lower = body.to_lowercase(); - if body_lower.contains("you may be connected to a disallowed isp") { - "Disallowed ISP" - } else if body_lower.contains("request is not allowed. please try again later.") { - "Yes" - } else if body_lower.contains("sorry, you have been blocked") { - "Blocked" - } else { - "Failed" - } - } else { - "Failed" - } - } - Err(_) => "Failed", - }; - - let url_web = "https://api.openai.com/compliance/cookie_requirements"; - let result_web = client.get(url_web).send().await; - - let web_status = match result_web { - Ok(response) => { - if let Ok(body) = response.text().await { - let body_lower = body.to_lowercase(); - if body_lower.contains("unsupported_country") { - "Unsupported Country/Region" - } else { - "Yes" - } - } else { - "Failed" - } - } - Err(_) => "Failed", - }; - - results.push(UnlockItem::checked("ChatGPT iOS", ios_status, region.clone())); - - results.push(UnlockItem::checked("ChatGPT Web", web_status, region)); - - results + UnlockItem::checked(CHATGPT_WEB_NAME, web_status, region) } diff --git a/crates/clash-verge-media-unlock/src/claude.rs b/crates/clash-verge-media-unlock/src/claude.rs index 2e8000206..9a15677fc 100644 --- a/crates/clash-verge-media-unlock/src/claude.rs +++ b/crates/clash-verge-media-unlock/src/claude.rs @@ -1,38 +1,27 @@ use reqwest::Client; +use crate::utils::get_trace_location; + use super::UnlockItem; -const BLOCKED_CODES: [&str; 10] = ["AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"]; +pub(crate) const CLAUDE_NAME: &str = "Claude"; + +const BLOCKED_CODES: &[&str] = &["AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"]; pub(super) async fn check_claude(client: &Client) -> UnlockItem { - let url = "https://claude.ai/cdn-cgi/trace"; + let code = get_trace_location(client, "https://claude.ai/cdn-cgi/trace") + .await + .map(|code| code.trim().to_ascii_uppercase()); - match client.get(url).send().await { - Ok(response) => match response.text().await { - Ok(body) => { - let mut country_code: Option = None; + let Some(code) = code else { + return UnlockItem::checked(CLAUDE_NAME, "Failed", None); + }; - for line in body.lines() { - if let Some(rest) = line.strip_prefix("loc=") { - country_code = Some(rest.trim().to_uppercase()); - break; - } - } + let status = if BLOCKED_CODES.contains(&code.as_str()) { + "No" + } else { + "Yes" + }; - if let Some(code) = country_code { - let status = if BLOCKED_CODES.contains(&code.as_str()) { - "No" - } else { - "Yes" - }; - - UnlockItem::checked_region("Claude", status, &code) - } else { - UnlockItem::checked("Claude", "Failed", None) - } - } - Err(_) => UnlockItem::checked("Claude", "Failed", None), - }, - Err(_) => UnlockItem::checked("Claude", "Failed", None), - } + UnlockItem::checked_region(CLAUDE_NAME, status, &code) } diff --git a/crates/clash-verge-media-unlock/src/disney_plus.rs b/crates/clash-verge-media-unlock/src/disney_plus.rs index 1ce61dcaf..a81886f52 100644 --- a/crates/clash-verge-media-unlock/src/disney_plus.rs +++ b/crates/clash-verge-media-unlock/src/disney_plus.rs @@ -1,18 +1,170 @@ -use regex::Regex; -use reqwest::Client; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use serde_json::{Value, json}; -use clash_verge_logging::{Type, logging}; +use crate::utils::extract_quoted_field; use super::UnlockItem; -const DISNEY_PLUS: &str = "Disney+"; +pub(crate) const DISNEY_NAME: &str = "Disney+"; + const AUTH_HEADER: &str = "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84"; -fn disney_item(status: impl Into, region: Option) -> UnlockItem { - UnlockItem::checked(DISNEY_PLUS, status, region) +const DEVICE_URL: &str = "https://disney.api.edge.bamgrid.com/devices"; +const TOKEN_URL: &str = "https://disney.api.edge.bamgrid.com/token"; +const GRAPHQL_URL: &str = "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql"; + +#[derive(Deserialize)] +struct DeviceResponse { + assertion: String, } -async fn fetch_main_page_region(client: &Client) -> Option { +#[derive(Deserialize)] +struct TokenResponse { + refresh_token: String, +} + +fn item(status: impl Into, region: Option) -> UnlockItem { + UnlockItem::checked(DISNEY_NAME, status, region) +} + +pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem { + let assertion = match get_assertion(client).await { + Ok(assertion) => assertion, + Err(item) => return item, + }; + + let refresh_token = match get_refresh_token(client, &assertion).await { + Ok(token) => token, + Err(item) => return item, + }; + + let unavailable = is_unavailable(client).await; + + let response = match client + .post(GRAPHQL_URL) + .header("authorization", AUTH_HEADER) + .json(&json!({ + "query": "mutation refreshToken($input: RefreshTokenInput!) { refreshToken(refreshToken: $input) { activeSession { sessionId } } }", + "variables": { + "input": { + "refreshToken": refresh_token + } + } + })) + .send() + .await + { + Ok(response) => response, + Err(_) => return item("Failed (Network Connection)", None), + }; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if !status.is_success() || body.is_empty() { + return fallback_region(client) + .await + .unwrap_or_else(|| item(format!("Failed (GraphQL: {status})"), None)); + } + + let Ok(data) = serde_json::from_str::(&body) else { + return fallback_region(client) + .await + .unwrap_or_else(|| item("Failed (Invalid GraphQL Response)", None)); + }; + + let Some(region) = find_string(&data, "countryCode") else { + return fallback_region(client).await.unwrap_or_else(|| item("No", None)); + }; + + if region == "JP" { + return UnlockItem::checked_region(DISNEY_NAME, "Yes", region); + } + + if unavailable { + return item("No", None); + } + + match find_bool(&data, "inSupportedLocation") { + Some(true) => UnlockItem::checked_region(DISNEY_NAME, "Yes", region), + Some(false) => item( + "Soon", + Some(format!("{}(即将上线)", UnlockItem::region_label(region))), + ), + None => item(format!("Failed (Unknown region status for {region})"), None), + } +} + +async fn get_assertion(client: &Client) -> Result { + let response = client + .post(DEVICE_URL) + .header("authorization", AUTH_HEADER) + .json(&json!({ + "deviceFamily": "browser", + "applicationRuntime": "chrome", + "deviceProfile": "windows", + "attributes": {} + })) + .send() + .await + .map_err(|_| item("Failed (Network Connection)", None))?; + + if response.status() == StatusCode::FORBIDDEN { + return Err(item("No (IP Banned By Disney+)", None)); + } + + response + .json::() + .await + .map(|data| data.assertion) + .map_err(|_| item("Failed (Cannot extract assertion)", None)) +} + +async fn get_refresh_token(client: &Client, assertion: &str) -> Result { + let response = client + .post(TOKEN_URL) + .header("authorization", AUTH_HEADER) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), + ("latitude", "0"), + ("longitude", "0"), + ("platform", "browser"), + ("subject_token", assertion), + ("subject_token_type", "urn:bamtech:params:oauth:token-type:device"), + ]) + .send() + .await + .map_err(|_| item("Failed (Network Connection)", None))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|_| item("Failed (Cannot read token response)", None))?; + + if status == StatusCode::FORBIDDEN || body.contains("forbidden-location") || body.contains("403 ERROR") { + return Err(item("No (IP Banned By Disney+)", None)); + } + + serde_json::from_str::(&body) + .map(|data| data.refresh_token) + .map_err(|_| item(format!("Failed (Cannot extract refresh token: {status})"), None)) +} + +async fn is_unavailable(client: &Client) -> bool { + client + .get("https://disneyplus.com") + .send() + .await + .map(|response| { + let url = response.url().as_str(); + url.contains("preview") || url.contains("unavailable") + }) + .unwrap_or(true) +} + +async fn fallback_region(client: &Client) -> Option { let body = client .get("https://www.disneyplus.com/") .send() @@ -21,286 +173,37 @@ async fn fetch_main_page_region(client: &Client) -> Option { .text() .await .ok()?; - let region_re = match Regex::new(r#"region"\s*:\s*"([^"]+)"#) { - Ok(region_re) => region_re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Disney+ main page region regex: {}", - e - ); - return None; - } - }; - region_re - .captures(&body) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())) -} -async fn disney_region_from_main_page(client: &Client) -> Option { - let region = fetch_main_page_region(client).await?; - Some(disney_item( + let region = extract_quoted_field(&body, "region")?; + + Some(item( "Yes", - Some(format!("{} (from main page)", UnlockItem::region_label(®ion))), + Some(format!("{} (from main page)", UnlockItem::region_label(region))), )) } -#[allow(clippy::cognitive_complexity)] -pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem { - let device_api_url = "https://disney.api.edge.bamgrid.com/devices"; +fn find_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + match value { + Value::Object(map) => map + .get(key) + .and_then(Value::as_str) + .or_else(|| map.values().find_map(|v| find_string(v, key))), - let device_req_body = serde_json::json!({ - "deviceFamily": "browser", - "applicationRuntime": "chrome", - "deviceProfile": "windows", - "attributes": {} - }); + Value::Array(array) => array.iter().find_map(|v| find_string(v, key)), - let device_result = client - .post(device_api_url) - .header("authorization", AUTH_HEADER) - .header("content-type", "application/json; charset=UTF-8") - .json(&device_req_body) - .send() - .await; - - let device_response = match device_result { - Ok(response) => response, - Err(_) => { - return disney_item("Failed (Network Connection)", None); - } - }; - - if device_response.status().as_u16() == 403 { - return disney_item("No (IP Banned By Disney+)", None); + _ => None, } - - let device_body = match device_response.text().await { - Ok(body) => body, - Err(_) => { - return disney_item("Failed (Error: Cannot read response)", None); - } - }; - - let re = match Regex::new(r#""assertion"\s*:\s*"([^"]+)"#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile assertion regex for Disney+: {}", - e - ); - return disney_item("Failed (Regex Error)", None); - } - }; - let assertion = match re.captures(&device_body) { - Some(caps) => caps.get(1).map(|m| m.as_str().to_string()), - None => None, - }; - - if assertion.is_none() { - return disney_item("Failed (Error: Cannot extract assertion)", None); - } - - let token_url = "https://disney.api.edge.bamgrid.com/token"; - let assertion_str = assertion.unwrap_or_default(); - let token_body = [ - ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), - ("latitude", "0"), - ("longitude", "0"), - ("platform", "browser"), - ("subject_token", assertion_str.as_str()), - ("subject_token_type", "urn:bamtech:params:oauth:token-type:device"), - ]; - - let token_result = client - .post(token_url) - .header("authorization", AUTH_HEADER) - .header("content-type", "application/x-www-form-urlencoded") - .form(&token_body) - .send() - .await; - - let token_response = match token_result { - Ok(response) => response, - Err(_) => { - return disney_item("Failed (Network Connection)", None); - } - }; - let token_status = token_response.status(); - - let token_body_text = match token_response.text().await { - Ok(body) => body, - Err(_) => { - return disney_item("Failed (Error: Cannot read token response)", None); - } - }; - - if token_body_text.contains("forbidden-location") || token_body_text.contains("403 ERROR") { - return disney_item("No (IP Banned By Disney+)", None); - } - - let token_json: Result = serde_json::from_str(&token_body_text); - - let refresh_token = match token_json { - Ok(json) => json - .get("refresh_token") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - Err(_) => match Regex::new(r#""refresh_token"\s*:\s*"([^"]+)"#) { - Ok(refresh_token_re) => refresh_token_re - .captures(&token_body_text) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile refresh_token regex for Disney+: {}", - e - ); - None - } - }, - }; - - if refresh_token.is_none() { - return disney_item( - format!( - "Failed (Error: Cannot extract refresh token, status: {}, response: {})", - token_status.as_u16(), - token_body_text.chars().take(100).collect::() + "..." - ), - None, - ); - } - - let graphql_url = "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql"; +} - let graphql_payload = format!( - r#"{{"query":"mutation refreshToken($input: RefreshTokenInput!) {{ refreshToken(refreshToken: $input) {{ activeSession {{ sessionId }} }} }}","variables":{{"input":{{"refreshToken":"{}"}}}}}}"#, - refresh_token.unwrap_or_default() - ); +fn find_bool(value: &Value, key: &str) -> Option { + match value { + Value::Object(map) => map + .get(key) + .and_then(Value::as_bool) + .or_else(|| map.values().find_map(|v| find_bool(v, key))), - let graphql_result = client - .post(graphql_url) - .header("authorization", AUTH_HEADER) - .header("content-type", "application/json") - .body(graphql_payload) - .send() - .await; - - let preview_check = client.get("https://disneyplus.com").send().await; - - let is_unavailable = match preview_check { - Ok(response) => { - let url = response.url().to_string(); - url.contains("preview") || url.contains("unavailable") - } - Err(_) => true, - }; - - let graphql_response = match graphql_result { - Ok(response) => response, - Err(_) => { - return disney_item("Failed (Network Connection)", None); - } - }; - let graphql_status = graphql_response.status(); - let graphql_body_text = match graphql_response.text().await { - Ok(text) => text, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to read Disney+ GraphQL response text: {}", - e - ); - String::new() - } - }; - - if graphql_body_text.is_empty() || graphql_status.as_u16() >= 400 { - if let Some(item) = disney_region_from_main_page(client).await { - return item; - } - - if graphql_body_text.is_empty() { - return disney_item( - format!( - "Failed (GraphQL error: empty response, status: {})", - graphql_status.as_u16() - ), - None, - ); - } - return disney_item( - format!( - "Failed (GraphQL error: {}, status: {})", - graphql_body_text.chars().take(50).collect::() + "...", - graphql_status.as_u16() - ), - None, - ); - } - - let region_re = match Regex::new(r#""countryCode"\s*:\s*"([^"]+)"#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Disney+ countryCode regex: {}", - e - ); - return disney_item("Failed (Regex Error)", None); - } - }; - let region_code = region_re - .captures(&graphql_body_text) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())); - - let supported_re = match Regex::new(r#""inSupportedLocation"\s*:\s*(false|true)"#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Disney+ supported location regex: {}", - e - ); - return disney_item("Failed (Regex Error)", None); - } - }; - let in_supported_location = supported_re - .captures(&graphql_body_text) - .and_then(|caps| caps.get(1).map(|m| m.as_str() == "true")); - - if region_code.is_none() { - if let Some(item) = disney_region_from_main_page(client).await { - return item; - } - - return disney_item("No", None); - } - - let region = region_code.unwrap_or_default(); - - if region == "JP" { - return UnlockItem::checked_region(DISNEY_PLUS, "Yes", ®ion); - } - - if is_unavailable { - return disney_item("No", None); - } + Value::Array(array) => array.iter().find_map(|v| find_bool(v, key)), - match in_supported_location { - Some(false) => disney_item( - "Soon", - Some(format!("{}(即将上线)", UnlockItem::region_label(®ion))), - ), - Some(true) => UnlockItem::checked_region(DISNEY_PLUS, "Yes", ®ion), - None => disney_item(format!("Failed (Error: Unknown region status for {region})"), None), + _ => None, } } diff --git a/crates/clash-verge-media-unlock/src/gemini.rs b/crates/clash-verge-media-unlock/src/gemini.rs index 2aad077b6..3b4cb6d05 100644 --- a/crates/clash-verge-media-unlock/src/gemini.rs +++ b/crates/clash-verge-media-unlock/src/gemini.rs @@ -2,35 +2,30 @@ use reqwest::Client; use super::UnlockItem; +pub(crate) const GEMINI_NAME: &str = "Gemini"; const BLOCKED_CODES: [&str; 9] = ["CHN", "RUS", "BLR", "CUB", "IRN", "PRK", "SYR", "HKG", "MAC"]; const REGION_MARKER: &str = ",2,1,200,\""; pub(super) async fn check_gemini(client: &Client) -> UnlockItem { - let url = "https://gemini.google.com"; - let failed = || UnlockItem::checked("Gemini", "Failed", None); - - let response = match client.get(url).send().await { - Ok(r) => r, - Err(_) => return failed(), - }; - let body = match response.text().await { - Ok(b) => b, - Err(_) => return failed(), + let Ok(response) = client.get("https://gemini.google.com").send().await else { + return UnlockItem::checked(GEMINI_NAME, "Failed", None); }; - let country_code = body - .find(REGION_MARKER) - .and_then(|i| { - let start = i + REGION_MARKER.len(); - body.get(start..start + 3) - }) - .filter(|s| s.bytes().all(|b| b.is_ascii_uppercase())); + let Ok(body) = response.text().await else { + return UnlockItem::checked(GEMINI_NAME, "Failed", None); + }; - match country_code { - Some(code) => { - let status = if BLOCKED_CODES.contains(&code) { "No" } else { "Yes" }; - UnlockItem::checked_region("Gemini", status, code) - } - None => failed(), - } + let Some(code) = body + .split_once(REGION_MARKER) + .and_then(|(_, rest)| rest.get(..3)) + .filter(|code| code.bytes().all(|c| c.is_ascii_uppercase())) + else { + return UnlockItem::checked(GEMINI_NAME, "Failed", None); + }; + + UnlockItem::checked_region( + GEMINI_NAME, + if BLOCKED_CODES.contains(&code) { "No" } else { "Yes" }, + code, + ) } diff --git a/crates/clash-verge-media-unlock/src/lib.rs b/crates/clash-verge-media-unlock/src/lib.rs index 3abcf7b5a..39c05103b 100644 --- a/crates/clash-verge-media-unlock/src/lib.rs +++ b/crates/clash-verge-media-unlock/src/lib.rs @@ -1,4 +1,4 @@ -use std::{future::Future, sync::Arc}; +use std::time::Duration; use reqwest::Client; use tokio::task::JoinSet; @@ -19,95 +19,129 @@ mod types; mod utils; mod youtube; -pub use types::{UnlockItem, default_unlock_items}; +pub use types::UnlockItem; -use bahamut::check_bahamut_anime; -use bilibili::{check_bilibili_china_mainland, check_bilibili_hk_mc_tw}; -use chatgpt::check_chatgpt_combined; -use claude::check_claude; -use disney_plus::check_disney_plus; -use gemini::check_gemini; -use netflix::check_netflix; -use prime_video::check_prime_video; -use spotify::check_spotify; -use tiktok::check_tiktok; -use youtube::check_youtube_premium; +const MAX_CONCURRENT_CHECKS: usize = 4; -type UnlockResults = Vec; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UnlockCheck { + BilibiliChinaMainland, + BilibiliHkMcTw, + ChatGptWeb, + Claude, + Gemini, + YouTubePremium, + BahamutAnime, + Netflix, + DisneyPlus, + PrimeVideo, + Spotify, + TikTok, +} -fn spawn_unlock_check(tasks: &mut JoinSet, client: Arc, check: F) +impl UnlockCheck { + const ALL: &[Self] = &[ + Self::BilibiliChinaMainland, + Self::BilibiliHkMcTw, + Self::ChatGptWeb, + Self::Claude, + Self::Gemini, + Self::YouTubePremium, + Self::BahamutAnime, + Self::Netflix, + Self::DisneyPlus, + Self::PrimeVideo, + Self::Spotify, + Self::TikTok, + ]; + + fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|check| check.name() == name) + } + + const fn name(self) -> &'static str { + match self { + Self::BilibiliChinaMainland => bilibili::BILIBILI_CHINA_MAINLAND_NAME, + Self::BilibiliHkMcTw => bilibili::BILIBILI_HK_MC_TW_NAME, + Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME, + Self::Claude => claude::CLAUDE_NAME, + Self::Gemini => gemini::GEMINI_NAME, + Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME, + Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME, + Self::Netflix => netflix::NETFLIX_NAME, + Self::DisneyPlus => disney_plus::DISNEY_NAME, + Self::PrimeVideo => prime_video::PRIME_VIDEO_NAME, + Self::Spotify => spotify::SPOTIFY_NAME, + Self::TikTok => tiktok::TIKTOK_NAME, + } + } + + async fn check(self, client: &Client) -> UnlockItem { + match self { + Self::BilibiliChinaMainland => bilibili::check_bilibili_china_mainland(client).await, + Self::BilibiliHkMcTw => bilibili::check_bilibili_hk_mc_tw(client).await, + Self::ChatGptWeb => chatgpt::check_chatgpt(client).await, + Self::Claude => claude::check_claude(client).await, + Self::Gemini => gemini::check_gemini(client).await, + Self::YouTubePremium => youtube::check_youtube_premium(client).await, + Self::BahamutAnime => bahamut::check_bahamut_anime(client).await, + Self::Netflix => netflix::check_netflix(client).await, + Self::DisneyPlus => disney_plus::check_disney_plus(client).await, + Self::PrimeVideo => prime_video::check_prime_video(client).await, + Self::Spotify => spotify::check_spotify(client).await, + Self::TikTok => tiktok::check_tiktok(client).await, + } + } + + async fn check_with_timeout(self, client: &Client) -> UnlockItem { + tokio::time::timeout(Duration::from_secs(15), self.check(client)) + .await + .unwrap_or_else(|_| UnlockItem::checked(self.name(), "Failed", None)) + } +} + +pub fn default_unlock_items() -> Vec { + UnlockCheck::ALL + .iter() + .map(|check| UnlockItem::pending(check.name())) + .collect() +} + +pub async fn check_media_unlock(client: &Client, on_complete: F) -> Vec where - F: FnOnce(Arc) -> Fut + Send + 'static, - Fut: Future + Send + 'static, + F: Fn(&UnlockItem) + Send, { - tasks.spawn(async move { check(client).await }); -} - -fn single_result(item: UnlockItem) -> UnlockResults { - vec![item] -} - -pub async fn check_media_unlock() -> Result, String> { - let client = match Client::builder() - .use_rustls_tls() - .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") - .timeout(std::time::Duration::from_secs(30)) - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true) - .tcp_keepalive(std::time::Duration::from_secs(60)) - .connection_verbose(true) - .build() { - Ok(client) => client, - Err(e) => return Err(format!("创建HTTP客户端失败: {e}")), + let mut tasks = JoinSet::new(); + let mut checks = UnlockCheck::ALL.iter().copied(); + let spawn = |tasks: &mut JoinSet, check: UnlockCheck| { + let client = client.clone(); + tasks.spawn(async move { check.check_with_timeout(&client).await }); }; - let mut tasks = JoinSet::new(); - let client_arc = Arc::new(client); - - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_bilibili_china_mainland(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_bilibili_hk_mc_tw(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - check_chatgpt_combined(&client).await - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_claude(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_gemini(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_youtube_premium(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_bahamut_anime(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_netflix(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_disney_plus(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_spotify(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_tiktok(&client).await) - }); - spawn_unlock_check(&mut tasks, Arc::clone(&client_arc), |client| async move { - single_result(check_prime_video(&client).await) - }); + for check in checks.by_ref().take(MAX_CONCURRENT_CHECKS) { + spawn(&mut tasks, check); + } let mut results = Vec::new(); while let Some(res) = tasks.join_next().await { match res { - Ok(items) => results.extend(items), + Ok(item) => { + on_complete(&item); + results.push(item); + } Err(e) => logging!(error, Type::Network, "任务执行失败: {e}"), } + + if let Some(check) = checks.next() { + spawn(&mut tasks, check); + } } - Ok(results) + results +} + +pub async fn check_media_unlock_item(client: &Client, name: &str) -> Result { + let check = UnlockCheck::from_name(name).ok_or_else(|| format!("未知的流媒体检测项目: {name}"))?; + + Ok(check.check_with_timeout(client).await) } diff --git a/crates/clash-verge-media-unlock/src/netflix.rs b/crates/clash-verge-media-unlock/src/netflix.rs index dd6d38469..7c7cefd3f 100644 --- a/crates/clash-verge-media-unlock/src/netflix.rs +++ b/crates/clash-verge-media-unlock/src/netflix.rs @@ -1,132 +1,127 @@ -use reqwest::Client; -use serde_json::Value; +use std::time::Duration; + +use reqwest::{Client, StatusCode}; +use serde::Deserialize; use clash_verge_logging::{Type, logging}; use super::UnlockItem; -const NETFLIX: &str = "Netflix"; +pub(crate) const NETFLIX_NAME: &str = "Netflix"; -fn netflix_item(status: impl Into, region: Option) -> UnlockItem { - UnlockItem::checked(NETFLIX, status, region) +const TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Deserialize)] +struct FastResponse { + targets: Vec, +} + +#[derive(Deserialize)] +struct FastTarget { + location: FastLocation, +} + +#[derive(Deserialize)] +struct FastLocation { + country: String, +} + +fn item(status: impl Into) -> UnlockItem { + UnlockItem::checked(NETFLIX_NAME, status, None) } pub(super) async fn check_netflix(client: &Client) -> UnlockItem { - let cdn_result = check_netflix_cdn(client).await; - if cdn_result.status == "Yes" { - return cdn_result; + let cdn = check_netflix_cdn(client).await; + if cdn.status == "Yes" || cdn.status.starts_with("No") { + return cdn; } - let url1 = "https://www.netflix.com/title/81280792"; - let url2 = "https://www.netflix.com/title/70143836"; + let (r1, r2) = tokio::join!(check_title(client, "81280792"), check_title(client, "70143836"),); - let result1 = client - .get(url1) - .timeout(std::time::Duration::from_secs(30)) + let (Ok(status1), Ok(status2)) = (r1, r2) else { + return item("Failed"); + }; + + if status1 == StatusCode::NOT_FOUND && status2 == StatusCode::NOT_FOUND { + return item("Originals Only"); + } + + if status1 == StatusCode::FORBIDDEN || status2 == StatusCode::FORBIDDEN { + return item("No"); + } + + if [status1, status2] + .iter() + .any(|s| matches!(*s, StatusCode::OK | StatusCode::MOVED_PERMANENTLY)) + { + return check_region(client).await; + } + + item(format!("Failed (状态码: {status1}_{status2})")) +} + +async fn check_title(client: &Client, id: &str) -> reqwest::Result { + client + .get(format!("https://www.netflix.com/title/{id}")) + .timeout(TIMEOUT) .send() - .await; + .await + .map(|res| res.status()) +} - if let Err(e) = &result1 { - logging!(error, Type::Network, "Netflix请求错误: {e}"); - return netflix_item("Failed", None); - } - - let result2 = client - .get(url2) - .timeout(std::time::Duration::from_secs(30)) +async fn check_region(client: &Client) -> UnlockItem { + let response = match client + .get("https://www.netflix.com/title/80018499") + .timeout(TIMEOUT) .send() - .await; - - if let Err(e) = &result2 { - logging!(error, Type::Network, "Netflix请求错误: {e}"); - return netflix_item("Failed", None); - } - - let status1 = match result1 { - Ok(response) => response.status().as_u16(), + .await + { + Ok(res) => res, Err(e) => { - logging!(error, Type::Network, "Failed to get Netflix response 1: {}", e); - return netflix_item("Failed", None); + logging!(error, Type::Network, "获取 Netflix 区域信息失败: {e}"); + return item("Yes (但无法获取区域)"); } }; - let status2 = match result2 { - Ok(response) => response.status().as_u16(), - Err(e) => { - logging!(error, Type::Network, "Failed to get Netflix response 2: {}", e); - return netflix_item("Failed", None); - } - }; + let region = response + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .and_then(|location| location.split('/').nth(3)) + .and_then(|region| region.split('-').next()) + .filter(|region| !region.is_empty()) + .unwrap_or("us"); - if status1 == 404 && status2 == 404 { - return netflix_item("Originals Only", None); - } - - if status1 == 403 || status2 == 403 { - return netflix_item("No", None); - } - - if status1 == 200 || status1 == 301 || status2 == 200 || status2 == 301 { - let test_url = "https://www.netflix.com/title/80018499"; - match client - .get(test_url) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - { - Ok(response) => { - if let Some(location) = response.headers().get("location") - && let Ok(location_str) = location.to_str() - { - let parts: Vec<&str> = location_str.split('/').collect(); - if parts.len() >= 4 { - let region_code = parts[3].split('-').next().unwrap_or("unknown"); - return UnlockItem::checked_region(NETFLIX, "Yes", region_code); - } - } - - UnlockItem::checked_region(NETFLIX, "Yes", "us") - } - Err(e) => { - logging!(error, Type::Network, "获取Netflix区域信息失败: {e}"); - netflix_item("Yes (但无法获取区域)", None) - } - } - } else { - netflix_item(format!("Failed (状态码: {status1}_{status2}"), None) - } + UnlockItem::checked_region(NETFLIX_NAME, "Yes", region) } async fn check_netflix_cdn(client: &Client) -> UnlockItem { - let url = "https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5"; - - match client.get(url).timeout(std::time::Duration::from_secs(30)).send().await { - Ok(response) => { - if response.status().as_u16() == 403 { - return netflix_item("No (IP Banned By Netflix)", None); - } - - match response.json::().await { - Ok(data) => { - if let Some(targets) = data.get("targets").and_then(|t| t.as_array()) - && !targets.is_empty() - && let Some(location) = targets[0].get("location") - && let Some(country) = location.get("country").and_then(|c| c.as_str()) - { - return UnlockItem::checked_region(NETFLIX, "Yes", country); - } - - netflix_item("Unknown", None) - } - Err(e) => { - logging!(error, Type::Network, "解析Fast.com API响应失败: {e}"); - netflix_item("Failed (解析错误)", None) - } - } - } + let response = match client + .get("https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5") + .timeout(TIMEOUT) + .send() + .await + { + Ok(res) => res, Err(e) => { - logging!(error, Type::Network, "Fast.com API请求失败: {e}"); - netflix_item("Failed (CDN API)", None) + logging!(error, Type::Network, "Fast.com API 请求失败: {e}"); + return item("Failed (CDN API)"); + } + }; + + if response.status() == StatusCode::FORBIDDEN { + return item("No (IP Banned By Netflix)"); + } + + match response.json::().await { + Ok(data) => data + .targets + .first() + .map(|target| UnlockItem::checked_region(NETFLIX_NAME, "Yes", &target.location.country)) + .unwrap_or_else(|| item("Unknown")), + Err(e) => { + logging!(error, Type::Network, "解析 Fast.com API 响应失败: {e}"); + item("Failed (解析错误)") } } } diff --git a/crates/clash-verge-media-unlock/src/prime_video.rs b/crates/clash-verge-media-unlock/src/prime_video.rs index d23d46cfa..ba4a70456 100644 --- a/crates/clash-verge-media-unlock/src/prime_video.rs +++ b/crates/clash-verge-media-unlock/src/prime_video.rs @@ -1,62 +1,35 @@ -use regex::Regex; use reqwest::Client; -use clash_verge_logging::{Type, logging}; - use super::UnlockItem; -const PRIME_VIDEO: &str = "Prime Video"; - -fn prime_video_item(status: impl Into, region: Option) -> UnlockItem { - UnlockItem::checked(PRIME_VIDEO, status, region) -} +pub(crate) const PRIME_VIDEO_NAME: &str = "Prime Video"; pub(super) async fn check_prime_video(client: &Client) -> UnlockItem { - let url = "https://www.primevideo.com"; - - let result = client.get(url).send().await; - - let response = match result { + let response = match client.get("https://www.primevideo.com").send().await { Ok(response) => response, Err(_) => { - return prime_video_item("Failed (Network Connection)", None); + return UnlockItem::checked(PRIME_VIDEO_NAME, "Failed (Network Connection)", None); } }; - match response.text().await { - Ok(body) => { - let is_blocked = body.contains("isServiceRestricted"); - - let region_re = match Regex::new(r#""currentTerritory":"([^"]+)""#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Prime Video region regex: {}", - e - ); - return prime_video_item("Failed (Regex Error)", None); - } - }; - let region_code = region_re - .captures(&body) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())); - - if is_blocked { - return prime_video_item("No (Service Not Available)", None); - } - - if let Some(region) = region_code { - return UnlockItem::checked_region(PRIME_VIDEO, "Yes", ®ion); - } - - if !is_blocked { - return prime_video_item("Failed (Error: PAGE ERROR)", None); - } - - prime_video_item("Failed (Error: Unknown Region)", None) + let body = match response.text().await { + Ok(body) => body, + Err(_) => { + return UnlockItem::checked(PRIME_VIDEO_NAME, "Failed (Cannot Read Response)", None); } - Err(_) => prime_video_item("Failed (Error: Cannot read response)", None), + }; + + if body.contains("isServiceRestricted") { + return UnlockItem::checked(PRIME_VIDEO_NAME, "No (Service Not Available)", None); + } + + let region = body + .split_once(r#""currentTerritory":""#) + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(region, _)| region); + + match region { + Some(region) => UnlockItem::checked_region(PRIME_VIDEO_NAME, "Yes", region), + None => UnlockItem::checked(PRIME_VIDEO_NAME, "Failed (PAGE ERROR)", None), } } diff --git a/crates/clash-verge-media-unlock/src/spotify.rs b/crates/clash-verge-media-unlock/src/spotify.rs index 17805fa7a..b1a8786c1 100644 --- a/crates/clash-verge-media-unlock/src/spotify.rs +++ b/crates/clash-verge-media-unlock/src/spotify.rs @@ -1,66 +1,61 @@ -use reqwest::{Client, Url}; +use reqwest::{Client, StatusCode, Url}; + +use crate::utils::classify_restricted_status; use super::UnlockItem; +pub(crate) const SPOTIFY_NAME: &str = "Spotify"; + pub(super) async fn check_spotify(client: &Client) -> UnlockItem { - let url = "https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json"; + let response = match client + .get("https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json") + .send() + .await + { + Ok(response) => response, + Err(_) => return UnlockItem::checked(SPOTIFY_NAME, "Failed", None), + }; - match client.get(url).send().await { - Ok(response) => { - let final_url = response.url().clone(); - let status_code = response.status(); - let body = response.text().await.unwrap_or_default(); + let status = response.status(); + let final_url = response.url().clone(); + let body = response.text().await.unwrap_or_default(); - let region = extract_region(&final_url).or_else(|| extract_region_from_body(&body)); - let status = determine_status(status_code.as_u16(), &body); + let region = extract_region(&final_url).or_else(|| extract_region_from_body(&body)); - UnlockItem::checked("Spotify", status, region) - } - Err(_) => UnlockItem::checked("Spotify", "Failed", None), - } + UnlockItem::checked(SPOTIFY_NAME, determine_status(status, &body), region) } -fn determine_status(status: u16, body: &str) -> &'static str { - if status == 403 || status == 451 { - return "No"; +fn determine_status(status: StatusCode, body: &str) -> &'static str { + if let Some(status) = classify_restricted_status(status) { + return status; } - if !(200..300).contains(&status) { - return "Failed"; + if body.to_ascii_lowercase().contains("not available in your country") { + "No" + } else { + "Yes" } - - let body_lower = body.to_lowercase(); - if body_lower.contains("not available in your country") { - return "No"; - } - - "Yes" } fn extract_region(url: &Url) -> Option { - let mut segments = url.path_segments()?; - let first_segment = segments.next()?; + let segment = url.path_segments()?.next()?; - if first_segment.is_empty() || first_segment == "api" { + if segment.is_empty() || segment == "api" { return None; } - let country_code = first_segment.split('-').next().unwrap_or(first_segment); - let upper = country_code.to_uppercase(); - Some(UnlockItem::region_label(&upper)) + let code = segment.split('-').next()?.to_ascii_uppercase(); + + Some(UnlockItem::region_label(&code)) } fn extract_region_from_body(body: &str) -> Option { - let marker = "\"countryCode\":\""; - if let Some(idx) = body.find(marker) { - let start = idx + marker.len(); - let rest = &body[start..]; - if let Some(end) = rest.find('"') { - let code = rest[..end].to_uppercase(); - if !code.is_empty() { - return Some(UnlockItem::region_label(&code)); - } - } - } - None + let code = body + .split_once(r#""countryCode":""#)? + .1 + .split_once('"')? + .0 + .to_ascii_uppercase(); + + (!code.is_empty()).then(|| UnlockItem::region_label(&code)) } diff --git a/crates/clash-verge-media-unlock/src/tiktok.rs b/crates/clash-verge-media-unlock/src/tiktok.rs index af320a639..3760209c4 100644 --- a/crates/clash-verge-media-unlock/src/tiktok.rs +++ b/crates/clash-verge-media-unlock/src/tiktok.rs @@ -1,80 +1,66 @@ -use std::sync::OnceLock; +use reqwest::{Client, StatusCode}; -use regex::Regex; -use reqwest::Client; +use crate::utils::{classify_restricted_status, extract_quoted_field}; use super::UnlockItem; +pub(crate) const TIKTOK_NAME: &str = "TikTok"; + pub(super) async fn check_tiktok(client: &Client) -> UnlockItem { - let trace_url = "https://www.tiktok.com/cdn-cgi/trace"; + let (mut status, mut region) = check_url(client, "https://www.tiktok.com/cdn-cgi/trace").await; - let mut status = String::from("Failed"); - let mut region = None; + if region.is_none() || status == "Failed" { + let (fallback_status, fallback_region) = check_url(client, "https://www.tiktok.com/").await; - if let Ok(response) = client.get(trace_url).send().await { - let status_code = response.status().as_u16(); - if let Ok(body) = response.text().await { - status = determine_status(status_code, &body).to_string(); - region = extract_region_from_body(&body); + if status != "No" { + status = fallback_status; } + + region = region.or(fallback_region); } - if (region.is_none() || status == "Failed") - && let Ok(response) = client.get("https://www.tiktok.com/").send().await - { - let status_code = response.status().as_u16(); - if let Ok(body) = response.text().await { - let fallback_status = determine_status(status_code, &body); - let fallback_region = extract_region_from_body(&body); - - if status != "No" { - status = fallback_status.to_string(); - } - - if region.is_none() { - region = fallback_region; - } - } - } - - UnlockItem::checked("TikTok", status, region) + UnlockItem::checked(TIKTOK_NAME, status, region) } -fn determine_status(status: u16, body: &str) -> &'static str { - if status == 403 || status == 451 { - return "No"; - } +async fn check_url(client: &Client, url: &str) -> (&'static str, Option) { + let Ok(response) = client.get(url).send().await else { + return ("Failed", None); + }; - if !(200..300).contains(&status) { - return "Failed"; - } + let status = response.status(); - let body_lower = body.to_lowercase(); - if body_lower.contains("access denied") - || body_lower.contains("not available in your region") - || body_lower.contains("tiktok is not available") - { - return "No"; - } + let Ok(body) = response.text().await else { + return ("Failed", None); + }; - "Yes" + (determine_status(status, &body), extract_region(&body)) } -fn extract_region_from_body(body: &str) -> Option { - static REGION_REGEX: OnceLock> = OnceLock::new(); - let regex = REGION_REGEX - .get_or_init(|| Regex::new(r#""region"\s*:\s*"([a-zA-Z-]+)""#).ok()) - .as_ref()?; - - if let Some(caps) = regex.captures(body) - && let Some(matched) = caps.get(1) - { - let raw = matched.as_str(); - let country_code = raw.split('-').next().unwrap_or(raw).to_uppercase(); - if !country_code.is_empty() { - return Some(UnlockItem::region_label(&country_code)); - } +fn determine_status(status: StatusCode, body: &str) -> &'static str { + if let Some(status) = classify_restricted_status(status) { + return status; } - None + let body = body.to_ascii_lowercase(); + + if [ + "access denied", + "not available in your region", + "tiktok is not available", + ] + .iter() + .any(|text| body.contains(text)) + { + "No" + } else { + "Yes" + } +} + +fn extract_region(body: &str) -> Option { + let region = extract_quoted_field(body, "region")?; + + let code = region.split('-').next()?.to_ascii_uppercase(); + + (!code.is_empty()).then(|| UnlockItem::region_label(&code)) } diff --git a/crates/clash-verge-media-unlock/src/types.rs b/crates/clash-verge-media-unlock/src/types.rs index 5f21e8477..f643cc3b9 100644 --- a/crates/clash-verge-media-unlock/src/types.rs +++ b/crates/clash-verge-media-unlock/src/types.rs @@ -38,26 +38,3 @@ impl UnlockItem { } } } - -const DEFAULT_UNLOCK_ITEM_NAMES: [&str; 13] = [ - "哔哩哔哩大陆", - "哔哩哔哩港澳台", - "ChatGPT iOS", - "ChatGPT Web", - "Claude", - "Gemini", - "YouTube Premium", - "Bahamut Anime", - "Netflix", - "Disney+", - "Prime Video", - "Spotify", - "TikTok", -]; - -pub fn default_unlock_items() -> Vec { - DEFAULT_UNLOCK_ITEM_NAMES - .iter() - .map(|name| UnlockItem::pending(name)) - .collect() -} diff --git a/crates/clash-verge-media-unlock/src/utils.rs b/crates/clash-verge-media-unlock/src/utils.rs index 91ff73ffa..2143a3504 100644 --- a/crates/clash-verge-media-unlock/src/utils.rs +++ b/crates/clash-verge-media-unlock/src/utils.rs @@ -1,4 +1,5 @@ use chrono::Local; +use reqwest::{Client, StatusCode}; pub fn get_local_date_string() -> String { let now = Local::now(); @@ -37,3 +38,36 @@ fn alpha2_to_emoji(alpha2: &str) -> String { .and_then(|x| char::from_u32(c2).map(|y| format!("{x}{y}"))) .unwrap_or_default() } + +pub(crate) async fn get_text(client: &Client, url: &str) -> Option { + client.get(url).send().await.ok()?.text().await.ok() +} + +pub(crate) async fn get_trace_location(client: &Client, url: &str) -> Option { + get_text(client, url) + .await? + .lines() + .find_map(|line| line.strip_prefix("loc=")) + .map(str::to_owned) +} + +pub(crate) fn extract_quoted_field<'a>(body: &'a str, key: &str) -> Option<&'a str> { + let (_, rest) = body.split_once(&format!(r#""{key}""#))?; + let value = rest.split_once(':')?.1.trim_start(); + let value = value.strip_prefix('"')?; + + Some(value.split_once('"')?.0) +} + +pub(crate) fn classify_restricted_status(status: StatusCode) -> Option<&'static str> { + if matches!( + status, + StatusCode::FORBIDDEN | StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS + ) { + Some("No") + } else if !status.is_success() { + Some("Failed") + } else { + None + } +} diff --git a/crates/clash-verge-media-unlock/src/youtube.rs b/crates/clash-verge-media-unlock/src/youtube.rs index 510437ce8..ed3f847ac 100644 --- a/crates/clash-verge-media-unlock/src/youtube.rs +++ b/crates/clash-verge-media-unlock/src/youtube.rs @@ -1,59 +1,67 @@ -use regex::Regex; -use reqwest::Client; +use reqwest::{Client, StatusCode}; -use clash_verge_logging::{Type, logging}; +use crate::utils::extract_quoted_field; use super::UnlockItem; +pub(crate) const YOUTUBE_PREMIUM_NAME: &str = "YouTube Premium"; + pub(super) async fn check_youtube_premium(client: &Client) -> UnlockItem { - let url = "https://www.youtube.com/premium?hl=en"; - let item = |status: &str, region: Option| UnlockItem::checked("YouTube Premium", status, region); + let Ok(response) = client.get("https://www.youtube.com/premium?hl=en").send().await else { + return UnlockItem::checked(YOUTUBE_PREMIUM_NAME, "Failed", None); + }; - match client.get(url).send().await { - Ok(response) => { - let status_code = response.status().as_u16(); + let status = response.status(); - if let Ok(body) = response.text().await { - let body_lower = body.to_ascii_lowercase(); - let region = [ - r#"id=["']country-code["'][^>]*>\s*([A-Za-z]{2,3})\s*<"#, - r#""GL"\s*:\s*"([A-Za-z]{2})""#, - r#""countryCode"\s*:\s*"([A-Za-z]{2})""#, - r#""country_code"\s*:\s*"([A-Za-z]{2})""#, - ] - .iter() - .find_map(|pattern| match Regex::new(pattern) { - Ok(re) => re - .captures(&body) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str().trim().to_ascii_uppercase()), - Err(e) => { - logging!(error, Type::Network, "Failed to compile YouTube Premium regex: {}", e); - None - } - }) - .map(|country_code| UnlockItem::region_label(&country_code)); + let Ok(body) = response.text().await else { + return UnlockItem::checked(YOUTUBE_PREMIUM_NAME, "Failed", None); + }; - let status = if body_lower.contains("youtube premium is not available in your country") - || body_lower.contains("premium is not available in your country") - || body_lower.contains("premium is not available in your region") - { - "No" - } else if (200..300).contains(&status_code) - && (body_lower.contains("youtube premium") - || body_lower.contains("ad-free") - || body_lower.contains(r#""browseid":"spunlimited""#)) - { - "Yes" - } else { - "Failed" - }; + let region = extract_region(&body).map(|code| UnlockItem::region_label(&code)); - item(status, region) - } else { - item("Failed", None) - } - } - Err(_) => item("Failed", None), + UnlockItem::checked(YOUTUBE_PREMIUM_NAME, determine_status(status, &body), region) +} + +fn determine_status(status: StatusCode, body: &str) -> &'static str { + let body = body.to_ascii_lowercase(); + + if [ + "youtube premium is not available in your country", + "premium is not available in your country", + "premium is not available in your region", + ] + .iter() + .any(|text| body.contains(text)) + { + return "No"; + } + + if status.is_success() + && ["youtube premium", "ad-free", r#""browseid":"spunlimited""#] + .iter() + .any(|text| body.contains(text)) + { + "Yes" + } else { + "Failed" } } + +fn extract_region(body: &str) -> Option { + ["GL", "countryCode", "country_code"] + .iter() + .find_map(|key| extract_quoted_field(body, key)) + .or_else(|| extract_country_code(body)) + .map(str::to_ascii_uppercase) +} + +fn extract_country_code(body: &str) -> Option<&str> { + let rest = body + .split_once(r#"id="country-code""#) + .or_else(|| body.split_once("id='country-code'"))? + .1; + + let value = rest.split_once('>')?.1.split_once('<')?.0.trim(); + + (!value.is_empty()).then_some(value) +} diff --git a/src-tauri/src/cmd/media_unlock_checker/mod.rs b/src-tauri/src/cmd/media_unlock_checker/mod.rs index 33ca1d92e..42862117e 100644 --- a/src-tauri/src/cmd/media_unlock_checker/mod.rs +++ b/src-tauri/src/cmd/media_unlock_checker/mod.rs @@ -1,12 +1,41 @@ pub use clash_verge_media_unlock::UnlockItem; -use tauri::command; +use reqwest::{Client, Proxy}; +use std::time::Duration; +use tauri::{command, ipc::Channel}; #[command] -pub async fn get_unlock_items() -> Result, String> { - Ok(clash_verge_media_unlock::default_unlock_items()) +pub fn get_unlock_items() -> Vec { + clash_verge_media_unlock::default_unlock_items() +} + +async fn create_client() -> Result { + let port = crate::config::MixedPort::effective().await; + let proxy = Proxy::all(format!("http://127.0.0.1:{port}")).map_err(|error| format!("创建代理失败: {error}"))?; + + Client::builder() + .use_rustls_tls() + .http1_only() + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .tcp_keepalive(Duration::from_secs(60)) + .cookie_store(true) + .proxy(proxy) + .build() + .map_err(|error| format!("创建HTTP客户端失败: {error}")) } #[command] -pub async fn check_media_unlock() -> Result, String> { - clash_verge_media_unlock::check_media_unlock().await +pub async fn check_media_unlock(on_complete: Channel) -> Result, String> { + let client = create_client().await?; + Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| { + let _ = on_complete.send(item.clone()); + }) + .await) +} + +#[command] +pub async fn check_media_unlock_item(name: String) -> Result { + let client = create_client().await?; + clash_verge_media_unlock::check_media_unlock_item(&client, &name).await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55f01a9cc..45e446fad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -210,6 +210,7 @@ mod app_init { cmd::restore_webdav_backup, cmd::get_unlock_items, cmd::check_media_unlock, + cmd::check_media_unlock_item, ] } } diff --git a/src/pages/unlock.tsx b/src/pages/unlock.tsx index ea91e6bb8..cbf482c63 100644 --- a/src/pages/unlock.tsx +++ b/src/pages/unlock.tsx @@ -19,9 +19,9 @@ import { alpha, useTheme, } from '@mui/material' -import { invoke } from '@tauri-apps/api/core' +import { Channel, invoke } from '@tauri-apps/api/core' import { useLockFn } from 'ahooks' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { BaseEmpty, BasePage } from '@/components/base' @@ -35,7 +35,6 @@ interface UnlockItem { } const UNLOCK_RESULTS_STORAGE_KEY = 'clash_verge_unlock_results' -const UNLOCK_RESULTS_TIME_KEY = 'clash_verge_unlock_time' const STATUS_LABEL_KEYS: Record = { Pending: 'tests.statuses.test.pending', @@ -95,201 +94,107 @@ const UnlockPage = () => { const theme = useTheme() const [unlockItems, setUnlockItems] = useState([]) + const unlockItemsRef = useRef([]) const [isCheckingAll, setIsCheckingAll] = useState(false) const [loadingItems, setLoadingItems] = useState([]) - const sortItemsByName = useCallback((items: UnlockItem[]) => { - return [...items].sort((a, b) => a.name.localeCompare(b.name)) - }, []) + const saveResultsToStorage = (items: UnlockItem[]) => { + try { + localStorage.setItem(UNLOCK_RESULTS_STORAGE_KEY, JSON.stringify(items)) + } catch (err) { + console.error('Failed to save results to storage:', err) + } + } - const mergeUnlockItems = useCallback( - (defaults: UnlockItem[], existing?: UnlockItem[] | null) => { - if (!existing || existing.length === 0) { - return defaults - } - - const normalizedExisting = dedupeUnlockItems(existing) - const existingMap = new Map( - normalizedExisting.map((item) => [ - normalizeUnlockName(item.name), - item, - ]), - ) - const merged = defaults.map((item) => { - const normalizedName = normalizeUnlockName(item.name) - const matchedItem = existingMap.get(normalizedName) - if (matchedItem) { - return { ...matchedItem, name: item.name } - } - return item - }) - - const mergedNameSet = new Set( - merged.map((item) => normalizeUnlockName(item.name)), - ) - normalizedExisting.forEach((item) => { - const normalizedName = normalizeUnlockName(item.name) - if (!mergedNameSet.has(normalizedName)) { - merged.push(item) - mergedNameSet.add(normalizedName) - } - }) - - return merged - }, - [], - ) - - // 保存测试结果到本地存储 - const saveResultsToStorage = useCallback( - (items: UnlockItem[], time: string | null) => { - try { - localStorage.setItem(UNLOCK_RESULTS_STORAGE_KEY, JSON.stringify(items)) - if (time) { - localStorage.setItem(UNLOCK_RESULTS_TIME_KEY, time) - } - } catch (err) { - console.error('Failed to save results to storage:', err) - } - }, - [], - ) - - const loadResultsFromStorage = useCallback((): { - items: UnlockItem[] | null - time: string | null - } => { + useEffect(() => { + let storedItems: UnlockItem[] = [] try { const itemsJson = localStorage.getItem(UNLOCK_RESULTS_STORAGE_KEY) - const time = localStorage.getItem(UNLOCK_RESULTS_TIME_KEY) - if (itemsJson) { - const parsedItems = JSON.parse(itemsJson) as UnlockItem[] - return { - items: dedupeUnlockItems(parsedItems), - time, - } + storedItems = dedupeUnlockItems(JSON.parse(itemsJson) as UnlockItem[]) } } catch (err) { console.error('Failed to load results from storage:', err) } - return { items: null, time: null } - }, []) - - const getUnlockItems = useCallback( - async ( - existingItems: UnlockItem[] | null = null, - existingTime: string | null = null, - ) => { + void (async () => { try { const defaultItems = await invoke('get_unlock_items') - const mergedItems = mergeUnlockItems(defaultItems, existingItems) - const sortedItems = sortItemsByName(mergedItems) - - setUnlockItems(sortedItems) - saveResultsToStorage( - sortedItems, - existingItems && existingItems.length > 0 ? existingTime : null, + const existingMap = new Map( + storedItems.map((item) => [normalizeUnlockName(item.name), item]), ) + const mergedItems = defaultItems.map((item) => { + const matchedItem = existingMap.get(normalizeUnlockName(item.name)) + return matchedItem ? { ...matchedItem, name: item.name } : item + }) + + const sortedItems = mergedItems.sort((a, b) => + a.name.localeCompare(b.name), + ) + unlockItemsRef.current = sortedItems + setUnlockItems(sortedItems) } catch (err: any) { console.error('Failed to get unlock items:', err) } - }, - [mergeUnlockItems, saveResultsToStorage, sortItemsByName], - ) - - useEffect(() => { - void (async () => { - const { items: storedItems, time: storedTime } = loadResultsFromStorage() - - if (storedItems && storedItems.length > 0) { - setUnlockItems(sortItemsByName(storedItems)) - await getUnlockItems(storedItems, storedTime) - } else { - await getUnlockItems() - } })() - }, [getUnlockItems, loadResultsFromStorage, sortItemsByName]) - - const invokeWithTimeout = async ( - cmd: string, - args?: any, - timeout = 15000, - ): Promise => { - return Promise.race([ - invoke(cmd, args), - new Promise((_, reject) => - setTimeout( - () => - reject(new Error(t('tests.unlock.page.messages.detectionTimeout'))), - timeout, - ), - ), - ]) - } + }, []) // 执行全部项目检测 const checkAllMedia = useLockFn(async () => { + const onComplete = new Channel((result) => { + const updatedItems = unlockItemsRef.current.map((item) => + item.name === result.name ? result : item, + ) + unlockItemsRef.current = updatedItems + setUnlockItems(updatedItems) + setLoadingItems((items) => items.filter((name) => name !== result.name)) + }) + try { setIsCheckingAll(true) - const result = await invokeWithTimeout('check_media_unlock') - const sortedItems = sortItemsByName(dedupeUnlockItems(result)) + setLoadingItems(unlockItems.map((item) => item.name)) + const result = await invoke('check_media_unlock', { + onComplete, + }) + const sortedItems = result.sort((a, b) => a.name.localeCompare(b.name)) + unlockItemsRef.current = sortedItems setUnlockItems(sortedItems) - const currentTime = new Date().toLocaleString() - - saveResultsToStorage(sortedItems, currentTime) - - setIsCheckingAll(false) + saveResultsToStorage(sortedItems) } catch (err: any) { - setIsCheckingAll(false) showNotice.error('tests.unlock.page.messages.detectionTimeout', err) console.error('Failed to check media unlock:', err) + } finally { + setLoadingItems([]) + setIsCheckingAll(false) } }) // 检测单个流媒体服务 - const checkSingleMedia = useLockFn(async (name: string) => { + const checkSingleMedia = async (name: string) => { + setLoadingItems((items) => [...items, name]) try { - setLoadingItems((prev) => [...prev, name]) - const result = await invokeWithTimeout('check_media_unlock') - const dedupedResult = dedupeUnlockItems(result) - - const normalizedTargetName = normalizeUnlockName(name) - const targetItem = dedupedResult.find( - (item: UnlockItem) => - normalizeUnlockName(item.name) === normalizedTargetName, + const result = await invoke('check_media_unlock_item', { + name, + }) + const updatedItems = unlockItemsRef.current.map((item) => + item.name === name ? result : item, ) - if (targetItem) { - const updatedItems = sortItemsByName( - dedupeUnlockItems( - unlockItems.map((item: UnlockItem) => - normalizeUnlockName(item.name) === normalizedTargetName - ? targetItem - : item, - ), - ), - ) - - setUnlockItems(updatedItems) - const currentTime = new Date().toLocaleString() - - saveResultsToStorage(updatedItems, currentTime) - } - - setLoadingItems((prev) => prev.filter((item) => item !== name)) + unlockItemsRef.current = updatedItems + setUnlockItems(updatedItems) + saveResultsToStorage(updatedItems) } catch (err: any) { - setLoadingItems((prev) => prev.filter((item) => item !== name)) showNotice.error( 'tests.unlock.page.messages.detectionFailedWithName', { name }, err, ) console.error(`Failed to check ${name}:`, err) + } finally { + setLoadingItems((items) => items.filter((item) => item !== name)) } - }) + } // 状态颜色 const getStatusColor = (status: string) => { @@ -315,7 +220,6 @@ const UnlockPage = () => { if (status === 'Yes') return if (status === 'No') return if (status === 'Soon') return - if (status.includes('Failed')) return return } @@ -339,7 +243,11 @@ const UnlockPage = () => {