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.
//! 多音源搜索结果的合并:交错排列各音源结果,跨音源的同一首歌只保留音质更好的一份。

use crate::download::Item;
use crate::model::Song;

/// 交错合并各音源的结果(第 1 名、第 1 名、第 2 名……),保持各自内部顺序。
pub fn interleave<T>(lists: Vec<Vec<T>>) -> Vec<T> {
    let mut iters: Vec<_> = lists.into_iter().map(Vec::into_iter).collect();
    let mut out = Vec::new();
    loop {
        let before = out.len();
        for it in &mut iters {
            if let Some(x) = it.next() {
                out.push(x);
            }
        }
        if out.len() == before {
            return out;
        }
    }
}

/// 归一化:小写、去空白、全角括号转半角。
fn norm(s: &str) -> String {
    s.chars()
        .filter(|c| !c.is_whitespace())
        .map(|c| match c {
            '(' => '(',
            ')' => ')',
            c => c,
        })
        .flat_map(char::to_lowercase)
        .collect()
}

/// 两首歌是否是"不同音源上的同一首":歌名、第一歌手一致,时长相差不超过 5 秒(任一方未知时不比较)。
pub fn same_song(a: &Song, b: &Song) -> bool {
    if a.source == b.source || norm(&a.title) != norm(&b.title) {
        return false;
    }
    let first = |s: &Song| s.singers.first().map(|x| norm(x)).unwrap_or_default();
    if first(a) != first(b) {
        return false;
    }
    a.interval == 0 || b.interval == 0 || a.interval.abs_diff(b.interval) <= 5
}

/// `a` 是否比 `b` 更好:档位高优先,同档取文件大的;完全相同时保留先出现的。
fn better(a: &Item, b: &Item) -> bool {
    (a.resolved.level, a.resolved.probe.size) > (b.resolved.level, b.resolved.probe.size)
}

/// 跨音源去重,返回 (结果, 被合并掉的条数)。保留的一份占据先出现的位置。
pub fn dedupe_across_sources(items: Vec<Item>) -> (Vec<Item>, usize) {
    let mut out: Vec<Item> = Vec::with_capacity(items.len());
    let mut merged = 0;
    for it in items {
        match out.iter_mut().find(|o| same_song(&o.song, &it.song)) {
            Some(o) => {
                merged += 1;
                if better(&it, o) {
                    *o = it;
                }
            }
            None => out.push(it),
        }
    }
    (out, merged)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Level, Resolved, SourceKind};
    use crate::probe::ProbeResult;

    fn song(source: SourceKind, title: &str, singer: &str, interval: u64) -> Song {
        Song {
            source,
            id: format!("{source}-{title}"),
            title: title.into(),
            singers: vec![singer.into()],
            interval,
            ..Song::default()
        }
    }

    fn item(s: Song, level: Level, size: u64) -> Item {
        Item::new(
            s,
            Resolved {
                via: "x",
                quality: String::new(),
                level,
                probe: ProbeResult {
                    url: String::new(),
                    ext: "flac".into(),
                    size,
                },
                referer: None,
                fallback: None,
            },
        )
    }

    #[test]
    fn interleaves() {
        assert_eq!(
            interleave(vec![vec![1, 3, 5, 6], vec![2, 4]]),
            [1, 2, 3, 4, 5, 6]
        );
        assert_eq!(interleave::<i32>(vec![vec![], vec![]]), Vec::<i32>::new());
        assert_eq!(interleave(vec![vec![1, 2]]), [1, 2]);
    }

    #[test]
    fn same_song_rules() {
        use SourceKind::*;
        let q = song(Qq, "刻在我心底的名字 (Live)", "蔡依林", 314);
        assert!(same_song(
            &q,
            &song(Netease, "刻在我心底的名字(Live)", "蔡依林", 316)
        ));
        assert!(same_song(
            &q,
            &song(Netease, "刻在我心底的名字 (live)", "蔡依林", 0)
        ));
        // 同一音源不合并(可能是不同版本)
        assert!(!same_song(
            &q,
            &song(Qq, "刻在我心底的名字 (Live)", "蔡依林", 314)
        ));
        // 时长差太多、歌手不同、歌名不同
        assert!(!same_song(
            &q,
            &song(Netease, "刻在我心底的名字 (Live)", "蔡依林", 330)
        ));
        assert!(!same_song(
            &q,
            &song(Netease, "刻在我心底的名字 (Live)", "田馥甄", 314)
        ));
        assert!(!same_song(
            &q,
            &song(Netease, "刻在我心底的名字", "蔡依林", 314)
        ));
    }

    #[test]
    fn keeps_better_in_first_position() {
        use SourceKind::*;
        let items = vec![
            item(song(Qq, "无人知晓", "田馥甄", 288), Level::Sq, 30),
            item(song(Qq, "悬日", "田馥甄", 280), Level::Master, 100),
            item(song(Netease, "无人知晓", "田馥甄", 288), Level::Master, 160),
            item(song(Netease, "悬日", "田馥甄", 280), Level::Master, 90),
            item(song(Netease, "要去什么地方", "田馥甄", 250), Level::Hq, 9),
        ];
        let (out, merged) = dedupe_across_sources(items);
        assert_eq!(merged, 2);
        let got: Vec<(SourceKind, &str)> = out
            .iter()
            .map(|i| (i.song.source, i.song.title.as_str()))
            .collect();
        assert_eq!(
            got,
            [
                (Netease, "无人知晓"),
                (Qq, "悬日"),
                (Netease, "要去什么地方")
            ]
        );
    }
}