第 28 章 本地音乐库扫描与元数据(lofty / walkdir)
本章目标
- 扫描用户选定目录,读取音频文件元数据。
- 用
lofty抽取标题、艺人、专辑、时长、封面。 - 通过
Channel<ScanEvent>把进度流式发给前端。 - 写入数据库 + 封面存到
$APPCACHE/covers/。
一、模块结构
src-tauri/src/core/library/
├── mod.rs
├── scanner.rs # 目录遍历 + 元数据提取
├── importer.rs # 入库逻辑
└── manager.rs # 外部门面
二、Scanner
// scanner.rs
use std::path::{Path, PathBuf};
use lofty::{Accessor, AudioFile, ItemKey, TaggedFileExt};
use walkdir::WalkDir;
pub const AUDIO_EXT: [&str; 8] = ["mp3","flac","m4a","wav","ogg","aac","aiff","ape"];
#[derive(Debug, Clone)]
pub struct RawTrack {
pub path: PathBuf,
pub title: String,
pub artist: Option<String>,
pub album: Option<String>,
pub track_no: Option<u32>,
pub disc_no: Option<u32>,
pub year: Option<u32>,
pub duration_ms: u64,
pub bitrate: u32,
pub sample_rate: u32,
pub format: String,
pub file_size: u64,
pub cover_bytes: Option<Vec<u8>>,
pub cover_mime: Option<String>,
}
pub fn list_audio(root: &Path) -> Vec<PathBuf> {
WalkDir::new(root).into_iter().filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| e.path().extension().and_then(|s| s.to_str())
.map(|s| AUDIO_EXT.contains(&s.to_lowercase().as_str())).unwrap_or(false))
.map(|e| e.into_path()).collect()
}
pub fn read_tags(path: &Path) -> anyhow::Result<RawTrack> {
let tagged = lofty::read_from_path(path)?;
let props = tagged.properties();
let tag = tagged.primary_tag().or_else(|| tagged.first_tag());
let title = tag.and_then(|t| t.title().map(|c| c.to_string()))
.unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("Unknown").to_string());
let artist = tag.and_then(|t| t.artist().map(|c| c.to_string()));
let album = tag.and_then(|t| t.album().map(|c| c.to_string()));
let track_no = tag.and_then(|t| t.track());
let disc_no = tag.and_then(|t| t.disk());
let year = tag.and_then(|t| t.year());
let (cover_bytes, cover_mime) = tag.and_then(|t| t.pictures().first())
.map(|p| (Some(p.data().to_vec()), Some(p.mime_type().map(|m| m.to_string()).unwrap_or_default())))
.unwrap_or((None, None));
let size = std::fs::metadata(path)?.len();
Ok(RawTrack {
path: path.to_path_buf(),
title, artist, album, track_no, disc_no, year,
duration_ms: props.duration().as_millis() as u64,
bitrate: props.audio_bitrate().unwrap_or(0),
sample_rate: props.sample_rate().unwrap_or(0),
format: path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string(),
file_size: size,
cover_bytes, cover_mime,
})
}
三、Importer:入库
// importer.rs
use sqlx::{SqlitePool, Transaction, Sqlite};
use std::path::PathBuf;
use super::scanner::RawTrack;
pub async fn upsert_artist(tx: &mut Transaction<'_, Sqlite>, name: &str) -> sqlx::Result<i64> {
sqlx::query_scalar::<_, i64>(
"INSERT INTO artists (name) VALUES (?) ON CONFLICT(name) DO UPDATE SET name=excluded.name RETURNING id"
).bind(name).fetch_one(&mut **tx).await
}
pub async fn upsert_album(tx: &mut Transaction<'_, Sqlite>, title: &str, artist_id: i64, year: Option<u32>) -> sqlx::Result<i64> {
sqlx::query_scalar::<_, i64>(
"INSERT INTO albums (title, artist_id, year) VALUES (?, ?, ?)
ON CONFLICT(title, artist_id) DO UPDATE SET year=COALESCE(albums.year, excluded.year) RETURNING id"
).bind(title).bind(artist_id).bind(year.map(|y| y as i64)).fetch_one(&mut **tx).await
}
pub async fn insert_song(tx: &mut Transaction<'_, Sqlite>, r: &RawTrack, artist_id: Option<i64>, album_id: Option<i64>, hash: String, cover_path: Option<PathBuf>) -> sqlx::Result<i64> {
sqlx::query_scalar::<_, i64>(
"INSERT INTO songs (title, artist_id, album_id, path, duration_ms, track_no, disc_no, file_size, file_hash, format, bitrate, sample_rate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
title=excluded.title, artist_id=excluded.artist_id, album_id=excluded.album_id,
duration_ms=excluded.duration_ms, bitrate=excluded.bitrate, sample_rate=excluded.sample_rate
RETURNING id"
)
.bind(&r.title).bind(artist_id).bind(album_id)
.bind(r.path.to_string_lossy())
.bind(r.duration_ms as i64)
.bind(r.track_no.map(|t| t as i64))
.bind(r.disc_no.map(|t| t as i64))
.bind(r.file_size as i64)
.bind(hash).bind(&r.format)
.bind(r.bitrate as i64).bind(r.sample_rate as i64)
.fetch_one(&mut **tx).await
}
pub async fn save_cover(cache_dir: &std::path::Path, hash: &str, bytes: &[u8], mime: Option<&str>) -> anyhow::Result<PathBuf> {
let ext = match mime {
Some("image/png") => "png", Some("image/webp") => "webp", _ => "jpg",
};
let path = cache_dir.join(format!("{}.{}", hash, ext));
if !path.exists() { tokio::fs::write(&path, bytes).await?; }
Ok(path)
}
四、Manager & 命令
// manager.rs
use super::{scanner, importer};
use sqlx::SqlitePool;
use std::path::PathBuf;
use tauri::ipc::Channel;
use serde::Serialize;
#[derive(Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
pub enum ScanEvent {
Started { total: usize },
Progress { done: usize, current: String },
Done { added: usize, updated: usize, skipped: usize, ms: u64 },
Failed { error: String },
}
pub async fn scan_folder(pool: &SqlitePool, cache_dir: &std::path::Path, root: PathBuf, ch: Channel<ScanEvent>) -> anyhow::Result<()> {
let start = std::time::Instant::now();
let paths = scanner::list_audio(&root);
ch.send(ScanEvent::Started { total: paths.len() })?;
let (mut added, mut updated, mut skipped) = (0, 0, 0);
for (i, p) in paths.iter().enumerate() {
ch.send(ScanEvent::Progress { done: i, current: p.to_string_lossy().to_string() })?;
match scanner::read_tags(p) {
Ok(raw) => {
let mut tx = pool.begin().await?;
let artist_id = if let Some(a) = &raw.artist {
Some(importer::upsert_artist(&mut tx, a).await?)
} else { None };
let album_id = if let (Some(al), Some(aid)) = (&raw.album, artist_id) {
Some(importer::upsert_album(&mut tx, al, aid, raw.year).await?)
} else { None };
let hash = blake3::hash(&std::fs::read(p)?).to_hex().to_string();
let cover_path = if let Some(b) = &raw.cover_bytes {
Some(importer::save_cover(cache_dir, &hash, b, raw.cover_mime.as_deref()).await?)
} else { None };
let _id = importer::insert_song(&mut tx, &raw, artist_id, album_id, hash, cover_path).await?;
tx.commit().await?;
added += 1;
}
Err(e) => { tracing::warn!("skip {}: {}", p.display(), e); skipped += 1; }
}
}
ch.send(ScanEvent::Done { added, updated, skipped, ms: start.elapsed().as_millis() as u64 })?;
Ok(())
}
五、Command
// cmds/library.rs
use crate::{state::AppState, core::library::manager};
#[tauri::command] #[specta::specta]
pub async fn library_scan(app: tauri::AppHandle, state: tauri::State<'_, AppState>, root: String, channel: tauri::ipc::Channel<manager::ScanEvent>) -> Result<(), String> {
let pool = state.db.clone();
let cache = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("covers");
std::fs::create_dir_all(&cache).ok();
tokio::spawn(async move {
if let Err(e) = manager::scan_folder(&pool, &cache, root.into(), channel.clone()).await {
let _ = channel.send(manager::ScanEvent::Failed { error: e.to_string() });
}
});
Ok(())
}
六、前端调用
// src/app/library/LibraryPage.tsx
import { Channel } from "@tauri-apps/api/core";
import { commands } from "@/lib/ipc";
import { open } from "@tauri-apps/plugin-dialog";
function ImportButton() {
const [progress, setProgress] = useState({ done: 0, total: 0 });
async function importFolder() {
const picked = await open({ directory: true });
if (!picked) return;
const ch = new Channel();
ch.onmessage = (msg: any) => {
if (msg.event === "started") setProgress({ done: 0, total: msg.data.total });
if (msg.event === "progress") setProgress(p => ({ ...p, done: msg.data.done }));
if (msg.event === "done") toast.success(`扫描完成:新增 ${msg.data.added}`);
};
await commands.libraryScan(picked as string, ch);
}
return (
<div>
<button onClick={importFolder} className="px-4 py-2 bg-brand-500 rounded">导入音乐文件夹</button>
{progress.total > 0 && <div>{progress.done}/{progress.total}</div>}
</div>
);
}
七、实时监听变化
use notify_debouncer_full::{new_debouncer, notify::*};
use std::time::Duration;
pub fn watch_library(root: PathBuf, pool: SqlitePool, app: tauri::AppHandle) {
let mut debouncer = new_debouncer(Duration::from_secs(3), None, move |res| {
// 收到 change 后重新扫描对应子目录
}).unwrap();
debouncer.watcher().watch(&root, RecursiveMode::Recursive).ok();
// 把 debouncer 放 state 里防止 drop
}
本章小结
扫描 + 元数据 + 入库 + 前端进度串完。CloudTone 现在已经能识别你整个音乐目录。
动手时刻
- 导入一个 500+ 首的目录,观察进度。
-
让
read_tags失败的歌也进入库(占位 title = 文件名)。
下一章:数据库 Schema 与查询层细化。