Files
cube/apps/music/src/main.rs
T
Fam Zheng e111398157
deploy cube / build-and-deploy (push) Successful in 1m15s
deploy simpleasm / build-and-deploy (push) Successful in 1m19s
deploy music / build-and-deploy (push) Successful in 4m38s
music(chord): 加 yopu.co 吉他谱自动抓取(sidecar 模式)
复刻 ../guitar 的功能:
- 新加 chord-fetcher sidecar(python 3.11 + chromium + selenium),跟 main 同 pod 共享 PVC
- yopu.py v2:搜「和弦谱」→ 进 view → 选 谱面样式=功能谱 + 和弦样式=级数名 → 截 sheet-container → PIL 裁白边
- music backend 加 POST /api/pieces/:id/chord/fetch + GET /chord/status,转发 sidecar 并把 png import 成 image attachment role=chord
- 前端 chord tab 在没图时显示「自动抓取」按钮,点了 polling 状态、完成后刷新
- CI build 两个 image(music + music-chord),rollout 同步切版本
2026-05-09 22:52:09 +01:00

844 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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` 下载(带 Rangevideo/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,
/// 同 pod 的 chord-fetcher sidecar root(默认 http://localhost:8001)。
chord_url: String,
http: reqwest::Client,
}
#[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 chord_url =
std::env::var("CHORD_URL").unwrap_or_else(|_| "http://localhost:8001".into());
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("build reqwest client");
let state = AppState {
db: Arc::new(Mutex::new(conn)),
blobs_dir,
chord_url,
http,
};
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/chord/fetch", post(chord_fetch))
.route("/pieces/:id/chord/status", get(chord_status))
.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_countmvp 无认证)
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: chord auto-fetch ----------
/// `POST /api/pieces/:id/chord/fetch` — 触发 sidecar 抓取 yopu 和弦谱。
/// 已经有 chord attachment 的曲目直接返回 completed。
async fn chord_fetch(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let (title, artist, has_chord) = chord_piece_meta(&s, piece_id)?;
if has_chord {
return Ok(JsonResp(json!({ "status": "completed", "reason": "已有吉他谱" })));
}
let query = match artist.as_deref() {
Some(a) if !a.is_empty() => format!("{a} {title}"),
_ => title,
};
let url = format!("{}/fetch", s.chord_url);
let resp = s
.http
.post(&url)
.query(&[("piece_id", piece_id.to_string()), ("query", query)])
.send()
.await
.map_err(|e| AppError::sidecar(format!("post fetch: {e}")))?;
if !resp.status().is_success() {
let st = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(AppError::sidecar(format!("sidecar {st}: {body}")));
}
let body: Value = resp
.json()
.await
.map_err(|e| AppError::sidecar(format!("decode: {e}")))?;
Ok(JsonResp(body))
}
/// `GET /api/pieces/:id/chord/status` — 查询抓取状态。完成时把 png import 成 attachment。
async fn chord_status(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let (_title, _artist, has_chord) = chord_piece_meta(&s, piece_id)?;
if has_chord {
return Ok(JsonResp(json!({ "status": "completed", "imported": true })));
}
let url = format!("{}/status/{}", s.chord_url, piece_id);
let resp = s
.http
.get(&url)
.send()
.await
.map_err(|e| AppError::sidecar(format!("get status: {e}")))?;
if !resp.status().is_success() {
return Err(AppError::sidecar(format!("sidecar status: {}", resp.status())));
}
let body: Value = resp
.json()
.await
.map_err(|e| AppError::sidecar(format!("decode: {e}")))?;
let st = body.get("status").and_then(|v| v.as_str()).unwrap_or("none");
let file_exists = body
.get("file_exists")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if st == "completed" && file_exists {
let attachment_id = import_chord_png(&s, piece_id).await?;
// 通知 sidecar 清掉 state + 文件,避免重复 import
let _ = s
.http
.delete(format!("{}/state/{}", s.chord_url, piece_id))
.send()
.await;
return Ok(JsonResp(json!({
"status": "completed",
"imported": true,
"attachment_id": attachment_id,
})));
}
Ok(JsonResp(body))
}
fn chord_piece_meta(
s: &AppState,
piece_id: i64,
) -> Result<(String, Option<String>, bool), AppError> {
let conn = s.db.lock().unwrap();
let row: Option<(String, Option<String>)> = conn
.query_row(
"SELECT title, artist FROM pieces WHERE id = ?1",
params![piece_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (title, artist) = row.ok_or(AppError::NotFound)?;
let has_chord: bool = conn
.query_row(
"SELECT 1 FROM attachments
WHERE piece_id = ?1 AND kind = 'image' AND role = 'chord' LIMIT 1",
params![piece_id],
|_| Ok(true),
)
.optional()?
.unwrap_or(false);
Ok((title, artist, has_chord))
}
async fn import_chord_png(s: &AppState, piece_id: i64) -> Result<i64, AppError> {
let src = std::path::PathBuf::from(format!("/data/chord-fetch/{piece_id}.png"));
let bytes = tokio::fs::metadata(&src).await.map_err(AppError::Io)?;
let size = bytes.len() as i64;
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, 'image', 'chord', 'image/png', 'chord.png', ?2,
COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)",
params![piece_id, size],
)?;
conn.last_insert_rowid()
};
let dst = s.blobs_dir.join(attachment_id.to_string());
if let Err(e) = tokio::fs::copy(&src, &dst).await {
// 失败回滚 db 行
let conn = s.db.lock().unwrap();
let _ = conn.execute("DELETE FROM attachments WHERE id = ?1", params![attachment_id]);
return Err(AppError::Io(e));
}
Ok(attachment_id)
}
// ---------- 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),
Sidecar(String),
}
impl AppError {
fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
fn sidecar(msg: impl Into<String>) -> Self {
Self::Sidecar(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()
}
Self::Sidecar(msg) => {
tracing::warn!(error = %msg, "chord sidecar");
(StatusCode::BAD_GATEWAY, msg).into_response()
}
}
}
}