mineral-server 0.5.10

Mineral 后台 server:收纳 audio engine + task scheduler + channels,对外发 ClientHandle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! 音频本体的本地缓存:在通用 [`CacheIndex`] 之上,补"按歌组织可读库路径"的领域逻辑。
//!
//! 落盘布局 `<source>/<quality>/<album>/<title>.<ext>`(可读、无 hash,可被播放器直接打开);
//! index 键 `cache_key` = `{song_id.qualified()}:{quality}`,每音质一份,落 `audio_cache` 表。
//! 缓存不可用(未开/open 失败)时退化成 null-object:`get` 恒 miss、`put_played` 静默成功,
//! 不影响流式播放。

use std::path::PathBuf;

use crate::persistence::ServerStore;
use mineral_model::{AudioFormat, BitRate, Song, SongId};
use mineral_persist::CacheIndex;

use crate::playback_instance::PlaybackInstanceId;

/// 单段文件名 / 目录名的最大字节数(留余量,远低于 255 上限)。
const SEGMENT_MAX_BYTES: usize = 200;

/// 音频本体缓存。线程安全(内部 [`CacheIndex`] 自带锁)。`get` sync、`put_played` async(写穿透)。
pub(crate) struct MediaCache {
    /// 后端缓存索引(DB-backed);禁用时为 null-object。
    index: CacheIndex,

    /// 缓存根目录;`None` = 禁用。capture 临时文件落到其下 `tmp/`(与最终库路径同分区 → harvest 走 rename)。
    root: Option<PathBuf>,
}

impl MediaCache {
    /// 打开(或创建)音频缓存:在 `persist` 上建 `audio_cache` 索引表 + 载入镜像。
    ///
    /// # Params:
    ///   - `persist`: daemon 持久化句柄(索引落其 `mineral.db`)
    ///   - `dir`: 缓存文件根目录(`relpath` 相对它)
    ///   - `capacity`: 容量上限字节
    ///
    /// # Return:
    ///   就绪缓存;底层 open 失败返回 `Err`(调用方应降级到 [`Self::disabled`])。
    pub(crate) async fn open(
        persist: &ServerStore,
        dir: PathBuf,
        capacity: u64,
    ) -> Result<Self, crate::persistence::Error> {
        // 清掉上次进程遗留的半截 capture(崩溃 / 被 kill 时没下完、也没 harvest 的 .part)。
        let tmp = dir.join("tmp");
        if tmp.is_dir() {
            drop(std::fs::remove_dir_all(&tmp));
        }
        let index = persist.audio_cache(dir.clone(), capacity).await?;
        Ok(Self {
            index,
            root: Some(dir),
        })
    }

    /// 禁用态缓存:`get` 恒 miss、`put_played` 静默成功、`capture_path` 恒 `None`。
    pub(crate) fn disabled() -> Self {
        Self {
            index: CacheIndex::disabled(),
            root: None,
        }
    }

    /// capture 临时落点 `<root>/tmp/<sanitized-key>-<instance>.part`,与最终库路径同分区。
    ///
    /// # Params:
    ///   - `song_id`: 歌曲 ID
    ///   - `quality`: 入库音质
    ///   - `instance_id`: 本次 playback instance,隔离同曲并发 capture
    ///
    /// # Return:
    ///   临时路径;缓存禁用返回 `None`。
    pub(crate) fn capture_path(
        &self,
        song_id: &SongId,
        quality: BitRate,
        instance_id: PlaybackInstanceId,
    ) -> Option<PathBuf> {
        let root = self.root.as_ref()?;
        let safe = sanitize_segment(&cache_key(song_id, quality), "capture");
        Some(root.join("tmp").join(format!("{safe}-{instance_id}.part")))
    }

    /// 命中返回缓存音频文件的绝对路径(可直接 `MediaUrl::Local` 播放),否则 `None`。
    ///
    /// # Params:
    ///   - `song_id`: 歌曲 ID
    ///   - `quality`: 请求音质(与入库时一致才命中)
    ///
    /// # Return:
    ///   命中且文件存在返回路径,否则 None。
    pub(crate) fn get(&self, song_id: &SongId, quality: BitRate) -> Option<PathBuf> {
        self.index.get(&cache_key(song_id, quality))
    }

    /// 把一首**已自然播完**的歌的音频文件收编进缓存(move 入库,可读库路径)。
    ///
    /// # Params:
    ///   - `song`: 刚播完的歌(取来源 / 专辑 / 标题组库路径)
    ///   - `quality`: 入库音质(决定 index 键与目录;应与播放请求一致)
    ///   - `format`: 实际音频格式(决定扩展名;未知按音质兜底)
    ///   - `src`: capture 落盘文件路径(成功后被移走)
    ///
    /// # Return:
    ///   入库时 LRU 驱逐掉的记录(可空;供上层 cache_evictions 埋点);缓存禁用返回空 vec。
    pub(crate) async fn put_played(
        &self,
        song: &Song,
        quality: BitRate,
        format: Option<&AudioFormat>,
        src: &std::path::Path,
    ) -> Result<Vec<mineral_persist::Evicted>, mineral_persist::Error> {
        let key = cache_key(&song.id, quality);
        let (subdir, file_name) = library_relpath(song, quality, format);
        self.index.record_file(&key, src, &subdir, &file_name).await
    }
}

/// 一首歌在库里的相对落点 `(subdir, file_name)` = `<source>/<quality>/<album>` + `<title>.<ext>`。
/// 缓存入库与永久导出共用同一套命名(下载导出 [`crate::download`] 复用)。
///
/// # Params:
///   - `song`: 歌曲(取来源 / 专辑 / 标题)
///   - `quality`: 音质(目录层)
///   - `format`: 实际格式(定扩展名;未知按音质兜底)
///
/// # Return:
///   `(subdir, file_name)`。
pub(crate) fn library_relpath(
    song: &Song,
    quality: BitRate,
    format: Option<&AudioFormat>,
) -> (String, String) {
    let (subdir, stem) = library_dir_and_stem(song, quality);
    let ext_seg = sanitize_segment(&ext_for(format, quality), "bin");
    (subdir, format!("{stem}.{ext_seg}"))
}

/// 一首歌在库里的「专辑目录 + 文件名主干(不含扩展名)」= `<source>/<quality>/<album>` + `<title>`。
/// 与 [`library_relpath`] 同一套命名,只是不带扩展名——供下载导出**按文件系统反查**:磁盘上
/// 扩展名未知时,定位到专辑目录后按主干匹配文件(见 [`crate::resolve`])。
///
/// # Params:
///   - `song`: 歌曲(取来源 / 专辑 / 标题)
///   - `quality`: 音质(目录层)
///
/// # Return:
///   `(subdir, title_stem)`,各段已 sanitize。
pub(crate) fn library_dir_and_stem(song: &Song, quality: BitRate) -> (String, String) {
    let album = song.album.as_ref().map(|a| a.name.as_str());
    let title = if song.name.is_empty() {
        song.id.as_str()
    } else {
        song.name.as_str()
    };
    (
        library_subdir(song.source().name(), album, quality),
        sanitize_segment(title, "_untitled"),
    )
}

/// 音频缓存索引键:`{song_id.qualified()}:{quality}`,全局唯一、每音质独立(`audio_cache` 表用)。
/// 下载导出不走索引(按文件系统路径反查,见 [`crate::resolve`]),故无需此键。
///
/// # Params:
///   - `song_id`: 歌曲 ID
///   - `quality`: 音质
///
/// # Return:
///   形如 `netease:186016:lossless`。
pub(crate) fn cache_key(song_id: &SongId, quality: BitRate) -> String {
    format!("{}:{}", song_id.qualified(), quality.as_str())
}

/// 库内专辑目录 `<source>/<quality>/<album>`(各段已 sanitize;`album` 为 `None` / 空 → `_unknown`)。
///
/// # Params:
///   - `source`: 来源名(如 `netease`)
///   - `album`: 专辑名
///   - `quality`: 音质(目录层)
///
/// # Return:
///   形如 `netease/lossless/叶惠美`。
fn library_subdir(source: &str, album: Option<&str>, quality: BitRate) -> String {
    let source_seg = sanitize_segment(source, "_unknown");
    let album_seg = sanitize_segment(album.unwrap_or(""), "_unknown");
    format!("{source_seg}/{}/{album_seg}", quality.as_str())
}

/// 扩展名:优先用实际格式,未知(拿不到格式)按音质兜底无损 `flac` / 有损 `mp3`。
///
/// # Params:
///   - `format`: 实际音频格式;`None` = 未知
///   - `quality`: 音质(format 未知时据此兜底)
///
/// # Return:
///   扩展名(不含点)。
fn ext_for(format: Option<&AudioFormat>, quality: BitRate) -> String {
    if let Some(f) = format {
        return f.as_str().to_owned();
    }
    match quality {
        BitRate::Lossless | BitRate::Hires => "flac",
        BitRate::Standard | BitRate::Higher | BitRate::Exhigh => "mp3",
    }
    .to_owned()
}

/// 把一段文本规整成合法的文件 / 目录名:非法字符→`_`、去首尾空白与尾点、按字节截断、空段兜底。
///
/// # Params:
///   - `raw`: 原始文本(歌名 / 专辑名等)
///   - `fallback`: 规整后为空时的兜底名
///
/// # Return:
///   合法的单段名(非空)。
fn sanitize_segment(raw: &str, fallback: &str) -> String {
    let mut out = String::with_capacity(raw.len());
    for ch in raw.chars() {
        if matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || ch.is_control() {
            out.push('_');
        } else {
            out.push(ch);
        }
    }
    let trimmed = out.trim().trim_end_matches('.').trim();
    let truncated = truncate_bytes(trimmed, SEGMENT_MAX_BYTES);
    if truncated.is_empty() {
        fallback.to_owned()
    } else {
        truncated.to_owned()
    }
}

/// 按 UTF-8 字符边界把 `s` 截断到不超过 `max` 字节。
///
/// # Params:
///   - `s`: 输入
///   - `max`: 最大字节数
///
/// # Return:
///   `s` 的前缀子串(`<= max` 字节,落在字符边界)。
fn truncate_bytes(s: &str, max: usize) -> &str {
    if s.len() <= max {
        return s;
    }
    let mut end = max;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    s.get(..end).unwrap_or(s)
}

#[cfg(test)]
mod tests {
    use mineral_model::{AlbumId, AlbumRef, AudioFormat, BitRate, Song, SongId, SourceKind};

    use crate::persistence::ServerStore;

    use crate::playback_instance::PlaybackInstanceId;

    use super::{
        MediaCache, cache_key, ext_for, library_relpath, sanitize_segment, truncate_bytes,
    };

    /// 起一个临时 DB 上的启用态 MediaCache(缓存文件落 `dir`)。
    async fn open_cache(
        db: &std::path::Path,
        dir: std::path::PathBuf,
    ) -> color_eyre::Result<MediaCache> {
        let persist = ServerStore::open(db).await?;
        let capacity = *crate::config::DaemonConfig::defaults()?
            .cache()
            .audio_capacity();
        Ok(MediaCache::open(&persist, dir, capacity).await?)
    }

    fn song(id: &str, name: &str, album: Option<&str>) -> Song {
        Song::builder()
            .id(SongId::new(SourceKind::NETEASE, id))
            .name(name.to_owned())
            .album(album.map(|a| AlbumRef {
                id: AlbumId::new(SourceKind::NETEASE, "0"),
                name: a.to_owned(),
            }))
            .build()
    }

    #[test]
    fn key_includes_qualified_and_quality() {
        let s = song("186016", "晴天", Some("叶惠美"));
        assert_eq!(
            cache_key(&s.id, BitRate::Lossless),
            "netease:186016:lossless"
        );
        assert_eq!(cache_key(&s.id, BitRate::Exhigh), "netease:186016:exhigh");
        assert_ne!(
            cache_key(&s.id, BitRate::Lossless),
            cache_key(&s.id, BitRate::Exhigh),
            "不同音质应是不同键"
        );
    }

    #[test]
    fn relpath_is_readable_library_layout() {
        let s = song("186016", "晴天", Some("叶惠美"));
        let (subdir, file) = library_relpath(&s, BitRate::Lossless, Some(&AudioFormat::Flac));
        assert_eq!(subdir, "netease/lossless/叶惠美");
        assert_eq!(file, "晴天.flac");
    }

    #[test]
    fn relpath_falls_back_on_empty_album_and_title() {
        // 专辑缺失 → _unknown;标题 sanitize 后为空(全空白)→ _untitled。
        let s = song("186016", "   ", None);
        let (subdir, file) = library_relpath(&s, BitRate::Higher, Some(&AudioFormat::Mp3));
        assert_eq!(subdir, "netease/higher/_unknown");
        assert_eq!(file, "_untitled.mp3");
    }

    /// put_played 把 capture 文件 move 入可读库路径,get 用同 (song_id, quality) 命中;源被移走。
    #[tokio::test]
    async fn put_played_then_get_roundtrips() -> color_eyre::Result<()> {
        let d = tempfile::tempdir()?;
        let cache = open_cache(&d.path().join("t.db"), d.path().join("cache")).await?;
        let s = song("186016", "晴天", Some("叶惠美"));
        // 模拟 capture 落盘文件。
        let src = d.path().join("capture.part");
        tokio::fs::write(&src, b"FLACdata").await?;
        cache
            .put_played(&s, BitRate::Lossless, Some(&AudioFormat::Flac), &src)
            .await?;

        let Some(path) = cache.get(&s.id, BitRate::Lossless) else {
            return Err(color_eyre::eyre::eyre!("入库后应命中"));
        };
        assert!(
            path.ends_with("netease/lossless/叶惠美/晴天.flac"),
            "应落到可读库路径: {}",
            path.display()
        );
        assert_eq!(tokio::fs::read(&path).await?, b"FLACdata");
        assert!(!src.exists(), "源 capture 文件应被移走");
        // 不同音质不命中(键含音质)。
        assert!(cache.get(&s.id, BitRate::Exhigh).is_none());
        Ok(())
    }

    /// 写穿透必须在没有显式 flush 的进程边界后保持命中;同一 db 与缓存目录重开后,
    /// 再次 harvest 同曲应原地覆盖且不产生 ` (2)`。
    #[tokio::test]
    async fn survives_daemon_restart_no_redownload_dup() -> color_eyre::Result<()> {
        let d = tempfile::tempdir()?;
        let db = d.path().join("mineral.db");
        let dir = d.path().join("cache");
        let s = song("186016", "昨天的去向", Some("夜晚做决定"));

        let first_path = {
            let cache = open_cache(&db, dir.clone()).await?;
            let src = d.path().join("cap.part");
            tokio::fs::write(&src, b"AUDIO").await?;
            cache
                .put_played(&s, BitRate::Exhigh, Some(&AudioFormat::Mp3), &src)
                .await?;
            let Some(p) = cache.get(&s.id, BitRate::Exhigh) else {
                return Err(color_eyre::eyre::eyre!("入库后应命中"));
            };
            p
            // cache 连同连接池在此 drop,且不显式 flush。
        };

        let reopened = open_cache(&db, dir.clone()).await?;
        let Some(p2) = reopened.get(&s.id, BitRate::Exhigh) else {
            return Err(color_eyre::eyre::eyre!("重启后应仍命中,不该回退重下"));
        };
        assert_eq!(p2, first_path, "重启后应命中同一文件");

        // 同 key 重复 harvest 必须原地覆盖。
        let src2 = d.path().join("cap2.part");
        tokio::fs::write(&src2, b"AUDIO2").await?;
        reopened
            .put_played(&s, BitRate::Exhigh, Some(&AudioFormat::Mp3), &src2)
            .await?;
        let album_dir = dir.join("netease/exhigh/夜晚做决定");
        let mut names = Vec::<String>::new();
        for entry in std::fs::read_dir(&album_dir)? {
            if let Some(name) = entry?.file_name().to_str() {
                names.push(name.to_owned());
            }
        }
        assert_eq!(
            names,
            vec!["昨天的去向.mp3".to_owned()],
            "同 key 应原地覆盖,绝不产生 ` (2)` 副本"
        );
        Ok(())
    }

    /// capture_path 落在缓存根的 tmp/ 下,并以 playback instance 隔离同曲并发写入。
    #[tokio::test]
    async fn capture_path_under_tmp() -> color_eyre::Result<()> {
        let d = tempfile::tempdir()?;
        let dir = d.path().join("cache");
        let cache = open_cache(&d.path().join("t.db"), dir.clone()).await?;
        let s = song("186016", "晴天", None);
        let instance_id = PlaybackInstanceId::new();
        let Some(p) = cache.capture_path(&s.id, BitRate::Lossless, instance_id) else {
            return Err(color_eyre::eyre::eyre!("启用态应给 capture 路径"));
        };
        assert!(
            p.starts_with(dir.join("tmp")),
            "应在 tmp/ 下: {}",
            p.display()
        );
        assert_eq!(
            p.file_name().and_then(|name| name.to_str()),
            Some(format!("netease_186016_lossless-{instance_id}.part").as_str())
        );
        Ok(())
    }

    /// 禁用态:get 恒 miss、put_played 静默成功、capture_path 恒 None。
    #[tokio::test]
    async fn disabled_is_null_object() -> color_eyre::Result<()> {
        let cache = MediaCache::disabled();
        let s = song("1", "x", None);
        assert!(cache.get(&s.id, BitRate::Lossless).is_none());
        assert!(
            cache
                .capture_path(&s.id, BitRate::Lossless, PlaybackInstanceId::new())
                .is_none()
        );
        // put_played 不报错(src 不存在也无所谓,直接返回 Ok)。
        cache
            .put_played(
                &s,
                BitRate::Lossless,
                Some(&AudioFormat::Flac),
                std::path::Path::new("/nope"),
            )
            .await?;
        Ok(())
    }

    #[test]
    fn sanitize_replaces_illegal_chars() {
        assert_eq!(sanitize_segment("AC/DC: Back?", "_x"), "AC_DC_ Back_");
        assert_eq!(sanitize_segment("   ", "_unknown"), "_unknown");
        assert_eq!(sanitize_segment("a/b", "_x"), "a_b");
    }

    #[test]
    fn sanitize_truncates_on_char_boundary() {
        let long = "晴".repeat(100); // 每个 3 字节 → 300 字节,超 200
        let out = sanitize_segment(&long, "_x");
        assert!(out.len() <= 200, "应截到 <=200 字节, got {}", out.len());
        assert!(out.chars().all(|c| c == '晴'), "不应在字符中间截断出乱码");
    }

    #[test]
    fn truncate_keeps_char_boundary() {
        assert_eq!(truncate_bytes("abcdef", 3), "abc");
        assert_eq!(truncate_bytes("晴天", 4), "晴"); // 晴=3字节,4 退到 3
        assert_eq!(truncate_bytes("abc", 10), "abc");
    }

    #[test]
    fn ext_prefers_format_then_falls_back_by_quality() {
        assert_eq!(ext_for(Some(&AudioFormat::Flac), BitRate::Lossless), "flac");
        assert_eq!(ext_for(Some(&AudioFormat::Mp3), BitRate::Higher), "mp3");
        // 格式未知(None)→ 按音质兜底
        assert_eq!(ext_for(None, BitRate::Lossless), "flac");
        assert_eq!(ext_for(None, BitRate::Higher), "mp3");
    }
}