uta 0.1.2

Command-line music search and downloader for QQ Music and NetEase Cloud Music, lossless first, shipped as a single static binary. For learning and research only; non-commercial use.
//! QQ 音乐协议实现。

pub mod album;
pub mod api;
pub mod artist;
pub mod lyric;
pub mod playlist;
#[cfg(feature = "qimei")]
pub mod qimei;
pub mod resolve;
pub mod search;
pub mod song;

use anyhow::Result;
use serde_json::Value;
use tokio::sync::Mutex;
use tracing::debug;

use crate::config::Config;

/// tang 接口的最大并发请求数。
pub const TANG_CONCURRENCY: usize = 2;

/// 一次运行共享的上下文:HTTP 客户端、配置、QIMEI36。
pub struct Session {
    pub client: reqwest::Client,
    pub config: Config,
    qimei36: Mutex<Option<String>>,
    /// tang 限流:实测并发 8 会返回"请求过于频繁",串行与并发 2 正常
    pub tang_permits: tokio::sync::Semaphore,
    /// tang 配额耗尽后置位,本次运行不再请求(熔断)
    pub tang_exhausted: std::sync::atomic::AtomicBool,
}

impl Session {
    pub fn new(client: reqwest::Client, config: Config) -> Self {
        Self {
            client,
            config,
            qimei36: Mutex::new(None),
            tang_permits: tokio::sync::Semaphore::new(TANG_CONCURRENCY),
            tang_exhausted: std::sync::atomic::AtomicBool::new(false),
        }
    }

    /// 取 QIMEI36:首次使用时请求一次(失败或未启用 qimei 时用随机值)。
    ///
    /// 实测搜索接口按 QIMEI36 限流:Python 版的回落常量被大量客户端共用,
    /// 经常处于"code=0 但结果为空"的状态,而任意新的 QIMEI36 都正常。
    pub async fn qimei36(&self) -> String {
        let mut guard = self.qimei36.lock().await;
        if let Some(q) = guard.as_ref() {
            return q.clone();
        }
        let q = self.obtain_qimei().await;
        *guard = Some(q.clone());
        q
    }

    /// 重新请求 QIMEI36(搜索返回空结果时调用)。
    pub async fn refresh_qimei(&self) {
        let q = self.obtain_qimei().await;
        *self.qimei36.lock().await = Some(q);
    }

    #[cfg(feature = "qimei")]
    async fn obtain_qimei(&self) -> String {
        match qimei::obtain(&self.client).await {
            Ok(q) => {
                debug!(q36 = %q, "取得 QIMEI36");
                q
            }
            Err(e) => {
                debug!("qimei 请求失败,使用随机 QIMEI36: {e:#}");
                api::random_qimei36()
            }
        }
    }

    #[cfg(not(feature = "qimei"))]
    async fn obtain_qimei(&self) -> String {
        debug!("未启用 qimei feature,使用随机 QIMEI36");
        api::random_qimei36()
    }

    pub async fn call(
        &self,
        qimei36: &str,
        ct: &str,
        module: &str,
        method: &str,
        param: Value,
    ) -> Result<Value> {
        api::call(&self.client, qimei36, ct, module, method, param).await
    }
}

/// Python 版 QQ_MUSIC_HOSTS。
const QQ_HOSTS: &[&str] = &[
    "y.qq.com",
    "i.y.qq.com",
    "m.y.qq.com",
    "c.y.qq.com",
    "c6.y.qq.com",
    "music.qq.com",
];

/// 链接是否属于 QQ 音乐域名(含子域名)。
pub fn is_qq_host(url: &str) -> bool {
    crate::net::host_in(url, QQ_HOSTS)
}