Ctrl K
ComunidadGuías…

src/state.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use melodl::SongInfo;
use serde::Serialize;
use uuid::Uuid;

use crate::music::SongView;

pub fn now_ts() -> f64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs_f64()
}

pub fn gen_id(len: usize) -> String {
    Uuid::new_v4().simple().to_string()[..len].to_string()
}

/// TTL 缓存:惰性过期,put 时顺手 GC
pub struct TtlCache<T> {
    ttl: Duration,
    data: Mutex<HashMap<String, (Instant, T)>>,
}

impl<T: Clone> TtlCache<T> {
    pub fn new(ttl_secs: u64) -> Self {
        Self {
            ttl: Duration::from_secs(ttl_secs),
            data: Mutex::new(HashMap::new()),
        }
    }

    pub fn put(&self, key: impl Into<String>, value: T) {
        let mut data = self.data.lock().unwrap();
        data.retain(|_, (ts, _)| ts.elapsed() <= self.ttl);
        data.insert(key.into(), (Instant::now(), value));
    }

    pub fn get(&self, key: &str) -> Option<T> {
        let mut data = self.data.lock().unwrap();
        match data.get(key) {
            Some((ts, _)) if ts.elapsed() > self.ttl => {
                data.remove(key);
                None
            }
            Some((_, v)) => Some(v.clone()),
            None => None,
        }
    }
}

/// 异步搜索任务注册表 + 结果缓存(TTL 内同关键词去重)
pub struct SearchStore {
    ttl: Duration,
    inner: Mutex<SearchStoreInner>,
}

#[derive(Default)]
struct SearchStoreInner {
    jobs: HashMap<String, SearchJob>,
    by_keyword: HashMap<String, String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SearchJob {
    pub id: String,
    pub keyword: String,
    pub status: String, // pending / running / done / failed
    pub error: Option<String>,
    pub created_at: f64,
    pub started_at: Option<f64>,
    pub finished_at: Option<f64>,
    pub elapsed: Option<f64>,
    pub sources_ok: Vec<String>,
    pub sources_failed: Vec<String>,
    pub data: Vec<SongView>,
    #[serde(skip)]
    pub raws: Vec<SongInfo>,
    pub last_access: f64,
}

pub enum CreateOutcome {
    /// 命中缓存(job 为完整结果)
    Cached { job_id: String, job: SearchJob },
    /// 同关键词已有任务在执行
    Running { job_id: String },
    /// 新建任务,调用方需入队执行
    Created { job_id: String },
}

impl SearchStore {
    pub fn new(ttl_secs: u64) -> Self {
        Self {
            ttl: Duration::from_secs(ttl_secs),
            inner: Mutex::new(SearchStoreInner::default()),
        }
    }

    pub fn create_or_get(&self, keyword: &str, cache: &TtlCache<SongInfo>) -> CreateOutcome {
        let mut inner = self.inner.lock().unwrap();
        self.gc(&mut inner);
        if let Some(job_id) = inner.by_keyword.get(keyword).cloned() {
            if let Some(job) = inner.jobs.get_mut(&job_id) {
                match job.status.as_str() {
                    "pending" | "running" => return CreateOutcome::Running { job_id },
                    "done" => {
                        // 缓存命中:刷新 token 有效期,避免拿到手已过期
                        for (view, raw) in job.data.iter().zip(job.raws.iter()) {
                            cache.put(view.token.clone(), raw.clone());
                        }
                        job.last_access = now_ts();
                        return CreateOutcome::Cached {
                            job_id,
                            job: job.clone(),
                        };
                    }
                    _ => {} // failed 等:落入下面重建
                }
            }
        }
        let job_id = gen_id(12);
        let job = SearchJob {
            id: job_id.clone(),
            keyword: keyword.to_string(),
            status: "pending".to_string(),
            error: None,
            created_at: now_ts(),
            started_at: None,
            finished_at: None,
            elapsed: None,
            sources_ok: vec![],
            sources_failed: vec![],
            data: vec![],
            raws: vec![],
            last_access: now_ts(),
        };
        inner.jobs.insert(job_id.clone(), job);
        inner.by_keyword.insert(keyword.to_string(), job_id.clone());
        CreateOutcome::Created { job_id }
    }

    pub fn update(&self, job_id: &str, f: impl FnOnce(&mut SearchJob)) {
        let mut inner = self.inner.lock().unwrap();
        if let Some(job) = inner.jobs.get_mut(job_id) {
            f(job);
        }
    }

    pub fn get(&self, job_id: &str) -> Option<SearchJob> {
        let mut inner = self.inner.lock().unwrap();
        let job = inner.jobs.get_mut(job_id)?;
        if job.status == "running" {
            if let Some(started) = job.started_at {
                job.elapsed = Some(((now_ts() - started) * 10.0).round() / 10.0);
            }
        }
        Some(job.clone())
    }

    fn gc(&self, inner: &mut SearchStoreInner) {
        let now = now_ts();
        let ttl = self.ttl.as_secs_f64();
        let expired: Vec<String> = inner
            .jobs
            .values()
            .filter(|j| now - j.last_access > ttl)
            .map(|j| j.id.clone())
            .collect();
        for jid in expired {
            if let Some(job) = inner.jobs.remove(&jid) {
                if inner.by_keyword.get(&job.keyword) == Some(&jid) {
                    inner.by_keyword.remove(&job.keyword);
                }
            }
        }
    }
}

/// 下载任务状态表(内存实现,重启清空)
#[derive(Default)]
pub struct TaskStore {
    tasks: Mutex<HashMap<String, Task>>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Task {
    pub id: String,
    pub title: String,
    pub status: String, // queued / matching / downloading / done / failed / unmatched
    pub file: Option<String>,
    pub error: Option<String>,
    pub created_at: f64,
    pub batch_id: Option<String>,
    pub source: Option<String>,
    pub ext: Option<String>,
}

impl TaskStore {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn create(&self, title: impl Into<String>, batch_id: Option<String>) -> String {
        let task_id = gen_id(12);
        let task = Task {
            id: task_id.clone(),
            title: title.into(),
            status: "queued".to_string(),
            file: None,
            error: None,
            created_at: now_ts(),
            batch_id,
            source: None,
            ext: None,
        };
        self.tasks.lock().unwrap().insert(task_id.clone(), task);
        task_id
    }

    pub fn update(&self, task_id: &str, f: impl FnOnce(&mut Task)) {
        if let Some(task) = self.tasks.lock().unwrap().get_mut(task_id) {
            f(task);
        }
    }

    pub fn get(&self, task_id: &str) -> Option<Task> {
        self.tasks.lock().unwrap().get(task_id).cloned()
    }

    pub fn list(&self, limit: usize) -> Vec<Task> {
        let mut tasks: Vec<Task> = self.tasks.lock().unwrap().values().cloned().collect();
        tasks.sort_by(|a, b| b.created_at.total_cmp(&a.created_at));
        tasks.truncate(limit);
        tasks
    }
}

/// 串行任务队列:单 consumer 逐个处理,避免并发下载
pub fn spawn_worker<T, F, Fut>(handler: F) -> tokio::sync::mpsc::UnboundedSender<T>
where
    T: Send + 'static,
    F: Fn(T) -> Fut + Send + Sync + 'static,
    Fut: std::future::Future<Output = ()> + Send + 'static,
{
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<T>();
    let handler = std::sync::Arc::new(handler);
    tokio::spawn(async move {
        while let Some(item) = rx.recv().await {
            (handler)(item).await;
        }
    });
    tx
}