music: 新建 music app,替换 piano-sheet
听歌 + 练琴曲目管理: - 数据:piece (title/artist/category/lyrics/play_count/notes) + attachments (audio/video/pdf/image; image 带 role=chord/numbered/staff) - 后端 axum + sqlite,附件流式落 PVC,ServeFile 支持 Range(视频拖动) - 前端 guitar 风格 player:左 sidebar + tabs(歌词/吉他谱/简谱/五线谱/PDF/视频),LRC 同步、快捷键、笔记自动保存 - ns cube-music + music.famzheng.me + bodylimit 5GiB - scripts/import_guitar.py 用于把 oci /data/guitar/ 旧曲库导入
This commit is contained in:
@@ -0,0 +1,684 @@
|
||||
//! music.famzheng.me — 听歌 + 练琴。
|
||||
//!
|
||||
//! 数据模型:曲目 (piece) → 附件 (attachment, 类型 video/audio/pdf/image)。
|
||||
//! 元数据走 sqlite,附件 bytes 落 `/data/blobs/<id>`,Range 下载交给 tower-http ServeFile。
|
||||
//!
|
||||
//! API:
|
||||
//! - `GET /api/pieces` 列表(含附件计数 + 简要类型分布)
|
||||
//! - `POST /api/pieces` 创建(json: title, category?, notes?)
|
||||
//! - `GET /api/pieces/:id` 详情(含 attachments 列表)
|
||||
//! - `PATCH /api/pieces/:id` 改 title / category / notes
|
||||
//! - `DELETE /api/pieces/:id` 删曲目 + 级联删附件 + 同步删磁盘
|
||||
//! - `POST /api/pieces/:id/attachments` multipart 流式上传,可一次多文件
|
||||
//! - `GET /api/attachments/:id` 下载(带 Range,video/audio 拖动用)
|
||||
//! - `DELETE /api/attachments/:id` 删单个附件 + 磁盘文件
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{DefaultBodyLimit, Multipart, Path, Query, Request, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Json as JsonResp, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SINGLE_FILE_BYTES: usize = 1024 * 1024 * 1024; // 1 GiB / 单附件
|
||||
const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
db: Arc<Mutex<Connection>>,
|
||||
blobs_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
cube_core::init_tracing();
|
||||
|
||||
let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
|
||||
let blobs_dir =
|
||||
PathBuf::from(std::env::var("BLOBS_DIR").unwrap_or_else(|_| "/data/blobs".into()));
|
||||
let dist = std::env::var("MUSIC_DIST_DIR").unwrap_or_else(|_| "/dist".into());
|
||||
|
||||
std::fs::create_dir_all(&blobs_dir).expect("mkdir blobs_dir");
|
||||
|
||||
let conn = Connection::open(&db_path).expect("open sqlite");
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS pieces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT,
|
||||
category TEXT,
|
||||
notes TEXT,
|
||||
lyrics TEXT,
|
||||
play_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_played_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
piece_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
role TEXT,
|
||||
mime TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_att_piece ON attachments(piece_id);",
|
||||
)
|
||||
.expect("init schema");
|
||||
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
|
||||
|
||||
let state = AppState {
|
||||
db: Arc::new(Mutex::new(conn)),
|
||||
blobs_dir,
|
||||
};
|
||||
|
||||
let api = Router::new()
|
||||
.route("/pieces", get(list_pieces).post(create_piece))
|
||||
.route(
|
||||
"/pieces/:id",
|
||||
get(get_piece).patch(patch_piece).delete(delete_piece),
|
||||
)
|
||||
.route("/pieces/:id/play", post(record_play))
|
||||
.route(
|
||||
"/pieces/:id/attachments",
|
||||
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
|
||||
)
|
||||
.route(
|
||||
"/attachments/:id",
|
||||
get(get_attachment).delete(delete_attachment),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let app = cube_core::base(dist).nest("/api", api);
|
||||
cube_core::serve(app, 8080).await
|
||||
}
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PieceSummary {
|
||||
id: i64,
|
||||
title: String,
|
||||
artist: Option<String>,
|
||||
category: Option<String>,
|
||||
play_count: i64,
|
||||
last_played_at: Option<String>,
|
||||
attachments: i64,
|
||||
kinds: Vec<String>,
|
||||
has_lyrics: bool,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PieceDetail {
|
||||
id: i64,
|
||||
title: String,
|
||||
artist: Option<String>,
|
||||
category: Option<String>,
|
||||
notes: Option<String>,
|
||||
lyrics: Option<String>,
|
||||
play_count: i64,
|
||||
last_played_at: Option<String>,
|
||||
created_at: String,
|
||||
attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Attachment {
|
||||
id: i64,
|
||||
kind: String,
|
||||
role: Option<String>,
|
||||
mime: String,
|
||||
filename: String,
|
||||
size_bytes: i64,
|
||||
sort_order: i64,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UploadQuery {
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreatePiece {
|
||||
title: String,
|
||||
artist: Option<String>,
|
||||
category: Option<String>,
|
||||
notes: Option<String>,
|
||||
lyrics: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PatchPiece {
|
||||
title: Option<String>,
|
||||
artist: Option<Option<String>>,
|
||||
category: Option<Option<String>>,
|
||||
notes: Option<Option<String>>,
|
||||
lyrics: Option<Option<String>>,
|
||||
/// admin / import 用:直接写 play_count(mvp 无认证)
|
||||
play_count: Option<i64>,
|
||||
}
|
||||
|
||||
// ---------- handlers: pieces ----------
|
||||
|
||||
async fn list_pieces(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.id, p.title, p.artist, p.category,
|
||||
p.play_count, p.last_played_at, p.created_at,
|
||||
COUNT(a.id) AS att_count,
|
||||
COALESCE(GROUP_CONCAT(DISTINCT a.kind), '') AS kinds,
|
||||
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics
|
||||
FROM pieces p
|
||||
LEFT JOIN attachments a ON a.piece_id = p.id
|
||||
GROUP BY p.id
|
||||
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let kinds_csv: String = r.get(8)?;
|
||||
let kinds = if kinds_csv.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
kinds_csv.split(',').map(|x| x.to_string()).collect()
|
||||
};
|
||||
let has_lyrics: i64 = r.get(9)?;
|
||||
Ok(PieceSummary {
|
||||
id: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
artist: r.get(2)?,
|
||||
category: r.get(3)?,
|
||||
play_count: r.get(4)?,
|
||||
last_played_at: r.get(5)?,
|
||||
created_at: r.get(6)?,
|
||||
attachments: r.get(7)?,
|
||||
kinds,
|
||||
has_lyrics: has_lyrics != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(JsonResp(json!(rows)))
|
||||
}
|
||||
|
||||
async fn create_piece(
|
||||
State(s): State<AppState>,
|
||||
JsonResp(body): JsonResp<CreatePiece>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let title = body.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(AppError::bad_request("title required"));
|
||||
}
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO pieces (title, artist, category, notes, lyrics)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
title,
|
||||
body.artist.as_deref().map(str::trim).filter(|s| !s.is_empty()),
|
||||
body.category.as_deref().map(str::trim).filter(|s| !s.is_empty()),
|
||||
body.notes.as_deref(),
|
||||
body.lyrics.as_deref()
|
||||
],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(JsonResp(json!({ "id": id })))
|
||||
}
|
||||
|
||||
async fn get_piece(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<PieceDetail>, AppError> {
|
||||
let conn = s.db.lock().unwrap();
|
||||
type PieceRow = (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
Option<String>,
|
||||
String,
|
||||
);
|
||||
let row: Option<PieceRow> = conn
|
||||
.query_row(
|
||||
"SELECT title, artist, category, notes, lyrics, play_count, last_played_at, created_at
|
||||
FROM pieces WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
r.get(3)?,
|
||||
r.get(4)?,
|
||||
r.get(5)?,
|
||||
r.get(6)?,
|
||||
r.get(7)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let (title, artist, category, notes, lyrics, play_count, last_played_at, created_at) =
|
||||
row.ok_or(AppError::NotFound)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, kind, role, mime, filename, size_bytes, sort_order, created_at
|
||||
FROM attachments
|
||||
WHERE piece_id = ?1
|
||||
ORDER BY sort_order ASC, id ASC",
|
||||
)?;
|
||||
let attachments = stmt
|
||||
.query_map(params![id], |r| {
|
||||
Ok(Attachment {
|
||||
id: r.get(0)?,
|
||||
kind: r.get(1)?,
|
||||
role: r.get(2)?,
|
||||
mime: r.get(3)?,
|
||||
filename: r.get(4)?,
|
||||
size_bytes: r.get(5)?,
|
||||
sort_order: r.get(6)?,
|
||||
created_at: r.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(JsonResp(PieceDetail {
|
||||
id,
|
||||
title,
|
||||
artist,
|
||||
category,
|
||||
notes,
|
||||
lyrics,
|
||||
play_count,
|
||||
last_played_at,
|
||||
created_at,
|
||||
attachments,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn patch_piece(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
JsonResp(body): JsonResp<PatchPiece>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let exists: bool = conn
|
||||
.query_row("SELECT 1 FROM pieces WHERE id = ?1", params![id], |_| {
|
||||
Ok(true)
|
||||
})
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
if let Some(title) = body.title.as_ref() {
|
||||
let t = title.trim();
|
||||
if t.is_empty() {
|
||||
return Err(AppError::bad_request("title can't be blank"));
|
||||
}
|
||||
conn.execute("UPDATE pieces SET title = ?1 WHERE id = ?2", params![t, id])?;
|
||||
}
|
||||
if let Some(artist) = body.artist {
|
||||
let artist = artist.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
conn.execute(
|
||||
"UPDATE pieces SET artist = ?1 WHERE id = ?2",
|
||||
params![artist, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(cat) = body.category {
|
||||
let cat = cat.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
conn.execute(
|
||||
"UPDATE pieces SET category = ?1 WHERE id = ?2",
|
||||
params![cat, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(notes) = body.notes {
|
||||
let notes = notes.as_deref();
|
||||
conn.execute(
|
||||
"UPDATE pieces SET notes = ?1 WHERE id = ?2",
|
||||
params![notes, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(lyrics) = body.lyrics {
|
||||
let lyrics = lyrics.as_deref();
|
||||
conn.execute(
|
||||
"UPDATE pieces SET lyrics = ?1 WHERE id = ?2",
|
||||
params![lyrics, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(pc) = body.play_count {
|
||||
conn.execute(
|
||||
"UPDATE pieces SET play_count = ?1 WHERE id = ?2",
|
||||
params![pc, id],
|
||||
)?;
|
||||
}
|
||||
Ok(JsonResp(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn record_play(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let n = conn.execute(
|
||||
"UPDATE pieces
|
||||
SET play_count = play_count + 1, last_played_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
if n == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT play_count FROM pieces WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(JsonResp(json!({ "play_count": count })))
|
||||
}
|
||||
|
||||
async fn delete_piece(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let to_unlink: Vec<i64> = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id FROM attachments WHERE piece_id = ?1")?;
|
||||
let ids: Vec<i64> = stmt
|
||||
.query_map(params![id], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let n = conn.execute("DELETE FROM pieces WHERE id = ?1", params![id])?;
|
||||
if n == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
ids
|
||||
};
|
||||
for aid in to_unlink {
|
||||
let _ = tokio::fs::remove_file(s.blobs_dir.join(aid.to_string())).await;
|
||||
}
|
||||
Ok(JsonResp(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------- handlers: attachments ----------
|
||||
|
||||
/// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。
|
||||
/// 每个 file field(任意 name)= 一个附件,`role` query 给整批文件。
|
||||
async fn upload_attachments(
|
||||
State(s): State<AppState>,
|
||||
Path(piece_id): Path<i64>,
|
||||
Query(q): Query<UploadQuery>,
|
||||
mut form: Multipart,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let role = match q.role.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => None,
|
||||
Some(r) if matches!(r, "chord" | "numbered" | "staff") => Some(r.to_string()),
|
||||
Some(other) => {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"unsupported role '{other}', expect one of: chord / numbered / staff"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let conn = s.db.lock().unwrap();
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM pieces WHERE id = ?1",
|
||||
params![piece_id],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
let mut created: Vec<Value> = Vec::new();
|
||||
|
||||
while let Some(mut field) = form
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("multipart: {e}")))?
|
||||
{
|
||||
let filename = field
|
||||
.file_name()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "untitled".to_string());
|
||||
let mime = field
|
||||
.content_type()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let kind = classify(&mime).ok_or_else(|| {
|
||||
AppError::bad_request(format!("unsupported mime '{mime}' for '{filename}'"))
|
||||
})?;
|
||||
|
||||
// 占坑拿 attachment id —— 文件名用 id,能唯一确定路径。
|
||||
let attachment_id = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO attachments (piece_id, kind, role, mime, filename, size_bytes, sort_order)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 0,
|
||||
COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)",
|
||||
params![piece_id, kind, role, mime, filename],
|
||||
)?;
|
||||
conn.last_insert_rowid()
|
||||
};
|
||||
|
||||
let final_path = s.blobs_dir.join(attachment_id.to_string());
|
||||
let tmp_path = s.blobs_dir.join(format!("{attachment_id}.tmp"));
|
||||
|
||||
let written: usize = match stream_to_file(&mut field, &tmp_path).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
let conn = s.db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM attachments WHERE id = ?1",
|
||||
params![attachment_id],
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
let conn = s.db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM attachments WHERE id = ?1",
|
||||
params![attachment_id],
|
||||
);
|
||||
return Err(AppError::Io(e));
|
||||
}
|
||||
|
||||
{
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE attachments SET size_bytes = ?1 WHERE id = ?2",
|
||||
params![written as i64, attachment_id],
|
||||
)?;
|
||||
}
|
||||
|
||||
created.push(json!({
|
||||
"id": attachment_id,
|
||||
"kind": kind,
|
||||
"role": role,
|
||||
"mime": mime,
|
||||
"filename": filename,
|
||||
"size_bytes": written,
|
||||
}));
|
||||
}
|
||||
|
||||
if created.is_empty() {
|
||||
return Err(AppError::bad_request("no files uploaded"));
|
||||
}
|
||||
Ok(JsonResp(json!({ "attachments": created })))
|
||||
}
|
||||
|
||||
async fn stream_to_file(
|
||||
field: &mut axum::extract::multipart::Field<'_>,
|
||||
path: &std::path::Path,
|
||||
) -> Result<usize, AppError> {
|
||||
let mut file = tokio::fs::File::create(path).await.map_err(AppError::Io)?;
|
||||
let mut total: usize = 0;
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("upload read: {e}")))?
|
||||
{
|
||||
total += chunk.len();
|
||||
if total > SINGLE_FILE_BYTES {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"single file exceeds {SINGLE_FILE_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
file.write_all(&chunk).await.map_err(AppError::Io)?;
|
||||
}
|
||||
file.flush().await.map_err(AppError::Io)?;
|
||||
file.sync_all().await.map_err(AppError::Io)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// `GET /api/attachments/:id` — Range-aware 下载。
|
||||
async fn get_attachment(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
let row: Option<(String, String, String)> = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT mime, filename, kind FROM attachments WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.optional()?
|
||||
};
|
||||
let (mime, filename, _kind) = row.ok_or(AppError::NotFound)?;
|
||||
let path = s.blobs_dir.join(id.to_string());
|
||||
|
||||
let mime_hv: header::HeaderValue = mime
|
||||
.parse()
|
||||
.unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream"));
|
||||
let svc = tower_http::services::ServeFile::new(&path);
|
||||
let mut resp = svc
|
||||
.oneshot(req)
|
||||
.await
|
||||
.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?
|
||||
.into_response();
|
||||
|
||||
// 强一些的缓存头,video 拖动友好
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private, max-age=3600"));
|
||||
resp.headers_mut().insert(header::CONTENT_TYPE, mime_hv);
|
||||
if let Ok(disp) = format!(
|
||||
"inline; filename*=UTF-8''{}",
|
||||
percent_encode(&filename)
|
||||
)
|
||||
.parse()
|
||||
{
|
||||
resp.headers_mut().insert(header::CONTENT_DISPOSITION, disp);
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn delete_attachment(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let n = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.execute("DELETE FROM attachments WHERE id = ?1", params![id])?
|
||||
};
|
||||
if n == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let _ = tokio::fs::remove_file(s.blobs_dir.join(id.to_string())).await;
|
||||
Ok(JsonResp(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
fn classify(mime: &str) -> Option<&'static str> {
|
||||
let m = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
|
||||
if m.starts_with("video/") {
|
||||
Some("video")
|
||||
} else if m.starts_with("audio/") {
|
||||
Some("audio")
|
||||
} else if m == "application/pdf" {
|
||||
Some("pdf")
|
||||
} else if m.starts_with("image/") {
|
||||
Some("image")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(*b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------- error type ----------
|
||||
|
||||
enum AppError {
|
||||
BadRequest(String),
|
||||
NotFound,
|
||||
Db(rusqlite::Error),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
fn bad_request(msg: impl Into<String>) -> Self {
|
||||
Self::BadRequest(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for AppError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
|
||||
Self::Db(e) => {
|
||||
tracing::error!(error = %e, "sqlite error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
|
||||
}
|
||||
Self::Io(e) => {
|
||||
tracing::error!(error = %e, "io error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user