第 29 章 数据库 Schema:歌曲 / 专辑 / 艺人 / 歌单 / 播放历史

本章目标

  • 完善 schema:补上 favorites、settings、metadata 索引。
  • 给出常用查询:按专辑聚合、最近添加、热度榜、搜索联接。
  • 封装 Rust 查询层 core::db::queries。

一、补完 schema

在第 19 章的基础上追加:

-- 20260201_addons.sql
CREATE TABLE IF NOT EXISTS favorites (
    song_id INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,
    added_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

CREATE TABLE IF NOT EXISTS library_roots (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    path TEXT NOT NULL UNIQUE,
    enabled INTEGER NOT NULL DEFAULT 1
);

CREATE INDEX IF NOT EXISTS idx_history_song ON play_history(song_id);
CREATE INDEX IF NOT EXISTS idx_history_played_at ON play_history(played_at DESC);

二、DTO

// src-tauri/src/core/db/models.rs
use serde::Serialize;
use specta::Type;

#[derive(Serialize, Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Song {
    pub id: i64,
    pub title: String,
    pub artist: Option<String>,
    pub album: Option<String>,
    pub artist_id: Option<i64>,
    pub album_id: Option<i64>,
    pub path: String,
    pub duration_ms: i64,
    pub track_no: Option<i64>,
    pub liked: bool,
    pub cover_path: Option<String>,
}

#[derive(Serialize, Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Album {
    pub id: i64,
    pub title: String,
    pub artist: String,
    pub year: Option<i64>,
    pub cover_path: Option<String>,
    pub song_count: i64,
}

三、查询

// src-tauri/src/core/db/queries.rs
use sqlx::SqlitePool;
use super::models::*;

pub async fn list_songs(pool: &SqlitePool, limit: i64, offset: i64) -> sqlx::Result<Vec<Song>> {
    sqlx::query_as::<_, Song>(
        "SELECT s.id, s.title, a.name AS artist, al.title AS album,
                s.artist_id, s.album_id, s.path, s.duration_ms, s.track_no,
                EXISTS(SELECT 1 FROM favorites f WHERE f.song_id = s.id) AS liked,
                al.cover_path
         FROM songs s
         LEFT JOIN artists a ON s.artist_id = a.id
         LEFT JOIN albums al ON s.album_id = al.id
         ORDER BY s.added_at DESC LIMIT ? OFFSET ?"
    ).bind(limit).bind(offset).fetch_all(pool).await
}

pub async fn list_albums(pool: &SqlitePool) -> sqlx::Result<Vec<Album>> {
    sqlx::query_as::<_, Album>(
        "SELECT al.id, al.title, COALESCE(a.name, '未知艺人') AS artist,
                al.year, al.cover_path,
                (SELECT COUNT(*) FROM songs s WHERE s.album_id = al.id) AS song_count
         FROM albums al LEFT JOIN artists a ON al.artist_id = a.id
         ORDER BY al.title"
    ).fetch_all(pool).await
}

pub async fn toggle_favorite(pool: &SqlitePool, song_id: i64) -> sqlx::Result<bool> {
    let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM favorites WHERE song_id = ?").bind(song_id).fetch_optional(pool).await?;
    if exists.is_some() {
        sqlx::query("DELETE FROM favorites WHERE song_id = ?").bind(song_id).execute(pool).await?;
        Ok(false)
    } else {
        sqlx::query("INSERT INTO favorites (song_id) VALUES (?)").bind(song_id).execute(pool).await?;
        Ok(true)
    }
}

pub async fn record_play(pool: &SqlitePool, song_id: i64, ms: u64) -> sqlx::Result<()> {
    sqlx::query("INSERT INTO play_history (song_id, duration_played_ms) VALUES (?, ?)")
        .bind(song_id).bind(ms as i64).execute(pool).await?;
    Ok(())
}

pub async fn top_played(pool: &SqlitePool, limit: i64) -> sqlx::Result<Vec<(i64, i64)>> {
    sqlx::query_as::<_, (i64, i64)>(
        "SELECT song_id, COUNT(*) AS c FROM play_history GROUP BY song_id ORDER BY c DESC LIMIT ?"
    ).bind(limit).fetch_all(pool).await
}

四、暴露命令

// cmds/library.rs
#[tauri::command] #[specta::specta]
pub async fn library_list_songs(state: tauri::State<'_, AppState>, limit: i64, offset: i64) -> Result<Vec<Song>, String> {
    queries::list_songs(&state.db, limit, offset).await.map_err(|e| e.to_string())
}

#[tauri::command] #[specta::specta]
pub async fn library_toggle_favorite(state: tauri::State<'_, AppState>, song_id: i64) -> Result<bool, String> {
    queries::toggle_favorite(&state.db, song_id).await.map_err(|e| e.to_string())
}

五、前端调用

// src/features/library/queries.ts
import { useQuery } from "@tanstack/react-query";
import { commands } from "@/lib/ipc";

export function useLibrarySongs(limit = 200, offset = 0) {
  return useQuery({
    queryKey: ["library","songs", limit, offset],
    queryFn: () => commands.libraryListSongs(limit, offset),
  });
}

本章小结

  • 查询层集中管理,避免 SQL 散落。
  • 列表页高效联表一次拿全。
  • 收藏、播放统计等都有专用索引。

动手时刻

  • 在 Library 页展示扫描结果。
  • 心形按钮切换收藏,立即反映在 UI。

下一章:播放队列与各种循环模式。