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
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
//! 队列结构编辑:删除 / 重排 / 批量清理 / 整表重排 / 撤销。

use mineral_model::SongId;
use mineral_protocol::{PlayCursor, QueueAnchor, QueueEditOutcome, QueueOp, QueuePos};

use crate::state::{QueueSnapshot, State};

/// 执行一次队列编辑。
///
/// [`QueueOp::ApplyTransform`] **不**在此处理:它要跨线程跑脚本,由调用方拿到新序后走
/// [`apply_order`]。
///
/// # Params:
///   - `st`: 播放状态(改 queue / cursor / original_queue / queue_undo)
///   - `op`: 待执行的操作
///
/// # Return:
///   本次编辑的结果。
pub(crate) fn apply(st: &mut State, op: &QueueOp) -> QueueEditOutcome {
    if matches!(op, QueueOp::Undo) {
        return undo(st);
    }
    // 每个操作都表达成「保留哪些原下标、以什么顺序」。游标跟随由此自动落地:重排后当前曲
    // 的新位置 = 它的原下标在这个序列里的位置,不必逐 op 手算。
    let Some(order) = plan(st, op) else {
        return QueueEditOutcome::Stale;
    };
    commit(st, &order)
}

/// 把一个操作翻译成保留下来的原下标序列。定位过期返回 `None`。
fn plan(st: &State, op: &QueueOp) -> Option<Vec<usize>> {
    let len = st.queue.len();
    let identity = || (0..len).collect::<Vec<_>>();
    match op {
        QueueOp::Remove(at) => {
            let target = resolve(st, at)?;
            Some((0..len).filter(|&i| i != target).collect())
        }
        QueueOp::Move { at, to } => {
            let target = resolve(st, at)?;
            let mut order = identity();
            let dest = destination(st, target, *to)?;
            order.remove(target);
            order.insert(dest.min(order.len()), target);
            Some(order)
        }
        QueueOp::ClearAbove(at) => {
            let target = resolve(st, at)?;
            Some((target..len).collect())
        }
        QueueOp::ClearBelow(at) => {
            let target = resolve(st, at)?;
            Some((0..=target).collect())
        }
        // 变换要跨线程跑脚本,新序由调用方经 apply_order 落地。
        QueueOp::ApplyTransform { .. } | QueueOp::Undo => Some(identity()),
    }
}

/// 校验定位并取出下标:下标处不是所报的那首歌即判定 client 视图已过期。
fn resolve(st: &State, at: &QueueAnchor) -> Option<usize> {
    st.queue
        .get(at.index)
        .filter(|s| s.id == at.song_id)
        .map(|_| at.index)
}

/// 重排的目标下标。端点不环绕:首项上移 / 末项下移返回原位(commit 据此判 NoOp)。
///
/// 返回值语义统一为「plan 里 `order.remove(target)` **之后**的插入位」:Up/Down/Top/Bottom
/// 本就落在这个空间,直接返回。AfterCurrent 例外——`after_current_pos` 给的是**原始下标
/// 空间**的绝对点(`cur+1`),当被移动项在它之前(`target < 绝对点`)时,remove 会把绝对点
/// 之后的下标全左移一位,插入位要跟着 −1,否则「紧随当前曲」会落到再后一格、中间空一格。
fn destination(st: &State, target: usize, to: QueuePos) -> Option<usize> {
    let last = st.queue.len().checked_sub(1)?;
    Some(match to {
        QueuePos::Up => target.saturating_sub(1),
        QueuePos::Down => (target + 1).min(last),
        QueuePos::Top => 0,
        QueuePos::Bottom => last,
        QueuePos::AfterCurrent => {
            let abs = super::nav::after_current_pos(st);
            if target < abs { abs - 1 } else { abs }
        }
    })
}

/// 落地一个原下标序列:重建队列、跟随游标、同步原序、记撤销快照、推版本。
fn commit(st: &mut State, order: &[usize]) -> QueueEditOutcome {
    if order.len() == st.queue.len() && order.iter().enumerate().all(|(at, &i)| at == i) {
        return QueueEditOutcome::NoOp;
    }
    let before = snapshot(st);
    let rebuilt = order
        .iter()
        .filter_map(|&i| st.queue.get(i).cloned())
        .collect::<Vec<_>>();
    st.cursor = follow_cursor(st.cursor, order, rebuilt.len());
    st.queue = rebuilt;
    sync_original(st);
    st.queue_undo = Some(before);
    st.bump_queue();
    QueueEditOutcome::Applied
}

/// 重排后游标的落点。
///
/// 当前曲若仍在保留序列里就跟着它走;被删掉则转悬空,接续点取「原下标之后第一个仍留存的
/// 条目」的新位置——没有这样的条目就落新队列长度,即播完即停。
fn follow_cursor(cursor: PlayCursor, order: &[usize], new_len: usize) -> PlayCursor {
    let survivor_after = |from: usize| {
        order
            .iter()
            .enumerate()
            .filter(|&(_, &original)| original >= from)
            .min_by_key(|&(_, &original)| original)
            .map_or(new_len, |(at, _)| at)
    };
    match cursor {
        PlayCursor::InQueue(cur) => order.iter().position(|&i| i == cur).map_or(
            PlayCursor::Detached {
                resume_at: survivor_after(cur + 1),
            },
            PlayCursor::InQueue,
        ),
        PlayCursor::Detached { resume_at } => PlayCursor::Detached {
            resume_at: survivor_after(resume_at),
        },
    }
}

/// 同步 shuffle 原序:摘掉新队列里已彻底不存在的歌。
///
/// 只同步删除,**不**同步重排与复制——原序快照的职责只是「退出 shuffle 时还原原始顺序」,
/// 它需要保证不复活已删的歌;把洗牌视图内的手动重排回写进去,反倒让这份快照失去意义。
fn sync_original(st: &mut State) {
    let Some(orig) = st.original_queue.as_mut() else {
        return;
    };
    let live = st
        .queue
        .iter()
        .map(|s| s.id.clone())
        .collect::<rustc_hash::FxHashSet<_>>();
    orig.retain(|s| live.contains(&s.id));
}

/// 拍一张编辑前的队列全貌。
fn snapshot(st: &State) -> QueueSnapshot {
    QueueSnapshot {
        queue: st.queue.clone(),
        original_queue: st.original_queue.clone(),
        cursor: st.cursor,
    }
}

/// 撤销上一次编辑;无快照可用时 no-op。
fn undo(st: &mut State) -> QueueEditOutcome {
    let Some(before) = st.queue_undo.take() else {
        return QueueEditOutcome::NoOp;
    };
    st.queue = before.queue;
    st.original_queue = before.original_queue;
    st.cursor = before.cursor;
    st.bump_queue();
    QueueEditOutcome::Applied
}

/// 按给定的 id 序列整表重排队列(脚本变换 / `mineral.queue.set` 的落地口)。
///
/// # Params:
///   - `st`: 播放状态
///   - `ids`: 新的队列顺序;每个 id 必须在原队列里出现过,次数不限
///
/// # Return:
///   本次编辑的结果;含未知 id 时整体拒绝,返回 [`QueueEditOutcome::Stale`]。
pub(crate) fn apply_order(st: &mut State, ids: &[SongId]) -> QueueEditOutcome {
    // 只认 id、从原队列取回权威实体:脚本手里的 song 表是有损投影(艺人 / 专辑只有名字),
    // 让它直接构造 Song 会造出半残实体。同一 id 出现多次即复制该条目,零额外成本。
    let mut order = Vec::with_capacity(ids.len());
    for id in ids {
        let Some(at) = st.queue.iter().position(|s| s.id == *id) else {
            mineral_log::warn!(
                target: "player",
                id = id.qualified(),
                "queue transform returned an unknown song, rejecting the whole order"
            );
            return QueueEditOutcome::Stale;
        };
        order.push(at);
    }
    commit(st, &order)
}

#[cfg(test)]
mod tests {
    use mineral_protocol::{
        PlayCursor, PlayMode, QueueAnchor, QueueEditOutcome, QueueOp, QueuePos,
    };
    use mineral_test::song;
    use pretty_assertions::assert_eq;

    use super::{apply, apply_order};
    use crate::state::State;

    /// 造队列 a/b/c/d,当前曲附着在 `sel`。
    fn state(sel: usize) -> State {
        let mut st = State::empty();
        st.queue = vec![song("a"), song("b"), song("c"), song("d")];
        st.cursor = PlayCursor::InQueue(sel);
        st.current_song = st.queue.get(sel).cloned();
        st
    }

    /// 取队列各歌 id(原序)。
    fn ids(st: &State) -> Vec<&str> {
        st.queue.iter().map(|s| s.id.as_str()).collect()
    }

    /// 造一个指向 `index` 处那首歌的定位。
    fn anchor(st: &State, index: usize) -> QueueAnchor {
        let id = st
            .queue
            .get(index)
            .map(|s| s.id.clone())
            .unwrap_or_else(|| song("missing").id);
        QueueAnchor::new(index, id)
    }

    /// 删除非在播条目:该行消失,游标仍附着在原来那首歌上(下标随之前移)。
    #[test]
    fn remove_non_current_keeps_cursor_on_same_song() {
        let mut st = state(2); // 当前 c
        let at = anchor(&st, 0); // 删 a
        assert_eq!(
            apply(&mut st, &QueueOp::Remove(at)),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["b", "c", "d"]);
        assert_eq!(st.cursor, PlayCursor::InQueue(1), "c 前移到下标 1");
    }

    /// 删在播曲:游标转为悬空,接续点指向原来的下一首。声音由音频引擎自然播完。
    #[test]
    fn remove_current_detaches_cursor_to_successor() {
        let mut st = state(1); // 当前 b
        let at = anchor(&st, 1);
        assert_eq!(
            apply(&mut st, &QueueOp::Remove(at)),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["a", "c", "d"]);
        assert_eq!(
            st.cursor,
            PlayCursor::Detached { resume_at: 1 },
            "接续点应指向原下一首 c(新下标 1)"
        );
    }

    /// 删在播的末位曲:无后继,接续点落队列长度 = 播完即停。
    #[test]
    fn remove_current_at_tail_stops_after_finish() {
        let mut st = state(3); // 当前 d(末位)
        let at = anchor(&st, 3);
        assert_eq!(
            apply(&mut st, &QueueOp::Remove(at)),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["a", "b", "c"]);
        assert_eq!(st.cursor, PlayCursor::Detached { resume_at: 3 });
    }

    /// 定位过期(下标处不是所报的那首歌)→ 拒绝执行,队列一字不动。
    #[test]
    fn stale_anchor_is_rejected() {
        let mut st = state(0);
        let bogus = QueueAnchor::new(1, song("zzz").id);
        assert_eq!(
            apply(&mut st, &QueueOp::Remove(bogus)),
            QueueEditOutcome::Stale
        );
        assert_eq!(ids(&st), vec!["a", "b", "c", "d"], "拒绝后队列不变");
    }

    /// 上移 / 下移一格,游标跟随歌曲而非停在原下标。
    #[test]
    fn move_up_and_down_follows_the_song() {
        let mut st = state(2); // 当前 c
        let at = anchor(&st, 2);
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at,
                    to: QueuePos::Up
                }
            ),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["a", "c", "b", "d"]);
        assert_eq!(st.cursor, PlayCursor::InQueue(1), "游标跟着 c 上移");

        let at = anchor(&st, 1);
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at,
                    to: QueuePos::Down
                }
            ),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
        assert_eq!(st.cursor, PlayCursor::InQueue(2));
    }

    /// 端点不环绕:首项上移 / 末项下移都是 NoOp。
    #[test]
    fn move_at_edges_does_not_wrap() {
        let mut st = state(0);
        let first = anchor(&st, 0);
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at: first,
                    to: QueuePos::Up
                }
            ),
            QueueEditOutcome::NoOp
        );
        let last = anchor(&st, 3);
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at: last,
                    to: QueuePos::Down
                }
            ),
            QueueEditOutcome::NoOp
        );
        assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
    }

    /// 移到队首 / 队尾。
    #[test]
    fn move_to_top_and_bottom() {
        let mut st = state(0);
        let at = anchor(&st, 2); // c
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at,
                    to: QueuePos::Top
                }
            ),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["c", "a", "b", "d"]);
        assert_eq!(st.cursor, PlayCursor::InQueue(1), "当前曲 a 被 c 挤后一位");

        let at = anchor(&st, 0); // c
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at,
                    to: QueuePos::Bottom
                }
            ),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["a", "b", "d", "c"]);
    }

    /// 移到当前曲之后 = 队列内重排,**不复制**(队列长度不变)。
    #[test]
    fn move_after_current_relocates_without_copying() {
        let mut st = state(0); // 当前 a
        let at = anchor(&st, 3); // d
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at,
                    to: QueuePos::AfterCurrent
                }
            ),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["a", "d", "b", "c"], "长度不变,只是换位");
        assert_eq!(st.cursor, PlayCursor::InQueue(0));
    }

    /// 回归:被移动项在**当前曲之前**时,「紧随当前曲」要紧挨着落,中间不空一格。
    /// 若 `after_current_pos` 不补偿 remove 导致的下标前移,a 会落到 d 后面。
    #[test]
    fn move_after_current_from_before_lands_snug() {
        let mut st = state(2); // 当前 c(下标 2)
        let at = anchor(&st, 0); // 移 a(在 c 之前)
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at,
                    to: QueuePos::AfterCurrent
                }
            ),
            QueueEditOutcome::Applied
        );
        // a 紧随 c,不是落到队尾(旧 bug 会得 [b,c,d,a],中间空一格)。
        assert_eq!(ids(&st), vec!["b", "c", "a", "d"], "a 紧挨当前曲 c 之后");
        assert_eq!(
            st.cursor,
            PlayCursor::InQueue(1),
            "当前曲 c 前移一位(a 被摘走)"
        );
    }

    /// 清空锚点之上:锚点自身保留。
    #[test]
    fn clear_above_keeps_the_anchor() {
        let mut st = state(0); // 当前 a,会被清掉
        let at = anchor(&st, 2); // c
        assert_eq!(
            apply(&mut st, &QueueOp::ClearAbove(at)),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["c", "d"]);
        assert_eq!(
            st.cursor,
            PlayCursor::Detached { resume_at: 0 },
            "在播的 a 被清掉 → 悬空,播完接 c"
        );
    }

    /// 清空锚点之下:锚点自身保留。
    #[test]
    fn clear_below_keeps_the_anchor() {
        let mut st = state(0); // 当前 a,保留
        let at = anchor(&st, 1); // b
        assert_eq!(
            apply(&mut st, &QueueOp::ClearBelow(at)),
            QueueEditOutcome::Applied
        );
        assert_eq!(ids(&st), vec!["a", "b"]);
        assert_eq!(st.cursor, PlayCursor::InQueue(0));
    }

    /// 清空首项之上 = 无可清 → NoOp。
    #[test]
    fn clear_above_first_is_noop() {
        let mut st = state(0);
        let at = anchor(&st, 0);
        assert_eq!(
            apply(&mut st, &QueueOp::ClearAbove(at)),
            QueueEditOutcome::NoOp
        );
        assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
    }

    /// 撤销把队列与游标一并还原;单级,撤销后再撤销无快照可用。
    #[test]
    fn undo_restores_queue_and_cursor() {
        let mut st = state(1); // 当前 b
        let at = anchor(&st, 1);
        assert_eq!(
            apply(&mut st, &QueueOp::Remove(at)),
            QueueEditOutcome::Applied
        );
        assert_eq!(st.cursor, PlayCursor::Detached { resume_at: 1 });

        assert_eq!(apply(&mut st, &QueueOp::Undo), QueueEditOutcome::Applied);
        assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
        assert_eq!(st.cursor, PlayCursor::InQueue(1), "游标从悬空变回附着");

        assert_eq!(
            apply(&mut st, &QueueOp::Undo),
            QueueEditOutcome::NoOp,
            "单级撤销:没有第二份快照"
        );
    }

    /// 被拒绝的编辑不留快照,不会让下一次撤销退到更早的状态。
    #[test]
    fn rejected_edit_leaves_no_undo_snapshot() {
        let mut st = state(0);
        let bogus = QueueAnchor::new(1, song("zzz").id);
        assert_eq!(
            apply(&mut st, &QueueOp::Remove(bogus)),
            QueueEditOutcome::Stale
        );
        assert_eq!(apply(&mut st, &QueueOp::Undo), QueueEditOutcome::NoOp);
    }

    /// shuffle 下删除条目必须同步 original_queue,否则退出 shuffle 时被删的歌会复活。
    #[test]
    fn removal_syncs_original_queue() {
        let mut st = state(0);
        st.play_mode = PlayMode::Shuffle;
        st.queue = vec![song("c"), song("a"), song("d"), song("b")];
        st.cursor = PlayCursor::InQueue(0);
        st.current_song = Some(song("c"));
        st.original_queue = Some(vec![song("a"), song("b"), song("c"), song("d")]);

        let at = anchor(&st, 3); // 洗牌视图里的 b
        assert_eq!(
            apply(&mut st, &QueueOp::Remove(at)),
            QueueEditOutcome::Applied
        );
        assert_eq!(
            st.original_queue
                .as_ref()
                .map(|q| q.iter().map(|s| s.id.as_str()).collect::<Vec<_>>()),
            Some(vec!["a", "c", "d"]),
            "b 应一并从原序里摘掉"
        );
    }

    /// shuffle 下的重排**不**回写 original_queue——原序快照的职责只是还原原始顺序。
    #[test]
    fn reorder_does_not_pollute_original_queue() {
        let mut st = state(0);
        st.play_mode = PlayMode::Shuffle;
        st.queue = vec![song("c"), song("a"), song("d"), song("b")];
        st.cursor = PlayCursor::InQueue(0);
        st.current_song = Some(song("c"));
        st.original_queue = Some(vec![song("a"), song("b"), song("c"), song("d")]);

        let at = anchor(&st, 3);
        assert_eq!(
            apply(
                &mut st,
                &QueueOp::Move {
                    at,
                    to: QueuePos::Top
                }
            ),
            QueueEditOutcome::Applied
        );
        assert_eq!(
            st.original_queue
                .as_ref()
                .map(|q| q.iter().map(|s| s.id.as_str()).collect::<Vec<_>>()),
            Some(vec!["a", "b", "c", "d"]),
            "原序不受洗牌视图内重排影响"
        );
    }

    /// 整表重排:接受原队列的任意重排与删减。
    #[test]
    fn apply_order_accepts_permutation_and_subset() {
        let mut st = state(1); // 当前 b
        let order = vec![song("d").id, song("b").id];
        assert_eq!(apply_order(&mut st, &order), QueueEditOutcome::Applied);
        assert_eq!(ids(&st), vec!["d", "b"]);
        assert_eq!(st.cursor, PlayCursor::InQueue(1), "当前曲跟到新位置");
    }

    /// 整表重排:允许同一首歌出现多于原有次数(复制已在队列的歌零成本)。
    #[test]
    fn apply_order_allows_duplicates_beyond_original_count() {
        let mut st = state(0);
        let order = vec![song("a").id, song("a").id, song("a").id];
        assert_eq!(apply_order(&mut st, &order), QueueEditOutcome::Applied);
        assert_eq!(ids(&st), vec!["a", "a", "a"]);
    }

    /// 整表重排:混入原队列里没有的 id → 整体拒绝,队列不变。
    #[test]
    fn apply_order_rejects_unknown_id() {
        let mut st = state(0);
        let order = vec![song("a").id, song("zzz").id];
        assert_eq!(apply_order(&mut st, &order), QueueEditOutcome::Stale);
        assert_eq!(ids(&st), vec!["a", "b", "c", "d"]);
    }

    /// 整表重排返回原序 → NoOp(不白白 bump 版本让 client 重拉整队列)。
    #[test]
    fn apply_order_identity_is_noop() {
        let mut st = state(0);
        let order: Vec<_> = st.queue.iter().map(|s| s.id.clone()).collect();
        assert_eq!(apply_order(&mut st, &order), QueueEditOutcome::NoOp);
    }
}