update
Some checks failed
Rust CI / Build Release (push) Has been cancelled
Rust CI / Cargo Check (push) Has been cancelled

This commit is contained in:
lychang
2026-07-19 15:04:22 +08:00
parent b915e69222
commit 82653c1a6c
40 changed files with 7778 additions and 1 deletions

74
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,74 @@
name: Rust CI
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
jobs:
check:
name: Cargo Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
components: clippy
- name: Cache cargo dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check compilation
run: cargo check --all-targets
- name: Clippy lint
run: cargo clippy --all-targets -- -D warnings
build:
name: Build Release
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- name: Cache cargo dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Build release
run: cargo build --release
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: imager-${{ runner.os }}
path: target/release/imager
if-no-files-found: error

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target
/albmums
/albums/
.idea

3417
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
-- 相册元数据扩展字段
ALTER TABLE albums ADD COLUMN person_name_original TEXT;
ALTER TABLE albums ADD COLUMN person_name_cleaned TEXT;
ALTER TABLE albums ADD COLUMN tags TEXT; -- JSON: [tag, ...]
ALTER TABLE albums ADD COLUMN all_words TEXT; -- JSON: [[word, pos], ...]
ALTER TABLE albums ADD COLUMN raw_title TEXT;
ALTER TABLE albums ADD COLUMN processed_at TEXT;
-- 标签索引表(倒排索引)
CREATE TABLE IF NOT EXISTS album_tags (
album_id TEXT NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (album_id, tag),
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_album_tags_tag ON album_tags(tag);
-- 人物表
CREATE TABLE IF NOT EXISTS persons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT,
total_albums INTEGER DEFAULT 0,
total_images INTEGER DEFAULT 0,
original_names TEXT, -- JSON: [raw_name, ...]
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_persons_name ON persons(name);
-- 别名表
CREATE TABLE IF NOT EXISTS person_aliases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id INTEGER NOT NULL,
alias_type TEXT NOT NULL, -- prefix / english / nickname / chinese
alias_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_aliases_name ON person_aliases(alias_name);
CREATE INDEX IF NOT EXISTS idx_aliases_person ON person_aliases(person_id);
-- 人物-相册关联表
CREATE TABLE IF NOT EXISTS person_albums (
person_id INTEGER NOT NULL,
album_id TEXT NOT NULL,
PRIMARY KEY (person_id, album_id),
FOREIGN KEY (person_id) REFERENCES persons(id),
FOREIGN KEY (album_id) REFERENCES albums(id)
);
CREATE INDEX IF NOT EXISTS idx_albums_cleaned ON albums(person_name_cleaned);

284
scripts/import_test_data.py Normal file
View File

@@ -0,0 +1,284 @@
"""
从 192.168.1.100:4500 API 拉取指定数量相册作为测试数据(多线程并行版)
导入到 Rust imager 项目的 SQLite 数据库和 albums 目录
"""
import requests
import sqlite3
import os
import sys
import time
import uuid
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
API_BASE = "http://192.168.1.100:4500"
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "imager.db")
ALBUMS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "albums")
# 全局 DB 写锁
db_lock = threading.Lock()
# 进度计数器
progress_lock = threading.Lock()
completed_count = [0]
total_count = [0]
# 线程本地 Session(复用连接)
thread_local = threading.local()
def get_session():
if not hasattr(thread_local, "session"):
thread_local.session = requests.Session()
thread_local.session.timeout = 60
return thread_local.session
def fetch_albums(limit=100):
"""获取相册列表"""
url = f"{API_BASE}/api/albums"
print(f"[1/4] 获取相册列表: {url}")
resp = requests.get(url, timeout=30)
data = resp.json()
if isinstance(data, dict) and "albums" in data:
albums = data["albums"]
elif isinstance(data, list):
albums = data
else:
print(f"未知响应格式: {type(data)}")
return []
albums = albums[:limit]
print(f" 获取到 {len(albums)} 个相册")
return albums
def fetch_album_images(album_id):
"""获取相册的图片列表"""
url = f"{API_BASE}/api/album/{album_id}"
try:
resp = requests.get(url, timeout=30)
data = resp.json()
if isinstance(data, dict) and "images" in data:
return data["images"]
elif isinstance(data, list):
return data
except Exception as e:
print(f" 获取图片列表失败 {album_id}: {e}")
return []
def download_single_image(img):
"""下载单张图片(线程安全)"""
img_id = img.get("id", str(uuid.uuid4()))
mime = img.get("mime_type", "image/jpeg")
ext = "jpg"
if mime and "/" in mime:
ext = mime.split("/")[-1]
album_id = str(img.get("_album_id", ""))
filename = f"{img_id}.{ext}"
db_filename = f"{album_id}/{filename}" # 数据库路径:{album_id}/{img_id}.ext
save_path = os.path.join(ALBUMS_DIR, album_id, filename)
# 跳过已下载
if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
file_size = os.path.getsize(save_path)
width, height = get_image_dimensions(save_path)
return (img_id, db_filename, file_size, width, height, mime)
url = f"{API_BASE}/api/image/{img_id}"
try:
session = get_session()
resp = session.get(url, timeout=60)
if resp.status_code == 200:
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "wb") as f:
f.write(resp.content)
file_size = len(resp.content)
width, height = get_image_dimensions(save_path)
return (img_id, db_filename, file_size, width, height, resp.headers.get("content-type", mime))
except Exception as e:
pass
return None
def get_image_dimensions(path):
"""获取图片尺寸(二进制头解析)"""
try:
with open(path, "rb") as f:
header = f.read(32)
if header[:2] == b"\xff\xd8":
f.seek(2)
while True:
chunk = f.read(4)
if len(chunk) < 4:
break
if chunk[0] != 0xFF:
break
seg_type = chunk[1]
if seg_type == 0xDA:
break
seg_len = int.from_bytes(chunk[2:4], "big")
if seg_type in (0xC0, 0xC1, 0xC2):
data = f.read(seg_len - 2)
if len(data) >= 5:
return int.from_bytes(data[1:3], "big"), int.from_bytes(data[3:5], "big")
f.seek(seg_len - 2, 1)
return 0, 0
elif header[:8] == b"\x89PNG\r\n\x1a\n":
return int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big")
elif header[:4] == b"RIFF" and header[8:12] == b"WEBP":
return int.from_bytes(header[24:26], "little"), int.from_bytes(header[26:28], "little")
elif header[:6] in (b"GIF87a", b"GIF89a"):
return int.from_bytes(header[6:8], "little"), int.from_bytes(header[8:10], "little")
return 0, 0
except Exception:
return 0, 0
def sync_album_to_db(conn, album, images_info):
"""将相册和图片写入数据库(调用方持有 db_lock)"""
cursor = conn.cursor()
now = datetime.utcnow().isoformat() + "Z"
album_id = album["id"]
title = album.get("title", "Untitled")
cursor.execute(
"""INSERT OR REPLACE INTO albums
(id, title, document, description, cover_image_id, image_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
album_id, title, album_id,
album.get("description"),
images_info[0][0] if images_info else None,
len(images_info),
album.get("created_at", now),
now,
),
)
for img_id, filename, file_size, width, height, mime_type in images_info:
cursor.execute(
"""INSERT OR REPLACE INTO images
(id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(img_id, album_id, filename, f"{API_BASE}/api/image/{img_id}",
f"图片 {img_id[:8]}", None, file_size, width, height, mime_type or "image/jpeg", now),
)
conn.commit()
def process_album(album, conn, dl_workers):
"""处理单个相册:下载所有图片并写入数据库"""
aid = album["id"]
title = album.get("title", "Untitled")[:60]
# 获取图片列表
images = fetch_album_images(aid)
if not images:
with progress_lock:
completed_count[0] += 1
print(f"[{completed_count[0]}/{total_count[0]}] {title} — 无图片,跳过")
return 0
# 为每个图片打上 album_id 标记
for img in images:
img["_album_id"] = aid
# 并行下载
downloaded = []
with ThreadPoolExecutor(max_workers=dl_workers) as pool:
futures = {pool.submit(download_single_image, img): img for img in images}
for future in as_completed(futures):
result = future.result()
if result:
downloaded.append(result)
if not downloaded:
with progress_lock:
completed_count[0] += 1
print(f"[{completed_count[0]}/{total_count[0]}] {title} — 0 张下载成功")
return 0
# 写入数据库
with db_lock:
sync_album_to_db(conn, album, downloaded)
n = len(downloaded)
with progress_lock:
completed_count[0] += 1
print(f"[{completed_count[0]}/{total_count[0]}] {title} — {n} 张图片")
return n
def main():
total = int(sys.argv[1]) if len(sys.argv) > 1 else 100
dl_workers = int(sys.argv[2]) if len(sys.argv) > 2 else 8
album_workers = int(sys.argv[3]) if len(sys.argv) > 3 else 4
print(f"=== 从 API 导入 {total} 个相册(并行下载: {dl_workers}线程/相册, 并行相册: {album_workers})===\n")
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
os.makedirs(ALBUMS_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA synchronous=NORMAL")
# 获取已有相册
existing = set()
for row in conn.execute("SELECT id FROM albums"):
existing.add(row[0])
print(f" 数据库中已有 {len(existing)} 个相册")
# 获取相册列表
albums = fetch_albums(total)
if not albums:
print("未获取到相册,退出")
return
new_albums = [a for a in albums if a["id"] not in existing]
skipped = len(albums) - len(new_albums)
if skipped > 0:
print(f" 跳过 {skipped} 个已存在的相册")
print(f" 需要导入 {len(new_albums)} 个新相册\n")
if not new_albums:
print("没有新相册需要导入")
conn.close()
return
total_count[0] = len(new_albums)
total_images = 0
t0 = time.time()
# 并行处理相册
with ThreadPoolExecutor(max_workers=album_workers) as pool:
futures = {pool.submit(process_album, album, conn, dl_workers): album for album in new_albums}
for future in as_completed(futures):
n = future.result()
total_images += n
elapsed = time.time() - t0
# 重建 image_count
print(f"\n[4/4] 更新相册图片计数...")
conn.execute("""UPDATE albums SET image_count = (SELECT COUNT(*) FROM images WHERE images.album_id = albums.id)""")
conn.commit()
conn.close()
print(f"\n=== 导入完成 ===")
print(f" 成功导入: {len(new_albums)} 个相册")
print(f" 总图片数: {total_images} 张")
print(f" 耗时: {elapsed:.1f} 秒")
print(f" 数据库: {DB_PATH}")
print(f" 图片目录: {ALBUMS_DIR}")
if __name__ == "__main__":
main()

872
src/bin/migrate.rs Normal file
View File

@@ -0,0 +1,872 @@
//! 数据库迁移 CLI 工具
//!
//! 功能:
//! 1. 从老数据库 (persons.db) 读取数据
//! 2. 对 title 进行解析 (人名提取、标签提取)
//! 3. 生成新的数据库
//! 4. 验证搜索等功能
//!
//! 用法:
//! cargo run --bin migrate -- --old-db ../yolov8_face_recognition/persons.db --new-db ./data/imager.db
//! cargo run --bin migrate -- --old-db ../yolov8_face_recognition/persons.db --new-db ./data/imager.db --reparse
//! cargo run --bin migrate -- --verify --new-db ./data/imager.db
use serde::{Deserialize, Serialize};
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, Row};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::Instant;
// ============================================================
// 标题解析模块 (Rust 原生实现)
// ============================================================
mod title_parser {
use regex::Regex;
use std::collections::HashSet;
use lazy_static::lazy_static;
/// 模特称呼前缀
const MODEL_PREFIXES: &[&str] = &[
"美女模特", "性感女神", "新人模特", "新人嫩模", "新人妹子",
"气质女神", "气质美女", "性感美女", "宅男女神", "丽质女神",
"丽质美女", "红粉佳人", "美乳女神", "清纯女神", "性感嫩模",
"性感御姐", "美女", "模特", "女神", "御姐", "嫩模",
"新晋女神", "新晋美模", "日本", "日本人气",
];
/// 停用词
const STOP_WORDS: &[&str] = &[
"photos", "photo", "phot", "images", "image", "video", "videos",
"picture", "pictures", "pic", "gif", "gifs", "mp4", "webp",
"cosplay", "cos", "set", "part", "collection", "series",
"special", "vol", "no", "no.", "ver", "version", "edition",
"new", "xxx", "preview", "teaser", "sample",
"写真", "套图", "旅拍", "系列", "诱惑", "性感", "美女",
"模特", "女神", "写真集", "首套", "版", "主题", "风格",
"the", "a", "an", "in", "on", "at", "of", "to", "for",
"with", "by", "and", "or", "but", "from", "is", "are",
"this", "that", "these", "those", "it", "its",
"的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一",
"一个", "上", "也", "很", "到", "说", "要", "去", "你", "会", "着",
"没有", "看", "好", "自己", "这", "他", "她", "它", "们", "那", "些",
];
/// 工作室排除名
const EXCLUDED_NAMES: &[&str] = &[
"xiuren", "秀人", "秀人网", "youmi", "尤蜜", "尤蜜荟",
"xingyan", "星颜", "魅妍社", "mistar",
"mygirl", "美媛馆", "imiss", "爱蜜社", "huayang", "花漾",
"bololi", "波萝社", "feilin", "嗲囡囡", "qingdouke", "青豆客",
"toutiao", "头条女神", "xiaoyu", "语画界", "youmei", "尤美",
"ugirls", "尤果", "尤果网", "mfstar", "模范学院",
"tgod", "推女神", "dgkirl", "御女郎", "candy", "糖果画报",
"wanibooks", "bluecake", "pure media", "puremedia",
];
lazy_static! {
static ref RE_PHOTOS: Regex = Regex::new(
r"(?i)\s*\d+\s*photos?\s*(?:and\s*\d+\s*videos?)?\s*$"
).unwrap();
static ref RE_COSPLAY: Regex = Regex::new(
r"(?i)^(.+?)\s+cosplay\s+(.+?)(?:\s*[–—\-]\s*(.+))?$"
).unwrap();
static ref RE_COSPLAY_BY: Regex = Regex::new(
r"(?i)^(.+?)\s+cosplay\s+by\s+(.+?)(?:\s*[–—\-]\s*(.+))?$"
).unwrap();
static ref RE_BRACKET: Regex = Regex::new(r"^[\s]*[\[【][^\]]*[\]】][\s]*").unwrap();
static ref RE_NUMBER_PREFIX: Regex = Regex::new(
r"(?i)^(?:No\.?\s*\d+|VOL\.?\s*\d+)\s+"
).unwrap();
static ref RE_SPLIT: Regex = Regex::new(r"\s*[–—\-]\s*").unwrap();
static ref RE_PAREN: Regex = Regex::new(r"[((]([^))]*)[))]").unwrap();
static ref RE_TRAILING: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap();
static ref RE_EN_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap();
static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
static ref RE_WORD: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap();
static ref STOP_SET: HashSet<&'static str> = {
let mut s = HashSet::new();
for w in STOP_WORDS { s.insert(*w); }
s
};
static ref EXCLUDED_LOWER: HashSet<String> = {
let mut s = HashSet::new();
for n in EXCLUDED_NAMES { s.insert(n.to_lowercase()); }
s
};
}
/// 清理标题末尾
pub fn clean_title_suffix(title: &str) -> String {
RE_PHOTOS.replace(title, "").trim().to_string()
}
/// 从标题提取人名
pub fn extract_person_name(title: &str) -> Option<String> {
let clean = clean_title_suffix(title);
if clean.is_empty() {
return None;
}
// cosplay 格式
if let Some(caps) = RE_COSPLAY_BY.captures(&clean) {
return Some(caps[2].trim().to_string());
}
if let Some(caps) = RE_COSPLAY.captures(&clean) {
return Some(caps[1].trim().to_string());
}
// 移除工作室前缀
let no_studio = RE_BRACKET.replace(&clean, "").trim().to_string();
// 移除模特称呼前缀
let mut no_prefix = no_studio;
for prefix in MODEL_PREFIXES {
if no_prefix.starts_with(prefix) {
no_prefix = no_prefix[prefix.len()..].trim().to_string();
break;
}
}
// 移除编号前缀
let no_prefix = RE_NUMBER_PREFIX.replace(&no_prefix, "").trim().to_string();
// 用分隔符拆分
let parts: Vec<&str> = RE_SPLIT.splitn(&no_prefix, 2).collect();
if parts.len() >= 2 {
let name = parts[0].trim();
let name = RE_PHOTOS.replace(name, "").trim().to_string();
if !name.is_empty() && name.len() >= 2 {
return Some(name);
}
}
// 没有分隔符
let name = RE_PHOTOS.replace(&no_prefix, "").trim().to_string();
if name.len() >= 2 {
Some(name)
} else {
None
}
}
/// 清洗人名
pub fn clean_person_name(raw_name: &str) -> (String, Vec<(String, String)>) {
let mut aliases = Vec::new();
let mut name = raw_name.to_string();
// 移除前缀
for prefix in MODEL_PREFIXES {
if name.starts_with(prefix) {
aliases.push(("prefix".to_string(), prefix.to_string()));
name = name[prefix.len()..].trim().to_string();
break;
}
}
// 移除括号内容
let mut cleaned = String::new();
let mut last_end = 0;
for cap in RE_PAREN.captures_iter(&name) {
let m = cap.get(0).unwrap();
let content = cap.get(1).map_or("", |c| c.as_str());
cleaned.push_str(&name[last_end..m.start()]);
last_end = m.end();
if !content.is_empty() {
if RE_CHINESE.is_match(content) {
aliases.push(("nickname".to_string(), content.to_string()));
} else {
aliases.push(("english".to_string(), content.to_string()));
}
}
}
cleaned.push_str(&name[last_end..]);
let name = cleaned.trim().to_string();
// 清理末尾标点
let name = RE_TRAILING.replace(&name, "").trim().to_string();
// 提取英文名别名
if let Some(caps) = RE_EN_SUFFIX.captures(&name) {
let chinese = caps[1].to_string();
let english = caps[2].trim().to_string();
if !english.is_empty() {
aliases.push(("chinese".to_string(), chinese));
aliases.push(("english".to_string(), english));
}
}
let name = if name.is_empty() { raw_name.to_string() } else { name };
(name, aliases)
}
/// 从标题提取标签
pub fn extract_tags(title: &str, person_name: Option<&str>) -> Vec<(String, String)> {
let mut clean = clean_title_suffix(title);
// 移除人名
if let Some(name) = person_name {
clean = clean.replace(name, " ");
for part in name.split_whitespace() {
clean = clean.replace(part, " ");
}
}
// 移除工作室前缀
clean = RE_BRACKET.replace(&clean, "").trim().to_string();
let mut tags = Vec::new();
let mut seen = HashSet::new();
for cap in RE_WORD.find_iter(&clean) {
let word = cap.as_str().trim();
if word.len() < 2 { continue; }
let word_lower = word.to_lowercase();
if STOP_SET.contains(word_lower.as_str()) { continue; }
if word.chars().all(|c| c.is_ascii_digit() || c == '.') { continue; }
if !seen.insert(word_lower.clone()) { continue; }
let tag_type = if RE_CHINESE.is_match(word) { "chinese" } else { "english" };
tags.push((word.to_lowercase(), tag_type.to_string()));
}
tags
}
/// 判断是否为排除名
pub fn is_excluded(name: &str) -> bool {
EXCLUDED_LOWER.contains(&name.to_lowercase())
}
}
// ============================================================
// 数据模型
// ============================================================
#[derive(Debug, Default)]
struct MigrationStats {
persons_imported: i64,
persons_skipped: i64,
persons_created: i64,
aliases_imported: i64,
albums_processed: i64,
albums_missing: i64,
tags_imported: i64,
person_albums_created: i64,
}
#[derive(Debug, Clone)]
struct OldAlbum {
id: String,
title: String,
image_count: i64,
person_name_original: Option<String>,
person_name_cleaned: Option<String>,
tags: Option<String>,
all_words: Option<String>,
}
#[derive(Debug, Clone)]
struct OldPerson {
id: i64,
name: String,
display_name: Option<String>,
total_albums: i64,
total_images: i64,
original_names: Option<String>,
}
// ============================================================
// 迁移器
// ============================================================
struct Migrator {
old_pool: SqlitePool,
new_pool: SqlitePool,
stats: MigrationStats,
}
impl Migrator {
async fn new(old_db_path: &str, new_db_path: &str) -> Result<Self, String> {
if !Path::new(old_db_path).exists() {
return Err(format!("老数据库不存在: {}", old_db_path));
}
// 创建新数据库目录
if let Some(parent) = Path::new(new_db_path).parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
}
println!("[连接] 老数据库: {}", old_db_path);
let old_pool = SqlitePoolOptions::new()
.max_connections(1)
.connect(&format!("sqlite:{}", old_db_path))
.await
.map_err(|e| format!("连接老数据库失败: {}", e))?;
println!("[连接] 新数据库: {}", new_db_path);
let new_pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(&format!("sqlite:{}", new_db_path))
.await
.map_err(|e| format!("连接新数据库失败: {}", e))?;
// SQLite 性能优化
for p in [&new_pool] {
sqlx::query("PRAGMA journal_mode=WAL;").execute(p).await.ok();
sqlx::query("PRAGMA synchronous=NORMAL;").execute(p).await.ok();
sqlx::query("PRAGMA foreign_keys=ON;").execute(p).await.ok();
}
Ok(Self { old_pool, new_pool, stats: MigrationStats::default() })
}
async fn init_new_schema(&self) -> Result<(), String> {
println!("[Schema] 初始化新数据库表结构...");
let schema_sql = include_str!("../migrations/001_initial.sql");
for stmt in schema_sql.split(';') {
let stmt = stmt.trim();
if !stmt.is_empty() {
sqlx::query(stmt).execute(&self.new_pool).await
.map_err(|e| format!("执行 Schema 失败: {}", e))?;
}
}
let schema_sql2 = include_str!("../migrations/002_metadata.sql");
for stmt in schema_sql2.split(';') {
let stmt = stmt.trim();
if !stmt.is_empty() {
sqlx::query(stmt).execute(&self.new_pool).await
.map_err(|e| format!("执行 Schema2 失败: {}", e))?;
}
}
println!("[Schema] 表结构初始化完成");
Ok(())
}
async fn read_old_albums(&self) -> Result<Vec<OldAlbum>, String> {
println!("[读取] 从老数据库读取相册...");
let rows = sqlx::query(
"SELECT id, title, image_count, person_name_original, person_name_cleaned, tags, all_words
FROM albums ORDER BY image_count DESC"
).fetch_all(&self.old_pool).await.map_err(|e| format!("读取相册失败: {}", e))?;
let albums: Vec<OldAlbum> = rows.iter().map(|r| OldAlbum {
id: r.get("id"),
title: r.get("title"),
image_count: r.get::<Option<i64>, _>("image_count").unwrap_or(0),
person_name_original: r.get("person_name_original"),
person_name_cleaned: r.get("person_name_cleaned"),
tags: r.get("tags"),
all_words: r.get("all_words"),
}).collect();
println!("[读取] 共读取 {} 个相册", albums.len());
Ok(albums)
}
async fn read_old_persons(&self) -> Result<Vec<OldPerson>, String> {
println!("[读取] 从老数据库读取人物...");
let rows = sqlx::query(
"SELECT id, name, display_name, total_albums, total_images, original_names
FROM persons ORDER BY total_albums DESC"
).fetch_all(&self.old_pool).await.map_err(|e| format!("读取人物失败: {}", e))?;
let persons: Vec<OldPerson> = rows.iter().map(|r| OldPerson {
id: r.get("id"),
name: r.get("name"),
display_name: r.get("display_name"),
total_albums: r.get::<Option<i64>, _>("total_albums").unwrap_or(0),
total_images: r.get::<Option<i64>, _>("total_images").unwrap_or(0),
original_names: r.get("original_names"),
}).collect();
println!("[读取] 共读取 {} 个人物", persons.len());
Ok(persons)
}
async fn read_old_aliases(&self) -> Result<Vec<(i64, String, String)>, String> {
let rows = sqlx::query(
"SELECT person_id, alias_type, alias_name FROM person_aliases"
).fetch_all(&self.old_pool).await.unwrap_or_default();
let aliases: Vec<(i64, String, String)> = rows.iter().map(|r| (
r.get("person_id"), r.get("alias_type"), r.get("alias_name")
)).collect();
println!("[读取] 共读取 {} 个别名", aliases.len());
Ok(aliases)
}
async fn read_old_person_albums(&self) -> Result<Vec<(i64, String)>, String> {
let rows = sqlx::query(
"SELECT person_id, album_id FROM person_albums"
).fetch_all(&self.old_pool).await.unwrap_or_default();
let links: Vec<(i64, String)> = rows.iter().map(|r| (
r.get("person_id"), r.get("album_id")
)).collect();
println!("[读取] 共读取 {} 个人物-相册关联", links.len());
Ok(links)
}
async fn migrate(&mut self, reparse: bool) -> Result<(), String> {
self.init_new_schema().await?;
// 读取老数据
let old_albums = self.read_old_albums().await?;
let old_persons = self.read_old_persons().await?;
let old_aliases = self.read_old_aliases().await?;
let old_links = self.read_old_person_albums().await?;
let mut old_to_new_person: HashMap<i64, i64> = HashMap::new();
// Step 1: 迁移人物
println!("\n[迁移] 步骤 1/5: 迁移人物...");
for p in &old_persons {
if title_parser::is_excluded(&p.name) {
self.stats.persons_skipped += 1;
continue;
}
sqlx::query(
"INSERT OR IGNORE INTO persons (name, display_name, total_albums, total_images, original_names)
VALUES (?, ?, ?, ?, ?)"
).bind(&p.name).bind(p.display_name.as_deref().unwrap_or(&p.name))
.bind(p.total_albums).bind(p.total_images).bind(p.original_names.as_deref())
.execute(&self.new_pool).await.ok();
let new_id: i64 = sqlx::query_scalar("SELECT id FROM persons WHERE name = ?")
.bind(&p.name).fetch_one(&self.new_pool).await.unwrap_or(0);
if new_id > 0 {
old_to_new_person.insert(p.id, new_id);
self.stats.persons_imported += 1;
}
}
println!("[迁移] 导入 {} 人,跳过 {} 人", self.stats.persons_imported, self.stats.persons_skipped);
// Step 2: 迁移别名
println!("\n[迁移] 步骤 2/5: 迁移别名...");
for (old_pid, alias_type, alias_name) in &old_aliases {
if let Some(&new_pid) = old_to_new_person.get(old_pid) {
sqlx::query(
"INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)"
).bind(new_pid).bind(alias_type).bind(alias_name)
.execute(&self.new_pool).await.ok();
self.stats.aliases_imported += 1;
}
}
println!("[迁移] 导入 {} 个别名", self.stats.aliases_imported);
// Step 3: 迁移相册 + 解析title
println!(
"\n[迁移] 步骤 3/5: 迁移相册并解析title ({})...",
if reparse { "重新解析" } else { "保留原解析" }
);
let mut person_map: HashMap<String, i64> = HashMap::new();
for (i, album) in old_albums.iter().enumerate() {
if (i + 1) % 200 == 0 {
println!(" 处理进度: {}/{}", i + 1, old_albums.len());
}
let (person_orig, person_cleaned, tags, all_words) =
if reparse || album.person_name_cleaned.as_deref().unwrap_or("").is_empty() {
// 重新解析
let person_name = title_parser::extract_person_name(&album.title);
let (cleaned, _aliases) = match &person_name {
Some(n) => {
let (c, a) = title_parser::clean_person_name(n);
if title_parser::is_excluded(&c) {
(None, a)
} else {
(Some(c), a)
}
}
None => (None, Vec::new()),
};
let tags_raw = title_parser::extract_tags(&album.title, person_name.as_deref());
let tag_names: Vec<String> = tags_raw.iter().map(|(t, _)| t.clone()).collect();
let all_words: Vec<[String; 2]> = tags_raw.iter()
.map(|(t, tp)| [t.clone(), tp.clone()]).collect();
(
person_name,
cleaned,
serde_json::to_string(&tag_names).unwrap_or_else(|_| "[]".to_string()),
serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string()),
)
} else {
(
album.person_name_original.clone(),
album.person_name_cleaned.clone(),
album.tags.clone().unwrap_or_else(|| "[]".to_string()),
album.all_words.clone().unwrap_or_else(|| "[]".to_string()),
)
};
// 写入相册
sqlx::query(
"INSERT OR REPLACE INTO albums
(id, title, image_count, person_name_original, person_name_cleaned,
tags, all_words, raw_title, processed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))"
).bind(&album.id).bind(&album.title).bind(album.image_count)
.bind(person_orig.as_deref()).bind(person_cleaned.as_deref())
.bind(&tags).bind(&all_words).bind(&album.title)
.execute(&self.new_pool).await.ok();
// 写入标签索引
if let Ok(tag_list) = serde_json::from_str::<Vec<String>>(&tags) {
if !tag_list.is_empty() {
for tag in &tag_list {
sqlx::query(
"INSERT OR IGNORE INTO album_tags (album_id, tag) VALUES (?, ?)"
).bind(&album.id).bind(tag).execute(&self.new_pool).await.ok();
}
self.stats.tags_imported += 1;
}
}
// 关联人物
if let Some(pc) = &person_cleaned {
if !pc.is_empty() && !title_parser::is_excluded(pc) {
let pid = if let Some(&id) = person_map.get(pc) {
id
} else {
// 查找或创建
let existing: Option<i64> = sqlx::query_scalar(
"SELECT id FROM persons WHERE name = ?"
).bind(pc).fetch_optional(&self.new_pool).await.unwrap_or(None);
let id = if let Some(eid) = existing {
eid
} else {
sqlx::query("INSERT INTO persons (name, display_name) VALUES (?, ?)")
.bind(pc).bind(pc).execute(&self.new_pool).await.ok();
self.stats.persons_created += 1;
sqlx::query_scalar::<_, i64>("SELECT last_insert_rowid()")
.fetch_one(&self.new_pool).await.unwrap_or(0)
};
person_map.insert(pc.clone(), id);
id
};
if pid > 0 {
sqlx::query(
"INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)"
).bind(pid).bind(&album.id).execute(&self.new_pool).await.ok();
}
}
}
self.stats.albums_processed += 1;
}
println!(
"[迁移] 处理 {} 个相册,{} 个标签导入,{} 个人物从title创建",
self.stats.albums_processed, self.stats.tags_imported, self.stats.persons_created
);
// Step 4: 迁移人物-相册关联
println!("\n[迁移] 步骤 4/5: 迁移人物-相册关联...");
for (old_pid, album_id) in &old_links {
if let Some(&new_pid) = old_to_new_person.get(old_pid) {
sqlx::query(
"INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)"
).bind(new_pid).bind(album_id).execute(&self.new_pool).await.ok();
self.stats.person_albums_created += 1;
}
}
println!("[迁移] 导入 {} 个关联", self.stats.person_albums_created);
// Step 5: 更新人物统计
println!("\n[迁移] 步骤 5/5: 更新人物统计...");
let person_ids: Vec<i64> = sqlx::query_scalar("SELECT id FROM persons")
.fetch_all(&self.new_pool).await.unwrap_or_default();
for pid in &person_ids {
sqlx::query(
"UPDATE persons SET
total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?),
total_images = (
SELECT COALESCE(SUM(a.image_count), 0)
FROM albums a JOIN person_albums pa ON a.id = pa.album_id
WHERE pa.person_id = ?
),
updated_at = datetime('now')
WHERE id = ?"
).bind(pid).bind(pid).bind(pid).execute(&self.new_pool).await.ok();
}
println!("[迁移] 更新 {} 个人物统计", person_ids.len());
Ok(())
}
async fn print_stats(&self) {
let stats = self.get_new_stats().await;
println!("\n 新数据库统计:");
println!(" 相册数: {}", stats.total_albums);
println!(" 图片数: {}", stats.total_images);
println!(" 人物数: {}", stats.total_persons);
println!(" 别名数: {}", stats.total_aliases);
println!(" 标签数: {}", stats.unique_tags);
println!(" 有标签相册: {}", stats.albums_with_tags);
println!(" 有人物相册: {}", stats.albums_with_person);
}
async fn get_new_stats(&self) -> NewDbStats {
NewDbStats {
total_albums: sqlx::query_scalar("SELECT COUNT(*) FROM albums")
.fetch_one(&self.new_pool).await.unwrap_or(0),
total_images: sqlx::query_scalar("SELECT COALESCE(SUM(image_count), 0) FROM albums")
.fetch_one(&self.new_pool).await.unwrap_or(0),
total_persons: sqlx::query_scalar("SELECT COUNT(*) FROM persons")
.fetch_one(&self.new_pool).await.unwrap_or(0),
total_aliases: sqlx::query_scalar("SELECT COUNT(*) FROM person_aliases")
.fetch_one(&self.new_pool).await.unwrap_or(0),
unique_tags: sqlx::query_scalar("SELECT COUNT(DISTINCT tag) FROM album_tags")
.fetch_one(&self.new_pool).await.unwrap_or(0),
albums_with_tags: sqlx::query_scalar(
"SELECT COUNT(*) FROM albums WHERE tags IS NOT NULL AND tags != '[]'"
).fetch_one(&self.new_pool).await.unwrap_or(0),
albums_with_person: sqlx::query_scalar(
"SELECT COUNT(*) FROM albums WHERE person_name_cleaned IS NOT NULL AND person_name_cleaned != ''"
).fetch_one(&self.new_pool).await.unwrap_or(0),
}
}
}
#[derive(Debug)]
struct NewDbStats {
total_albums: i64,
total_images: i64,
total_persons: i64,
total_aliases: i64,
unique_tags: i64,
albums_with_tags: i64,
albums_with_person: i64,
}
// ============================================================
// 搜索验证器
// ============================================================
struct SearchVerifier {
pool: SqlitePool,
}
impl SearchVerifier {
async fn new(db_path: &str) -> Result<Self, String> {
let pool = SqlitePoolOptions::new()
.max_connections(2)
.connect(&format!("sqlite:{}", db_path))
.await
.map_err(|e| format!("连接数据库失败: {}", e))?;
Ok(Self { pool })
}
async fn verify_all(&self) {
println!("\n{}", "=".repeat(60));
println!(" 🔍 搜索功能验证 (Rust 实现)");
println!("{}", "=".repeat(60));
// 1. 热门人物
println!("\n[验证1] 热门人物 (Top 10):");
let top_persons: Vec<(String, i64, i64)> = sqlx::query(
"SELECT name, total_albums, total_images FROM persons ORDER BY total_albums DESC LIMIT 10"
).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get("name"), r.get("total_albums"), r.get("total_images"))).collect();
for (i, (name, albums, images)) in top_persons.iter().enumerate() {
println!(" {:2d}. {:<25} {:4} 相册, {:5} 张图片", i + 1, name, albums, images);
}
// 2. 热门标签
println!("\n[验证2] 热门标签 (Top 20):");
let top_tags: Vec<(String, i64, i64)> = sqlx::query(
"SELECT tag, COUNT(*) as cnt, COUNT(DISTINCT album_id) as album_cnt
FROM album_tags GROUP BY tag ORDER BY cnt DESC LIMIT 20"
).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get("tag"), r.get::<i64, _>("cnt"), r.get::<i64, _>("album_cnt"))).collect();
for (i, (tag, cnt, album_cnt)) in top_tags.iter().enumerate() {
println!(" {:2d}. {:<20} 出现 {:4} 次, {:4} 个相册", i + 1, tag, cnt, album_cnt);
}
// 3. 人物搜索
if let Some((test_person, _, _)) = top_persons.first() {
println!("\n[验证3] 人物搜索: \"{}\"", test_person);
let like = format!("%{}%", test_person);
let results: Vec<(String, i64)> = sqlx::query(
"SELECT DISTINCT p.name, p.total_albums FROM persons p
LEFT JOIN person_aliases pa ON pa.person_id = p.id
WHERE p.name LIKE ? OR pa.alias_name LIKE ?
ORDER BY p.total_albums DESC LIMIT 5"
).bind(&like).bind(&like).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get("name"), r.get("total_albums"))).collect();
for (name, albums) in &results {
println!(" - {} ({} 相册)", name, albums);
}
// 4. 人物相册
println!("\n[验证4] 人物相册: \"{}\" (前5个)", test_person);
let person_albums: Vec<(i64, String)> = sqlx::query(
"SELECT a.image_count, a.title FROM albums a
JOIN person_albums pa ON pa.album_id = a.id
JOIN persons p ON p.id = pa.person_id
WHERE p.name = ? ORDER BY a.image_count DESC LIMIT 5"
).bind(test_person).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get("image_count"), r.get("title"))).collect();
for (ic, title) in &person_albums {
let short: String = title.chars().take(60).collect();
println!(" - [{:3}张] {}", ic, short);
}
}
// 5. 标签搜索
if let Some((test_tag, _, _)) = top_tags.first() {
println!("\n[验证5] 标签搜索: \"{}\" (前5个)", test_tag);
let tag_albums: Vec<(i64, String)> = sqlx::query(
"SELECT a.image_count, a.title FROM albums a
JOIN album_tags t ON t.album_id = a.id
WHERE t.tag = ? ORDER BY a.image_count DESC LIMIT 5"
).bind(test_tag).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get("image_count"), r.get("title"))).collect();
for (ic, title) in &tag_albums {
let short: String = title.chars().take(60).collect();
println!(" - [{:3}张] {}", ic, short);
}
}
// 6. 标题搜索
println!("\n[验证6] 标题搜索: \"性感\" (前5个)");
let like = "%性感%".to_string();
let title_albums: Vec<(i64, String)> = sqlx::query(
"SELECT image_count, title FROM albums WHERE title LIKE ? ORDER BY image_count DESC LIMIT 5"
).bind(&like).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get("image_count"), r.get("title"))).collect();
for (ic, title) in &title_albums {
let short: String = title.chars().take(60).collect();
println!(" - [{:3}张] {}", ic, short);
}
println!("\n{}", "=".repeat(60));
println!(" ✅ 搜索功能验证完成!");
println!("{}", "=".repeat(60));
}
}
// ============================================================
// CLI 入口
// ============================================================
#[derive(Debug, clap::Parser)]
#[command(name = "migrate", about = "数据库迁移工具 (Rust 实现)")]
struct Cli {
/// 老数据库路径
#[arg(long, default_value = "../yolov8_face_recognition/persons.db")]
old_db: String,
/// 新数据库路径
#[arg(long, default_value = "./data/imager.db")]
new_db: String,
/// 重新解析所有 title
#[arg(long)]
reparse: bool,
/// 迁移后验证搜索功能
#[arg(long)]
verify: bool,
/// 仅验证搜索功能 (不执行迁移)
#[arg(long)]
verify_only: bool,
}
#[tokio::main]
async fn main() -> Result<(), String> {
// 检查是否有 clap
let args: Vec<String> = std::env::args().collect();
let mut old_db = "../yolov8_face_recognition/persons.db".to_string();
let mut new_db = "./data/imager.db".to_string();
let mut reparse = false;
let mut verify = false;
let mut verify_only = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--old-db" => { i += 1; if i < args.len() { old_db = args[i].clone(); } }
"--new-db" => { i += 1; if i < args.len() { new_db = args[i].clone(); } }
"--reparse" => reparse = true,
"--verify" => verify = true,
"--verify-only" => verify_only = true,
"--help" | "-h" => {
println!("数据库迁移工具 (Rust 实现)");
println!();
println!("用法: cargo run --bin migrate -- [选项]");
println!();
println!("选项:");
println!(" --old-db <路径> 老数据库路径 (默认: ../yolov8_face_recognition/persons.db)");
println!(" --new-db <路径> 新数据库路径 (默认: ./data/imager.db)");
println!(" --reparse 重新解析所有 title");
println!(" --verify 迁移后验证搜索功能");
println!(" --verify-only 仅验证搜索功能 (不执行迁移)");
println!(" --help, -h 显示帮助");
return Ok(());
}
_ => {}
}
i += 1;
}
let start = Instant::now();
// 仅验证模式
if verify_only {
if !Path::new(&new_db).exists() {
return Err(format!("数据库不存在: {}", new_db));
}
let verifier = SearchVerifier::new(&new_db).await?;
verifier.verify_all().await;
return Ok(());
}
println!("{}", "=".repeat(60));
println!(" 🚀 数据库迁移工具 (Rust 原生实现)");
println!("{}", "=".repeat(60));
// 执行迁移
let mut migrator = Migrator::new(&old_db, &new_db).await?;
migrator.migrate(reparse).await?;
println!("\n{}", "=".repeat(60));
println!(" ✅ 迁移完成!用时: {:.2?}", start.elapsed());
println!("{}", "=".repeat(60));
migrator.print_stats().await;
// 验证搜索
if verify {
let verifier = SearchVerifier::new(&new_db).await?;
verifier.verify_all().await;
}
Ok(())
}

132
src/cache/mod.rs vendored Normal file
View File

@@ -0,0 +1,132 @@
//! 缓存层 — 内存缓存管理
use crate::models::*;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::info;
/// 相册缓存
#[derive(Debug, Clone)]
pub struct AlbumsCache {
pub albums: Vec<AlbumInfo>,
pub search_index: HashMap<String, Vec<AlbumInfo>>,
pub last_updated: chrono::DateTime<chrono::Utc>,
}
impl AlbumsCache {
pub fn new() -> Self {
Self { albums: Vec::new(), search_index: HashMap::new(), last_updated: chrono::Utc::now() }
}
pub fn is_expired(&self) -> bool {
(chrono::Utc::now() - self.last_updated).num_minutes() >= 30
}
pub fn update(&mut self, albums: Vec<AlbumInfo>) {
self.search_index.clear();
for album in &albums {
let title_lower = album.title.to_lowercase();
for word in title_lower.split_whitespace() {
if word.len() >= 2 {
self.search_index.entry(word.to_string()).or_default().push(album.clone());
}
}
self.search_index.entry(title_lower).or_default().push(album.clone());
}
self.albums = albums;
self.last_updated = chrono::Utc::now();
info!("相册缓存已更新,共 {} 个相册,{} 个搜索索引项", self.albums.len(), self.search_index.len());
}
pub fn search(&self, query: &str) -> Vec<AlbumInfo> {
let q = query.to_lowercase().trim().to_string();
if q.is_empty() { return Vec::new(); }
let mut seen = std::collections::HashSet::new();
let mut results = Vec::new();
// 1. 完整匹配
if let Some(matches) = self.search_index.get(&q) {
for album in matches {
if seen.insert(album.id) { results.push(album.clone()); }
}
}
// 2. 单词匹配
for word in q.split_whitespace() {
if word.len() >= 2 {
if let Some(matches) = self.search_index.get(word) {
for album in matches {
if seen.insert(album.id) { results.push(album.clone()); }
}
}
}
}
// 3. 模糊搜索
if results.is_empty() {
for album in &self.albums {
let tl = album.title.to_lowercase();
if tl.contains(&q) {
if seen.insert(album.id) { results.push(album.clone()); }
}
}
}
// 4. 部分匹配
if results.is_empty() && q.len() >= 2 {
for album in &self.albums {
let tl = album.title.to_lowercase();
if q.split_whitespace().any(|w| w.len() >= 2 && tl.contains(w)) {
if seen.insert(album.id) { results.push(album.clone()); }
}
}
}
results
}
}
/// 标签缓存
#[derive(Debug, Clone, Default)]
pub struct TagCache {
pub popular_tags: Vec<TagStat>,
pub last_updated: chrono::DateTime<chrono::Utc>,
}
impl TagCache {
pub fn update(&mut self, tags: Vec<TagStat>) {
self.popular_tags = tags;
self.last_updated = chrono::Utc::now();
info!("标签缓存已更新,共 {} 个热门标签", self.popular_tags.len());
}
}
/// 人物缓存
#[derive(Debug, Clone, Default)]
pub struct PersonCache {
pub persons: Vec<PersonWithCounts>,
pub last_updated: chrono::DateTime<chrono::Utc>,
}
impl PersonCache {
pub fn update(&mut self, persons: Vec<PersonWithCounts>) {
self.persons = persons;
self.last_updated = chrono::Utc::now();
info!("人物缓存已更新,共 {} 人", self.persons.len());
}
}
/// 共享缓存
pub struct AppCache {
pub albums: Arc<RwLock<AlbumsCache>>,
pub tags: Arc<RwLock<TagCache>>,
pub persons: Arc<RwLock<PersonCache>>,
}
impl AppCache {
pub fn new() -> Self {
Self {
albums: Arc::new(RwLock::new(AlbumsCache::new())),
tags: Arc::new(RwLock::new(TagCache::default())),
persons: Arc::new(RwLock::new(PersonCache::default())),
}
}
}

132
src/db/album.rs Normal file
View File

@@ -0,0 +1,132 @@
use super::Database;
use crate::models::*;
use sqlx::Row;
use uuid::Uuid;
impl Database {
// ── 查询 ──
pub async fn get_all_albums(&self) -> Result<Vec<AlbumInfo>, sqlx::Error> {
let rows = sqlx::query(
r#"SELECT id, title, document, description, cover_image_id, image_count, created_at
FROM albums ORDER BY created_at DESC"#
).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|r| {
let id_str = r.get::<String, _>("id");
AlbumInfo {
id: Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()),
title: r.get("title"),
document: r.get("document"),
description: r.get("description"),
cover_image_id: r.get("cover_image_id"),
image_count: r.get("image_count"),
created_at: r.get("created_at"),
}
}).collect())
}
pub async fn get_album_with_images(&self, album_id: Uuid) -> Result<Option<AlbumWithImages>, sqlx::Error> {
let row = sqlx::query("SELECT * FROM albums WHERE id = ?")
.bind(album_id.to_string())
.fetch_optional(&self.pool).await?;
let Some(row) = row else { return Ok(None) };
let id_str = row.get::<String, _>("id");
let id = Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil());
let cover = row.get::<Option<String>, _>("cover_image_id")
.and_then(|s| Uuid::parse_str(&s).ok());
let album = Album {
id,
title: row.get("title"),
description: row.get("description"),
cover_image_id: cover,
image_count: row.get("image_count"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
person_name_cleaned: row.get("person_name_cleaned"),
tags: row.get("tags"),
};
let img_rows = sqlx::query("SELECT * FROM images WHERE album_id = ?")
.bind(album_id.to_string()).fetch_all(&self.pool).await?;
let mut images = Vec::new();
for r in &img_rows {
let img_id = Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil());
let aid = Uuid::parse_str(&r.get::<String, _>("album_id")).unwrap_or_else(|_| Uuid::nil());
images.push(super::image::Image {
id: img_id, album_id: aid,
filename: r.get("filename"),
original_url: r.get("original_url"),
title: r.get("title"),
description: r.get("description"),
file_size: r.get("file_size"),
width: r.get("width"),
height: r.get("height"),
mime_type: r.get("mime_type"),
created_at: r.get("created_at"),
});
}
Ok(Some(AlbumWithImages { album, images: images.clone(), image_count: images.len() as i64 }))
}
pub async fn get_all_albums_full(&self) -> Vec<AlbumMeta> {
let has_col = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM pragma_table_info('albums') WHERE name = 'person_name_original'"
).fetch_one(&self.pool).await.unwrap_or(0);
if has_col == 0 { return Vec::new(); }
let rows = sqlx::query(
r#"SELECT id, title, person_name_original, person_name_cleaned, tags, all_words, raw_title, processed_at, image_count
FROM albums ORDER BY created_at DESC"#
).fetch_all(&self.pool).await.unwrap_or_default();
rows.iter().map(|r| AlbumMeta {
id: r.get("id"), title: r.get("title"),
person_name_original: r.get("person_name_original"),
person_name_cleaned: r.get("person_name_cleaned"),
tags: r.get("tags"), all_words: r.get("all_words"),
raw_title: r.get("raw_title"), processed_at: r.get("processed_at"),
image_count: r.get("image_count"),
}).collect()
}
// ── 写入 ──
pub async fn create_album(&self, album: CreateAlbum) -> Result<Uuid, sqlx::Error> {
let id = Uuid::new_v4();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO albums (id, title, document, description, image_count, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?)"
).bind(id.to_string()).bind(album.title).bind(album.document).bind(album.description).bind(&now).bind(&now)
.execute(&self.pool).await?;
Ok(id)
}
pub async fn delete_album(&self, album_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM albums WHERE id = ?").bind(album_id.to_string()).execute(&self.pool).await?;
Ok(())
}
pub async fn update_album_cover(&self, album_id: Uuid, image_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE albums SET cover_image_id = ? WHERE id = ?")
.bind(image_id.to_string()).bind(album_id.to_string()).execute(&self.pool).await?;
Ok(())
}
pub async fn update_album_image_count(&self, album_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query(
r#"UPDATE albums SET image_count = (SELECT COUNT(*) FROM images WHERE album_id = ?), updated_at = datetime('now') WHERE id = ?"#
).bind(album_id.to_string()).bind(album_id.to_string()).execute(&self.pool).await?;
Ok(())
}
pub async fn get_album_image_count(&self, album_id: Uuid) -> Result<i64, sqlx::Error> {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM images WHERE album_id = ?")
.bind(album_id.to_string()).fetch_one(&self.pool).await
}
}

63
src/db/connection.rs Normal file
View File

@@ -0,0 +1,63 @@
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
use crate::config::Config;
pub struct Database {
pub pool: SqlitePool,
pub config: Config,
}
impl Database {
pub async fn new(config: &Config) -> Result<Self, sqlx::Error> {
let url = &config.database.url;
let file_path = if url.starts_with("sqlite:") { &url[7..] } else { url };
if let Some(parent) = std::path::Path::new(file_path).parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
if !std::path::Path::new(file_path).exists() {
std::fs::File::create(file_path)?;
println!("创建新的数据库文件: {}", file_path);
}
let pool = SqlitePoolOptions::new()
.max_connections(10)
.min_connections(2)
.acquire_timeout(std::time::Duration::from_secs(5))
.idle_timeout(std::time::Duration::from_secs(300))
.max_lifetime(std::time::Duration::from_secs(1800))
.connect(url)
.await?;
// SQLite 性能优化
sqlx::query("PRAGMA journal_mode=WAL;").execute(&pool).await?;
sqlx::query("PRAGMA wal_autocheckpoint=4000;").execute(&pool).await?;
sqlx::query("PRAGMA synchronous=NORMAL;").execute(&pool).await?;
sqlx::query("PRAGMA cache_size=-64000;").execute(&pool).await?;
sqlx::query("PRAGMA mmap_size=268435456;").execute(&pool).await?;
sqlx::query("PRAGMA temp_store=MEMORY;").execute(&pool).await?;
sqlx::query("PRAGMA foreign_keys=ON;").execute(&pool).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let db = Database { pool, config: config.clone() };
db.ensure_image_count_field().await?;
Ok(db)
}
/// 确保 image_count 字段存在
async fn ensure_image_count_field(&self) -> Result<(), sqlx::Error> {
let has_col = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM pragma_table_info('albums') WHERE name = 'image_count'"
).fetch_one(&self.pool).await.unwrap_or(0);
if has_col == 0 {
sqlx::query("ALTER TABLE albums ADD COLUMN image_count INTEGER DEFAULT 0").execute(&self.pool).await?;
println!("✅ image_count 字段已添加");
} else {
println!("ℹ️ image_count字段已存在,跳过初始化");
}
Ok(())
}
}

67
src/db/favorite.rs Normal file
View File

@@ -0,0 +1,67 @@
use super::Database;
use crate::models::*;
use sqlx::Row;
use uuid::Uuid;
impl Database {
pub async fn get_user_favorites(&self, user_id: &str) -> Result<Vec<FavoriteWithImage>, sqlx::Error> {
let rows = sqlx::query(
r#"SELECT f.id, f.user_id, f.image_id, f.album_id, f.created_at,
i.album_id as img_album_id, i.filename, i.original_url, i.title, i.description,
i.file_size, i.width, i.height, i.mime_type, i.created_at as img_created_at,
a.title as album_title
FROM favorites f
JOIN images i ON f.image_id = i.id
JOIN albums a ON f.album_id = a.id
WHERE f.user_id = ? ORDER BY f.created_at DESC"#
).bind(user_id).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|r| {
let fav_id = Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil());
let img_id = Uuid::parse_str(&r.get::<String, _>("image_id")).unwrap_or_else(|_| Uuid::nil());
let aid = Uuid::parse_str(&r.get::<String, _>("album_id")).unwrap_or_else(|_| Uuid::nil());
let img_aid = Uuid::parse_str(&r.get::<String, _>("img_album_id")).unwrap_or_else(|_| Uuid::nil());
FavoriteWithImage {
favorite: Favorite { id: fav_id, user_id: r.get("user_id"), image_id: img_id, album_id: aid, created_at: r.get("created_at") },
image: crate::models::image::Image {
id: img_id, album_id: img_aid,
filename: r.get("filename"), original_url: r.get("original_url"),
title: r.get("title"), description: r.get("description"),
file_size: r.get("file_size"), width: r.get("width"),
height: r.get("height"), mime_type: r.get("mime_type"),
created_at: r.get("img_created_at"),
},
album_title: r.get("album_title"),
}
}).collect())
}
pub async fn get_favorite_status(&self, user_id: &str, image_id: Uuid) -> Result<FavoriteStatus, sqlx::Error> {
let row = sqlx::query("SELECT id FROM favorites WHERE user_id = ? AND image_id = ?")
.bind(user_id).bind(image_id.to_string()).fetch_optional(&self.pool).await?;
Ok(FavoriteStatus {
is_favorited: row.is_some(),
favorite_id: row.map(|r| Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil())),
})
}
pub async fn add_favorite(&self, user_id: &str, fav: CreateFavorite) -> Result<Uuid, sqlx::Error> {
let id = Uuid::new_v4();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("INSERT INTO favorites (id, user_id, image_id, album_id, created_at) VALUES (?, ?, ?, ?, ?)")
.bind(id.to_string()).bind(user_id).bind(fav.image_id.to_string()).bind(fav.album_id.to_string()).bind(&now)
.execute(&self.pool).await?;
Ok(id)
}
pub async fn remove_favorite(&self, favorite_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM favorites WHERE id = ?").bind(favorite_id.to_string()).execute(&self.pool).await?;
Ok(())
}
pub async fn remove_favorite_by_image(&self, user_id: &str, image_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM favorites WHERE user_id = ? AND image_id = ?")
.bind(user_id).bind(image_id.to_string()).execute(&self.pool).await?;
Ok(())
}
}

74
src/db/image.rs Normal file
View File

@@ -0,0 +1,74 @@
use super::Database;
use crate::models::*;
use sqlx::Row;
use uuid::Uuid;
/// 重新导出用于 cross-module access
pub use crate::models::image::Image;
impl Database {
pub async fn add_images(&self, images: Vec<CreateImage>) -> Result<Vec<Uuid>, sqlx::Error> {
if images.is_empty() { return Ok(Vec::new()); }
let now = chrono::Utc::now().to_rfc3339();
let mut ids = Vec::new();
let first_album_id = images.first().map(|img| img.album_id);
for img in &images {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO images (id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).bind(id.to_string()).bind(img.album_id.to_string()).bind(&img.filename)
.bind(&img.original_url).bind(&img.title).bind(&img.description)
.bind(img.file_size).bind(img.width).bind(img.height).bind(&img.mime_type).bind(&now)
.execute(&self.pool).await?;
ids.push(id);
}
if let Some(aid) = first_album_id {
self.update_album_image_count(aid).await?;
let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM images WHERE album_id = ?")
.bind(aid.to_string()).fetch_one(&self.pool).await?;
if count == images.len() as i64 {
self.update_album_cover(aid, ids[0]).await?;
}
}
Ok(ids)
}
pub async fn get_image(&self, image_id: Uuid) -> Result<Option<Image>, sqlx::Error> {
let row = sqlx::query("SELECT * FROM images WHERE id = ?")
.bind(image_id.to_string()).fetch_optional(&self.pool).await?;
let Some(r) = row else { return Ok(None) };
Ok(Some(Image {
id: image_id,
album_id: Uuid::parse_str(&r.get::<String, _>("album_id")).unwrap_or_else(|_| Uuid::nil()),
filename: r.get("filename"),
original_url: r.get("original_url"),
title: r.get("title"),
description: r.get("description"),
file_size: r.get("file_size"),
width: r.get("width"),
height: r.get("height"),
mime_type: r.get("mime_type"),
created_at: r.get("created_at"),
}))
}
pub async fn get_unprocessed_images(&self, limit: i64) -> Result<Vec<Image>, sqlx::Error> {
let rows = sqlx::query("SELECT * FROM images WHERE file_size > 0 LIMIT ?")
.bind(limit).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|r| Image {
id: Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil()),
album_id: Uuid::parse_str(&r.get::<String, _>("album_id")).unwrap_or_else(|_| Uuid::nil()),
filename: r.get("filename"),
original_url: r.get("original_url"),
title: r.get("title"),
description: r.get("description"),
file_size: r.get("file_size"),
width: r.get("width"),
height: r.get("height"),
mime_type: r.get("mime_type"),
created_at: r.get("created_at"),
}).collect())
}
}

203
src/db/metadata.rs Normal file
View File

@@ -0,0 +1,203 @@
use super::Database;
use crate::models::*;
use sqlx::Row;
use uuid::Uuid;
impl Database {
// ── 人员查询 ──
pub async fn get_all_person_names(&self) -> Vec<(String, i64)> {
sqlx::query("SELECT p.name, p.total_albums FROM persons p ORDER BY p.total_albums DESC")
.fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get::<String, _>("name"), r.get::<i64, _>("total_albums"))).collect()
}
pub async fn get_all_aliases(&self) -> Vec<(String, String)> {
sqlx::query("SELECT a.alias_name, p.name as person_name FROM person_aliases a JOIN persons p ON a.person_id = p.id")
.fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get::<String, _>("alias_name"), r.get::<String, _>("person_name"))).collect()
}
pub async fn get_all_persons_with_counts(&self) -> Vec<(i64, String, i64, i64)> {
sqlx::query("SELECT id, name, total_albums, total_images FROM persons ORDER BY total_albums DESC")
.fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| (r.get::<i64, _>("id"), r.get::<String, _>("name"), r.get::<i64, _>("total_albums"), r.get::<i64, _>("total_images"))).collect()
}
pub async fn get_person_by_name(&self, name: &str) -> Option<Person> {
let r = sqlx::query("SELECT * FROM persons WHERE name = ?").bind(name).fetch_optional(&self.pool).await.ok()??;
Some(Person {
id: r.get("id"), name: r.get("name"), display_name: r.get("display_name"),
total_albums: r.get("total_albums"), total_images: r.get("total_images"),
original_names: r.get("original_names"), created_at: r.get("created_at"), updated_at: r.get("updated_at"),
})
}
pub async fn get_person_albums(&self, person_id: i64) -> Vec<AlbumInfo> {
sqlx::query(
r#"SELECT a.id, a.title, a.document, a.description, a.cover_image_id, a.image_count, a.created_at
FROM albums a JOIN person_albums pa ON a.id = pa.album_id WHERE pa.person_id = ? ORDER BY a.created_at DESC"#
).bind(person_id).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| AlbumInfo {
id: Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil()),
title: r.get("title"), document: r.get("document"),
description: r.get("description"), cover_image_id: r.get("cover_image_id"),
image_count: r.get("image_count"), created_at: r.get("created_at"),
}).collect()
}
pub async fn get_person_aliases(&self, person_id: i64) -> Vec<PersonAlias> {
sqlx::query("SELECT * FROM person_aliases WHERE person_id = ?").bind(person_id)
.fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| PersonAlias {
id: r.get("id"), person_id: r.get("person_id"),
alias_type: r.get("alias_type"), alias_name: r.get("alias_name"),
created_at: r.get("created_at"),
}).collect()
}
pub async fn search_persons(&self, query: &str) -> Vec<PersonWithCounts> {
let pattern = format!("%{}%", query);
let rows = sqlx::query(
r#"SELECT DISTINCT p.id, p.name, p.display_name, p.total_albums, p.total_images
FROM persons p LEFT JOIN person_aliases a ON p.id = a.person_id
WHERE p.name LIKE ? OR a.alias_name LIKE ? ORDER BY p.total_albums DESC"#
).bind(&pattern).bind(&pattern).fetch_all(&self.pool).await.unwrap_or_default();
let mut results = Vec::new();
for r in &rows {
let pid = r.get::<i64, _>("id");
let aliases = self.get_person_aliases(pid).await;
results.push(PersonWithCounts {
id: pid, name: r.get("name"), display_name: r.get("display_name"),
total_albums: r.get("total_albums"), total_images: r.get("total_images"),
aliases: aliases.into_iter().map(|a| a.alias_name).collect(),
});
}
results
}
pub async fn get_all_persons_with_aliases(&self) -> Vec<PersonWithCounts> {
let rows = sqlx::query("SELECT id, name, display_name, total_albums, total_images FROM persons ORDER BY total_albums DESC")
.fetch_all(&self.pool).await.unwrap_or_default();
let mut results = Vec::new();
for r in &rows {
let pid = r.get::<i64, _>("id");
let aliases = self.get_person_aliases(pid).await;
results.push(PersonWithCounts {
id: pid, name: r.get("name"), display_name: r.get("display_name"),
total_albums: r.get("total_albums"), total_images: r.get("total_images"),
aliases: aliases.into_iter().map(|a| a.alias_name).collect(),
});
}
results
}
// ── 标签查询 ──
pub async fn get_popular_tags(&self, limit: i64) -> Vec<TagStat> {
sqlx::query(
r#"SELECT tag, COUNT(*) as cnt, COUNT(DISTINCT album_id) as album_cnt
FROM album_tags GROUP BY tag ORDER BY cnt DESC LIMIT ?"#
).bind(limit).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| TagStat {
tag: r.get("tag"), count: r.get("cnt"), album_count: r.get("album_cnt"),
}).collect()
}
pub async fn get_tag_co_occurrences(&self, tag: &str, limit: i64) -> Vec<TagCoOccurrence> {
let rows = sqlx::query(
r#"SELECT t2.tag as co_tag, COUNT(*) as cnt
FROM album_tags t1 JOIN album_tags t2 ON t1.album_id = t2.album_id
WHERE t1.tag = ? AND t2.tag != ? GROUP BY t2.tag ORDER BY cnt DESC LIMIT ?"#
).bind(tag).bind(tag).bind(limit).fetch_all(&self.pool).await.unwrap_or_default();
let total = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM album_tags WHERE tag = ?")
.bind(tag).fetch_one(&self.pool).await.unwrap_or(1);
rows.iter().map(|r| {
let cnt: i64 = r.get("cnt");
TagCoOccurrence {
tag: tag.to_string(), co_tag: r.get("co_tag"),
count: cnt, score: cnt as f64 / total as f64,
}
}).collect()
}
pub async fn search_albums_by_tag(&self, tag: &str) -> Vec<AlbumInfo> {
sqlx::query(
r#"SELECT a.id, a.title, a.document, a.description, a.cover_image_id, a.image_count, a.created_at
FROM albums a JOIN album_tags t ON a.id = t.album_id WHERE t.tag = ? ORDER BY a.created_at DESC"#
).bind(tag).fetch_all(&self.pool).await.unwrap_or_default()
.iter().map(|r| AlbumInfo {
id: Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil()),
title: r.get("title"), document: r.get("document"),
description: r.get("description"), cover_image_id: r.get("cover_image_id"),
image_count: r.get("image_count"), created_at: r.get("created_at"),
}).collect()
}
// ── 统计 ──
pub async fn get_metadata_stats(&self) -> MetadataStats {
MetadataStats {
total_persons: sqlx::query_scalar("SELECT COUNT(*) FROM persons").fetch_one(&self.pool).await.unwrap_or(0),
total_aliases: sqlx::query_scalar("SELECT COUNT(*) FROM person_aliases").fetch_one(&self.pool).await.unwrap_or(0),
total_albums_with_person: sqlx::query_scalar("SELECT COUNT(*) FROM albums WHERE person_name_original IS NOT NULL AND person_name_original != ''").fetch_one(&self.pool).await.unwrap_or(0),
total_albums_without_person: sqlx::query_scalar("SELECT COUNT(*) FROM albums WHERE person_name_original IS NULL OR person_name_original = ''").fetch_one(&self.pool).await.unwrap_or(0),
total_tags: sqlx::query_scalar("SELECT COUNT(*) FROM album_tags").fetch_one(&self.pool).await.unwrap_or(0),
}
}
// ── 写入 ──
pub async fn insert_person(&self, name: &str, display_name: Option<&str>) -> i64 {
if let Some(p) = self.get_person_by_name(name).await { return p.id; }
sqlx::query("INSERT OR IGNORE INTO persons (name, display_name) VALUES (?, ?)")
.bind(name).bind(display_name).execute(&self.pool).await.ok();
self.get_person_by_name(name).await.map(|p| p.id).unwrap_or(0)
}
pub async fn insert_person_alias(&self, person_id: i64, alias_type: &str, alias_name: &str) {
sqlx::query("INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)")
.bind(person_id).bind(alias_type).bind(alias_name).execute(&self.pool).await.ok();
}
pub async fn insert_person_album(&self, person_id: i64, album_id: &str) {
sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)")
.bind(person_id).bind(album_id).execute(&self.pool).await.ok();
}
pub async fn delete_person_albums(&self, person_id: i64) {
sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
}
pub async fn update_album_person(&self, album_id: &str, person_name_original: &str, person_name_cleaned: &str) {
sqlx::query("UPDATE albums SET person_name_original = ?, person_name_cleaned = ?, processed_at = datetime('now') WHERE id = ?")
.bind(person_name_original).bind(person_name_cleaned).bind(album_id).execute(&self.pool).await.ok();
}
pub async fn update_album_tags(&self, album_id: &str, tags_json: &str, all_words_json: &str) {
sqlx::query("UPDATE albums SET tags = ?, all_words = ?, processed_at = datetime('now') WHERE id = ?")
.bind(tags_json).bind(all_words_json).bind(album_id).execute(&self.pool).await.ok();
sqlx::query("DELETE FROM album_tags WHERE album_id = ?").bind(album_id).execute(&self.pool).await.ok();
if let Ok(tags) = serde_json::from_str::<Vec<String>>(tags_json) {
for tag in &tags {
sqlx::query("INSERT OR IGNORE INTO album_tags (album_id, tag) VALUES (?, ?)")
.bind(album_id).bind(tag).execute(&self.pool).await.ok();
}
}
}
pub async fn update_person_counts(&self, person_id: i64) {
sqlx::query(
r#"UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?),
total_images = (SELECT COALESCE(SUM(a.image_count), 0) FROM albums a JOIN person_albums pa ON a.id = pa.album_id WHERE pa.person_id = ?),
updated_at = datetime('now') WHERE id = ?"#
).bind(person_id).bind(person_id).bind(person_id).execute(&self.pool).await.ok();
}
/// 获取所有相册 ID 集合(用于迁移)
pub async fn get_all_album_ids(&self) -> Vec<String> {
sqlx::query_scalar::<_, String>("SELECT id FROM albums").fetch_all(&self.pool).await.unwrap_or_default()
}
}

161
src/db/migration.rs Normal file
View File

@@ -0,0 +1,161 @@
//! 从老数据库(Python 项目 persons.db)迁移元数据到新数据库
//!
//! 迁移策略:
//! 1. 读取老 DB 的 persons/aliases/albums 数据
//! 2. 按 album_id 对齐新 DB 中的相册
//! 3. 批量插入人员、别名、标签、关联
use super::Database;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::Row;
/// 迁移结果统计
#[derive(Debug, Default)]
pub struct MigrationStats {
pub persons_imported: usize,
pub persons_skipped: usize,
pub aliases_imported: usize,
pub albums_updated: usize,
pub albums_missing: usize,
pub tags_imported: usize,
pub person_albums_created: usize,
}
impl Database {
/// 从老数据库迁移元数据
pub async fn migrate_from_old_db(&self, old_db_path: &str) -> Result<MigrationStats, String> {
if !std::path::Path::new(old_db_path).exists() {
return Err(format!("老数据库不存在: {}", old_db_path));
}
println!("[迁移] 打开老数据库: {}", old_db_path);
let old_pool = SqlitePoolOptions::new()
.max_connections(1)
.connect(&format!("sqlite:{}", old_db_path))
.await
.map_err(|e| format!("连接老数据库失败: {}", e))?;
// 先获取新 DB 中所有相册 ID
let new_album_ids: std::collections::HashSet<String> = self.get_all_album_ids().await.into_iter().collect();
println!("[迁移] 新数据库中有 {} 个相册", new_album_ids.len());
let mut stats = MigrationStats::default();
// ── 1. 迁移人员 ──
println!("[迁移] 步骤 1/4: 迁移人员...");
let old_persons: Vec<(i64, String, Option<String>, i64, i64, Option<String>)> = sqlx::query(
"SELECT id, name, display_name, total_albums, total_images, original_names FROM persons"
).fetch_all(&old_pool).await.map_err(|e| format!("读取老人员失败: {}", e))?
.iter().map(|r| (
r.get("id"), r.get("name"), r.get("display_name"),
r.get("total_albums"), r.get("total_images"), r.get("original_names"),
)).collect();
let mut old_to_new_person: std::collections::HashMap<i64, i64> = std::collections::HashMap::new();
for (old_pid, name, display_name, _albums, _images, original_names) in &old_persons {
let new_pid = self.insert_person(name, display_name.as_deref()).await;
if new_pid > 0 {
old_to_new_person.insert(*old_pid, new_pid);
// 更新 original_names
if let Some(on) = original_names {
sqlx::query("UPDATE persons SET original_names = ? WHERE id = ?")
.bind(on).bind(new_pid).execute(&self.pool).await.ok();
}
stats.persons_imported += 1;
} else {
stats.persons_skipped += 1;
}
}
println!("[迁移] 导入 {} 人,跳过 {} 人", stats.persons_imported, stats.persons_skipped);
// ── 2. 迁移别名 ──
println!("[迁移] 步骤 2/4: 迁移别名...");
let old_aliases: Vec<(i64, String, String)> = sqlx::query(
"SELECT person_id, alias_type, alias_name FROM person_aliases"
).fetch_all(&old_pool).await.unwrap_or_default()
.iter().map(|r| (r.get("person_id"), r.get("alias_type"), r.get("alias_name"))).collect();
for (old_pid, alias_type, alias_name) in &old_aliases {
if let Some(&new_pid) = old_to_new_person.get(old_pid) {
self.insert_person_alias(new_pid, alias_type, alias_name).await;
stats.aliases_imported += 1;
}
}
println!("[迁移] 导入 {} 个别名", stats.aliases_imported);
// ── 3. 迁移相册人员标签 ──
println!("[迁移] 步骤 3/4: 迁移相册人员+标签...");
let old_albums: Vec<(String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = sqlx::query(
"SELECT id, title, person_name_original, person_name_cleaned, tags, all_words FROM albums"
).fetch_all(&old_pool).await.unwrap_or_default()
.iter().map(|r| (
r.get("id"), r.get("title"), r.get("person_name_original"),
r.get("person_name_cleaned"), r.get("tags"), r.get("all_words"),
)).collect();
for (album_id, _title, person_orig, person_cleaned, tags, all_words) in &old_albums {
if !new_album_ids.contains(album_id) {
stats.albums_missing += 1;
continue;
}
// 更新人员信息
if let (Some(po), Some(pc)) = (person_orig, person_cleaned) {
if !po.is_empty() {
self.update_album_person(album_id, po, pc).await;
stats.albums_updated += 1;
}
}
// 更新标签
if let Some(t) = tags {
if t != "[]" && !t.is_empty() {
let aw = all_words.clone().unwrap_or_else(|| "[]".to_string());
self.update_album_tags(album_id, t, &aw).await;
stats.tags_imported += 1;
}
}
}
println!("[迁移] 更新 {} 个相册,{} 个不在新库中,{} 个标签导入",
stats.albums_updated, stats.albums_missing, stats.tags_imported);
// ── 4. 迁移人物-相册关联 ──
println!("[迁移] 步骤 4/4: 迁移人物-相册关联...");
let old_person_albums: Vec<(i64, String)> = sqlx::query(
"SELECT person_id, album_id FROM person_albums"
).fetch_all(&old_pool).await.unwrap_or_default()
.iter().map(|r| (r.get("person_id"), r.get("album_id"))).collect();
for (old_pid, album_id) in &old_person_albums {
if let Some(&new_pid) = old_to_new_person.get(old_pid) {
if new_album_ids.contains(album_id) {
self.insert_person_album(new_pid, album_id).await;
stats.person_albums_created += 1;
}
}
}
println!("[迁移] 创建 {} 个关联", stats.person_albums_created);
// 更新所有人员的计数
println!("[迁移] 更新人员统计...");
for &new_pid in old_to_new_person.values() {
self.update_person_counts(new_pid).await;
}
println!("[迁移] ✅ 完成!");
Ok(stats)
}
/// 获取迁移统计摘要
pub fn migration_summary(&self, stats: &MigrationStats) -> String {
format!(
"迁移完成:\n - 人员: {} 导入, {} 跳过\n - 别名: {} 导入\n - 相册: {} 更新, {} 缺失\n - 标签: {} 导入\n - 关联: {} 创建",
stats.persons_imported, stats.persons_skipped,
stats.aliases_imported,
stats.albums_updated, stats.albums_missing,
stats.tags_imported,
stats.person_albums_created,
)
}
}

15
src/db/mod.rs Normal file
View File

@@ -0,0 +1,15 @@
//! 数据库操作层 — 按领域拆分
mod connection;
pub mod album;
pub mod image;
pub mod favorite;
pub mod metadata;
pub mod migration;
pub use connection::Database;
pub use album::*;
pub use image::*;
pub use favorite::*;
pub use metadata::*;
pub use migration::MigrationStats;

176
src/engine/crawler.rs Normal file
View File

@@ -0,0 +1,176 @@
//! API 数据获取
//! 从 http://192.168.1.100:4500 获取相册和图片数据
//! 对应 Python 的 fetch_from_api.py
use serde::Deserialize;
use std::time::Duration;
/// 从 API 获取的相册信息
#[derive(Debug, Clone, Deserialize)]
pub struct ApiAlbum {
pub id: String,
pub title: String,
#[serde(default)]
pub image_count: i64,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
/// 从 API 获取的图片详情
#[derive(Debug, Clone, Deserialize)]
pub struct ApiImage {
pub id: String,
#[serde(default)]
pub filename: Option<String>,
#[serde(default)]
pub original_url: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub width: Option<i32>,
#[serde(default)]
pub height: Option<i32>,
#[serde(default)]
pub file_size: Option<i64>,
#[serde(default)]
pub mime_type: Option<String>,
}
/// 相册详情(含图片列表)
#[derive(Debug, Clone, Deserialize)]
pub struct ApiAlbumDetail {
#[serde(default)]
pub images: Vec<ApiImage>,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub title: Option<String>,
}
/// API 数据获取器
#[derive(Debug, Clone)]
pub struct Crawler {
api_base: String,
client: reqwest::Client,
}
impl Crawler {
/// 创建 Crawler 实例
pub fn new(api_base: &str) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent("imager-rs/1.0")
.build()
.expect("创建 HTTP 客户端失败");
Self {
api_base: api_base.trim_end_matches('/').to_string(),
client,
}
}
/// 获取所有相册列表
/// GET /api/albums
pub async fn get_albums(&self) -> Result<Vec<ApiAlbum>, Box<dyn std::error::Error>> {
let url = format!("{}/api/albums", self.api_base);
let resp = self.client.get(&url).send().await?;
let data: serde_json::Value = resp.json().await?;
let albums: Vec<ApiAlbum> = if let Some(arr) = data.as_array() {
serde_json::from_value(data)?
} else if let Some(arr) = data.get("albums").and_then(|v: &serde_json::Value| v.as_array()) {
serde_json::from_value(serde_json::Value::Array(arr.clone()))?
} else {
Vec::new()
};
Ok(albums)
}
/// 获取相册详情(含图片列表)
/// GET /api/album/{id}
pub async fn get_album_detail(&self, album_id: &str) -> Result<ApiAlbumDetail, Box<dyn std::error::Error>> {
let url = format!("{}/api/album/{}", self.api_base, album_id);
let resp = self.client.get(&url).send().await?;
let data: serde_json::Value = resp.json().await?;
let detail = if let Some(images) = data.get("images").and_then(|v: &serde_json::Value| v.as_array()) {
ApiAlbumDetail {
images: serde_json::from_value(serde_json::Value::Array(images.clone()))?,
id: data.get("id").and_then(|v: &serde_json::Value| v.as_str()).map(|s| s.to_string()),
title: data.get("title").and_then(|v: &serde_json::Value| v.as_str()).map(|s| s.to_string()),
}
} else if let Some(_arr) = data.as_array() {
ApiAlbumDetail {
images: serde_json::from_value(data)?,
id: None,
title: None,
}
} else {
ApiAlbumDetail {
images: Vec::new(),
id: None,
title: None,
}
};
Ok(detail)
}
/// 获取图片二进制数据
/// GET /api/image/{id}
pub async fn get_image_data(&self, image_id: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let url = format!("{}/api/image/{}", self.api_base, image_id);
let resp = self.client.get(&url).send().await?;
let bytes = resp.bytes().await?;
Ok(bytes.to_vec())
}
/// 获取图片的下载 URL(不下载,只返回 URL 字符串)
pub fn get_image_url(&self, image_id: &str) -> String {
format!("{}/api/image/{}", self.api_base, image_id)
}
/// 获取多个相册的详情(批量)
pub async fn get_album_details_batch(
&self,
album_ids: &[String],
) -> Result<Vec<(String, ApiAlbumDetail)>, Box<dyn std::error::Error>> {
let mut results = Vec::new();
for aid in album_ids {
match self.get_album_detail(aid).await {
Ok(detail) => results.push((aid.clone(), detail)),
Err(e) => {
eprintln!("[Crawler] 获取相册 {} 详情失败: {}", aid, e);
}
}
}
Ok(results)
}
/// 获取所有相册详情(含图片)
pub async fn fetch_all_albums_with_images(
&self,
) -> Result<Vec<(ApiAlbum, ApiAlbumDetail)>, Box<dyn std::error::Error>> {
let albums = self.get_albums().await?;
let mut results = Vec::new();
for album in &albums {
match self.get_album_detail(&album.id).await {
Ok(detail) => results.push((album.clone(), detail)),
Err(e) => {
eprintln!("[Crawler] 获取相册 {} 详情失败: {}", album.id, e);
}
}
}
Ok(results)
}
/// 获取指定相册的图片列表(仅返回 id 列表)
pub async fn get_album_image_ids(&self, album_id: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let detail = self.get_album_detail(album_id).await?;
Ok(detail.images.into_iter().map(|img| img.id).collect())
}
}

15
src/engine/mod.rs Normal file
View File

@@ -0,0 +1,15 @@
//! 核心引擎层 — 标题解析、人名匹配、标签提取、推荐计算
pub mod title_parser;
pub mod person_merger;
pub mod tag_extractor;
pub mod recommend;
pub mod pipeline;
pub mod crawler;
pub use title_parser::*;
pub use person_merger::*;
pub use tag_extractor::*;
pub use recommend::*;
pub use pipeline::*;
pub use crawler::*;

383
src/engine/person_merger.rs Normal file
View File

@@ -0,0 +1,383 @@
//! 人名匹配与合并
//! 对应 Python 的 match_person_from_title.py + merge_persons_by_core.py
//! 所有函数均为 async,因为底层 Database 操作是异步的
use crate::db::Database;
use crate::engine::title_parser::{extract_cn, is_excluded_name};
use std::collections::{HashMap, HashSet};
/// 匹配模式
#[derive(Debug, Clone)]
pub struct Pattern {
pub text: String,
pub person_name: String,
pub source: String, // "name" | "alias"
pub length: usize,
}
/// 匹配结果
#[derive(Debug, Clone)]
pub struct MatchResult {
pub matched: bool,
pub method: String,
pub person: Option<String>,
pub matched_text: Option<String>,
pub all_persons: Vec<String>,
pub multi_person: bool,
}
/// 深度匹配结果
#[derive(Debug, Clone)]
pub struct DeepMatchResult {
pub matched: bool,
pub method: String, // "jieba_extract" | "exact_name_match" | "alias_fuzzy_match"
pub person: Option<String>,
pub matched_text: Option<String>,
pub all_persons: Vec<String>,
pub multi_person: bool,
}
// ── Pass1: 收集模式 ──
/// 收集所有人名和别名,构建匹配模式列表
/// 按长度降序排列,确保长名优先匹配
pub async fn collect_person_patterns(db: &Database) -> Vec<Pattern> {
let mut patterns = Vec::new();
// 1. 收集所有人名
let rows = db.get_all_person_names().await;
for (name, _albums) in &rows {
if is_excluded_name(name) {
continue;
}
patterns.push(Pattern {
text: name.clone(),
person_name: name.clone(),
source: "name".to_string(),
length: name.len(),
});
}
// 2. 收集所有别名
let aliases = db.get_all_aliases().await;
for (alias_name, person_name) in &aliases {
if is_excluded_name(person_name) || is_excluded_name(alias_name) {
continue;
}
if alias_name != person_name {
patterns.push(Pattern {
text: alias_name.clone(),
person_name: person_name.clone(),
source: "alias".to_string(),
length: alias_name.len(),
});
}
}
// 3. 去重
let mut best_map: HashMap<String, Pattern> = HashMap::new();
for p in patterns {
let key = p.text.to_lowercase();
best_map.entry(key).or_insert(p);
}
let mut patterns: Vec<Pattern> = best_map.into_values().collect();
// 4. 按长度降序排列
patterns.sort_by(|a, b| b.length.cmp(&a.length));
patterns
}
/// Pass1: 简单子串匹配
pub fn match_title(title: &str, patterns: &[Pattern]) -> Option<(String, String, String)> {
if title.is_empty() {
return None;
}
for p in patterns {
if p.text.len() < 2 {
continue;
}
if title.contains(&p.text) {
return Some((p.text.clone(), p.person_name.clone(), p.source.clone()));
}
}
None
}
/// Pass2: 深度匹配(多重策略)
pub fn deep_match_title(
title: &str,
known_names: &HashSet<String>,
known_aliases: &HashSet<String>,
) -> DeepMatchResult {
let mut result = DeepMatchResult {
matched: false,
method: String::new(),
person: None,
matched_text: None,
all_persons: Vec::new(),
multi_person: false,
};
if title.is_empty() {
return result;
}
let title_clean = title.trim();
// 策略1: 用正则提取人名
if let Some(extracted) = crate::engine::title_parser::extract_person_name_from_title(title_clean) {
if known_names.contains(&extracted) {
result.matched = true;
result.method = "jieba_extract".to_string();
result.person = Some(extracted.clone());
result.matched_text = Some(extracted);
return result;
}
}
// 策略2: 标题中精确子串匹配已知人名(长名优先)
let mut found: Vec<String> = Vec::new();
let mut sorted_names: Vec<&String> = known_names.iter().collect();
sorted_names.sort_by(|a, b| b.len().cmp(&a.len()));
for name in &sorted_names {
if name.len() < 2 {
continue;
}
if title_clean.contains(name.as_str()) {
found.push(name.to_string());
}
}
if !found.is_empty() {
found.sort_by(|a, b| b.len().cmp(&a.len()));
let best = found[0].clone();
result.matched = true;
result.method = "exact_name_match".to_string();
result.person = Some(best.clone());
result.matched_text = Some(best);
result.all_persons = found.clone();
result.multi_person = found.len() > 1;
return result;
}
// 策略3: 别名模糊匹配
let title_lower = title_clean.to_lowercase();
let mut sorted_aliases: Vec<&String> = known_aliases.iter().collect();
sorted_aliases.sort_by(|a, b| b.len().cmp(&a.len()));
for alias in &sorted_aliases {
if alias.len() < 2 {
continue;
}
if title_lower.contains(alias.to_lowercase().as_str()) {
result.matched = true;
result.method = "alias_fuzzy_match".to_string();
result.person = Some(alias.to_string());
result.matched_text = Some(alias.to_string());
return result;
}
}
result
}
/// 收集所有已知人名和别名
pub async fn collect_known_names(db: &Database) -> (HashSet<String>, HashSet<String>) {
let mut known_names = HashSet::new();
let mut known_aliases = HashSet::new();
for (name, _) in db.get_all_person_names().await {
known_names.insert(name);
}
for (alias_name, _) in db.get_all_aliases().await {
known_aliases.insert(alias_name);
}
(known_names, known_aliases)
}
/// 检测含多个人名的相册
pub async fn detect_multi_person_titles(
db: &Database,
known_names: Option<&HashSet<String>>,
) -> Vec<(String, String, Vec<String>)> {
let names = match known_names {
Some(n) => n.clone(),
None => collect_known_names(db).await.0,
};
let albums = db.get_all_albums_full().await;
let mut multi_person = Vec::new();
for album in &albums {
let title = album.title.as_deref().unwrap_or("");
if title.is_empty() {
continue;
}
let mut found: Vec<String> = Vec::new();
let mut sorted_names: Vec<&String> = names.iter().collect();
sorted_names.sort_by(|a, b| b.len().cmp(&a.len()));
for name in &sorted_names {
if name.len() < 2 {
continue;
}
if title.contains(name.as_str()) {
found.push(name.to_string());
}
}
if found.len() > 1 {
multi_person.push((
album.id.clone(),
title.to_string(),
found,
));
}
}
multi_person
}
// ── 中文核心合并分析 ──
/// 分析所有人名的中文核心分组
/// 返回: {core: [(id, name, albums, images), ...]}
pub async fn analyze_groups(db: &Database) -> HashMap<String, Vec<(i64, String, i64, i64)>> {
let persons = db.get_all_persons_with_counts().await;
let mut groups: HashMap<String, Vec<(i64, String, i64, i64)>> = HashMap::new();
for (pid, name, albums, images) in persons {
if let Some(cn) = extract_cn(&name) {
if cn.len() >= 2 {
groups.entry(cn).or_default().push((pid, name, albums, images));
}
}
}
for items in groups.values_mut() {
items.sort_by(|a, b| b.2.cmp(&a.2));
}
groups.retain(|_, v| v.len() >= 2);
groups
}
/// 统计匹配分析
#[derive(Debug, Clone)]
pub struct MatchStats {
pub total_albums: usize,
pub matched: usize,
pub unmatched: usize,
pub already_set: usize,
pub changed: usize,
pub by_source: HashMap<String, usize>,
pub name_frequency: HashMap<String, usize>,
}
/// 执行 Pass1 匹配分析
pub async fn analyze_matches(db: &Database, patterns: &[Pattern]) -> MatchStats {
let albums = db.get_all_albums_full().await;
let mut stats = MatchStats {
total_albums: albums.len(),
matched: 0,
unmatched: 0,
already_set: 0,
changed: 0,
by_source: HashMap::new(),
name_frequency: HashMap::new(),
};
for album in &albums {
let title = album.title.as_deref().unwrap_or("");
let current = album.person_name_original.as_deref().unwrap_or("");
if let Some((text, person, source)) = match_title(title, patterns) {
*stats.by_source.entry(source).or_insert(0) += 1;
*stats.name_frequency.entry(person.clone()).or_insert(0) += 1;
if current.is_empty() {
stats.matched += 1;
} else if current != person {
stats.changed += 1;
} else {
stats.already_set += 1;
}
} else {
stats.unmatched += 1;
}
}
stats
}
/// 深度匹配分析统计
#[derive(Debug, Clone)]
pub struct DeepMatchStats {
pub total_albums: usize,
pub pass1_matched: usize,
pub pass1_unmatched: usize,
pub pass2_jieba_match: usize,
pub pass2_exact_match: usize,
pub pass2_alias_match: usize,
pub pass2_still_unmatched: usize,
pub pass2_multi_person: usize,
}
/// 执行 Pass2 深度匹配分析
pub async fn analyze_deep_matches(db: &Database) -> DeepMatchStats {
let (known_names, known_aliases) = collect_known_names(db).await;
let albums = db.get_all_albums_full().await;
let mut stats = DeepMatchStats {
total_albums: albums.len(),
pass1_matched: 0,
pass1_unmatched: 0,
pass2_jieba_match: 0,
pass2_exact_match: 0,
pass2_alias_match: 0,
pass2_still_unmatched: 0,
pass2_multi_person: 0,
};
for album in &albums {
let title = album.title.as_deref().unwrap_or("");
let current = album.person_name_original.as_deref().unwrap_or("");
if title.is_empty() {
continue;
}
if !current.is_empty() && known_names.contains(current) {
stats.pass1_matched += 1;
continue;
}
stats.pass1_unmatched += 1;
let result = deep_match_title(title, &known_names, &known_aliases);
if !result.matched {
stats.pass2_still_unmatched += 1;
continue;
}
match result.method.as_str() {
"jieba_extract" => stats.pass2_jieba_match += 1,
"exact_name_match" => stats.pass2_exact_match += 1,
"alias_fuzzy_match" => stats.pass2_alias_match += 1,
_ => {}
}
if result.multi_person {
stats.pass2_multi_person += 1;
}
}
stats
}

81
src/engine/pipeline.rs Normal file
View File

@@ -0,0 +1,81 @@
//! 处理管线 — 批量处理相册元数据
use crate::db::Database;
use crate::engine::title_parser;
use crate::engine::tag_extractor;
use crate::engine::person_merger;
use std::collections::HashMap;
/// 处理所有相册的元数据:提取人名 + 标签
/// 两阶段:先用正则提取人名入库,再用匹配精炼,最后提取标签
pub async fn process_all_albums(db: &Database) -> (usize, usize) {
let albums = db.get_all_albums_full().await;
let mut person_count = 0;
let mut tag_count = 0;
// Phase 1: 先用正则从标题直接提取人名(冷启动,不需要已有 persons)
let patterns = person_merger::collect_person_patterns(db).await;
let use_patterns = !patterns.is_empty();
for album in &albums {
let title = album.title.as_deref().unwrap_or("");
if title.is_empty() { continue; }
// 提取人名
if album.person_name_original.is_none() || album.person_name_original.as_deref() == Some("") {
let person_name = if use_patterns {
// 已有人物库,用匹配
person_merger::match_title(title, &patterns).map(|(_, name, _)| name)
} else {
// 冷启动,直接用正则提取
title_parser::extract_person_name_from_title(title)
};
if let Some(person_name) = person_name {
let (cleaned, _aliases) = title_parser::clean_person_name(&person_name);
let final_name = if cleaned.is_empty() { person_name.clone() } else { cleaned };
db.update_album_person(&album.id, &person_name, &final_name).await;
// 创建人员
let pid = db.insert_person(&final_name, None).await;
db.insert_person_album(pid, &album.id).await;
db.update_person_counts(pid).await;
person_count += 1;
}
}
}
// Phase 2: 提取标签(使用已填充的人名来排除)
// 重新获取 albums(因为 person_name_cleaned 已更新)
let albums_refreshed = db.get_all_albums_full().await;
let mut seen_albums: HashMap<String, String> = HashMap::new();
for album in &albums {
if let Some(pn) = &album.person_name_cleaned {
seen_albums.insert(album.id.clone(), pn.clone());
}
}
for album in &albums_refreshed {
let title = album.title.as_deref().unwrap_or("");
if title.is_empty() { continue; }
let existing_tags = album.tags.as_deref().unwrap_or("");
if existing_tags.is_empty() || existing_tags == "[]" {
let person = album.person_name_cleaned.as_deref()
.or_else(|| seen_albums.get(&album.id).map(|s| s.as_str()));
let tags = tag_extractor::extract_tags_from_title(title, person);
let unique = tag_extractor::unique_tags(tags);
if !unique.is_empty() {
let tag_names: Vec<String> = unique.iter().map(|(t, _)| t.clone()).collect();
let tags_json = serde_json::to_string(&tag_names).unwrap_or_else(|_| "[]".to_string());
let all_words: Vec<[String; 2]> = unique.iter().map(|(t, tp)| [t.clone(), tp.clone()]).collect();
let words_json = serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string());
db.update_album_tags(&album.id, &tags_json, &words_json).await;
tag_count += 1;
}
}
}
println!("[管线] 处理完成:{} 个人名,{} 个标签", person_count, tag_count);
(person_count, tag_count)
}

203
src/engine/recommend.rs Normal file
View File

@@ -0,0 +1,203 @@
//! 推荐引擎 — 标签推荐、人物推荐、关联推荐
//!
//! 推荐策略:
//! 1. 标签推荐:基于输入前缀匹配 + 热门度排序
//! 2. 人物推荐:基于名称匹配 + 相册数排序
//! 3. 关联推荐:基于标签共现关系 + 人物共现关系
use crate::db::Database;
use crate::models::*;
use sqlx::Row;
/// 推荐引擎
pub struct RecommendEngine;
impl RecommendEngine {
/// 标签自动补全推荐
pub async fn recommend_tags(
db: &Database,
query: &str,
limit: usize,
) -> RecommendResponse {
let limit = limit.min(20);
let q = query.trim().to_lowercase();
let tags = db.get_popular_tags(200).await;
let mut items: Vec<RecommendItem> = Vec::new();
for t in &tags {
let tag_lower = t.tag.to_lowercase();
let score = if q.is_empty() {
// 无查询时按热门度
t.count as f64
} else if tag_lower == q {
// 精确匹配
1000.0 + t.count as f64
} else if tag_lower.starts_with(&q) {
// 前缀匹配
500.0 + t.count as f64
} else if tag_lower.contains(&q) {
// 包含匹配
100.0 + t.count as f64
} else {
continue;
};
items.push(RecommendItem {
value: t.tag.clone(),
label: format!("{} ({})", t.tag, t.count),
score,
count: Some(t.count),
source: "tag".to_string(),
});
}
items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
let total = items.len();
items.truncate(limit);
RecommendResponse { items, total, category: "tags".to_string() }
}
/// 人物推荐
pub async fn recommend_persons(
db: &Database,
query: &str,
limit: usize,
) -> RecommendResponse {
let limit = limit.min(20);
let q = query.trim().to_lowercase();
let persons = db.get_all_persons_with_aliases().await;
let mut items: Vec<RecommendItem> = Vec::new();
for p in &persons {
let name_lower = p.name.to_lowercase();
let score = if q.is_empty() {
p.total_albums as f64
} else if name_lower == q {
1000.0 + p.total_albums as f64
} else if name_lower.starts_with(&q) {
500.0 + p.total_albums as f64
} else if name_lower.contains(&q) {
100.0 + p.total_albums as f64
} else {
// 检查别名
let alias_match = p.aliases.iter().any(|a| a.to_lowercase().contains(&q));
if alias_match { 200.0 + p.total_albums as f64 } else { continue }
};
let label = if p.aliases.is_empty() {
format!("{} ({}相册)", p.name, p.total_albums)
} else {
format!("{} [{}] ({}相册)", p.name, p.aliases.join(", "), p.total_albums)
};
items.push(RecommendItem {
value: p.name.clone(),
label,
score,
count: Some(p.total_albums),
source: "person".to_string(),
});
}
items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
let total = items.len();
items.truncate(limit);
RecommendResponse { items, total, category: "persons".to_string() }
}
/// 关联推荐(共现标签 + 共现人物)
pub async fn recommend_related(
db: &Database,
context: &str, // 标签或人名
limit: usize,
) -> TagRecommendation {
let limit = limit.min(15);
// 获取共现标签
let co_tags = db.get_tag_co_occurrences(context, limit as i64).await;
let related_tags: Vec<RecommendItem> = co_tags.iter().map(|c| {
RecommendItem {
value: c.co_tag.clone(),
label: format!("{} (共现{}次)", c.co_tag, c.count),
score: c.score,
count: Some(c.count),
source: "co-occurrence".to_string(),
}
}).collect();
// 获取相关人物(通过标签)
let albums = db.search_albums_by_tag(context).await;
let mut person_scores: std::collections::HashMap<String, (i64, f64)> = std::collections::HashMap::new();
let total = albums.len() as f64;
for album in &albums {
let meta = db.get_all_albums_full().await;
for m in &meta {
if m.id == album.id.to_string() {
if let Some(ref name) = m.person_name_cleaned {
let entry = person_scores.entry(name.clone()).or_insert((0, 0.0));
entry.0 += 1;
entry.1 = entry.0 as f64 / total;
}
}
}
}
let mut similar_persons: Vec<RecommendItem> = person_scores.into_iter()
.filter(|(_, (cnt, _))| *cnt >= 1)
.map(|(name, (cnt, score))| RecommendItem {
value: name.clone(),
label: format!("{} ({}次)", name, cnt),
score,
count: Some(cnt),
source: "co-occurrence".to_string(),
}).collect();
similar_persons.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
similar_persons.truncate(limit);
TagRecommendation {
tag: context.to_string(),
related_tags,
similar_persons,
}
}
/// 热门人物
pub async fn popular_persons(db: &Database, limit: usize) -> Vec<PopularPerson> {
let persons = db.get_all_persons_with_aliases().await;
let mut results: Vec<PopularPerson> = persons.iter().take(limit).map(|p| {
PopularPerson {
name: p.name.clone(),
display_name: p.display_name.clone(),
total_albums: p.total_albums,
total_images: p.total_images,
top_tags: Vec::new(),
aliases: p.aliases.clone(),
}
}).collect();
// 为热门人物获取 top tags
// (简化:从 person_albums 查标签)
for rp in &mut results {
if let Some(person) = db.get_person_by_name(&rp.name).await {
let albums = db.get_person_albums(person.id).await;
let mut tag_counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
for album in &albums {
let aid = album.id.to_string();
let rows = sqlx::query("SELECT tag FROM album_tags WHERE album_id = ?")
.bind(&aid).fetch_all(&db.pool).await.unwrap_or_default();
for row in &rows {
*tag_counts.entry(row.get::<String, _>("tag")).or_insert(0) += 1;
}
}
let mut tags: Vec<(String, i64)> = tag_counts.into_iter().collect();
tags.sort_by(|a, b| b.1.cmp(&a.1));
rp.top_tags = tags.into_iter().take(5).map(|(t, _)| t).collect();
}
}
results
}
}

View File

@@ -0,0 +1,81 @@
//! 标签提取器 — 从标题中提取标签词
use regex::Regex;
use lazy_static::lazy_static;
use std::collections::HashSet;
lazy_static! {
static ref WORD_RE: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap();
static ref HAS_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
}
/// 停用词
const STOP_WORDS: &[&str] = &[
"photos", "photo", "pictures", "picture", "images", "image", "video", "videos",
"cosplay", "cos", "and", "the", "of", "in", "a", "an", "is", "to", "for",
"part", "vol", "collection", "special", "extra", "bonus",
"new", "old", "selfie", "official", "original", "preview", "patreon",
"秀人", "网", "写真", "图", "套", "合集", "系列", "作品",
];
/// 从标题中提取标签
pub fn extract_tags_from_title(
title: &str,
person_name: Option<&str>,
) -> Vec<(String, String)> {
let mut tags = Vec::new();
let mut title_lower = title.to_lowercase();
// 移除人名
if let Some(name) = person_name {
title_lower = title_lower.replace(&name.to_lowercase(), " ");
}
let stop_words: HashSet<&str> = STOP_WORDS.iter().copied().collect();
let person_name_lower = person_name.map(|n| n.to_lowercase());
for cap in WORD_RE.find_iter(&title_lower) {
let word = cap.as_str().trim();
if word.len() < 2 { continue; }
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
if stop_words.contains(&word.to_lowercase().as_str()) { continue; }
if let Some(ref pn) = person_name_lower {
if word.to_lowercase().contains(pn.as_str()) { continue; }
}
let tag_type = if HAS_CHINESE.is_match(word) { "chinese" } else { "english" };
tags.push((word.to_string(), tag_type.to_string()));
}
tags
}
/// 标签去重并排序
pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for (tag, tag_type) in tags {
let key = tag.to_lowercase();
if seen.insert(key) {
result.push((tag, tag_type));
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_tags() {
let tags = extract_tags_from_title(
"XiuRen秀人网 – 杨晨晨Yome – Night Dress '81 photos'",
Some("杨晨晨Yome"),
);
let names: Vec<&str> = tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(names.contains(&"Night"));
assert!(names.contains(&"Dress"));
assert!(!names.contains(&"photos"));
}
}

285
src/engine/title_parser.rs Normal file
View File

@@ -0,0 +1,285 @@
//! 标题解析、人名提取、标签提取
//! 对应 Python 的 tag_index.py + clean_person_names.py 的核心逻辑
use regex::Regex;
use std::collections::HashSet;
use lazy_static::lazy_static;
// ── 常量 ──
/// 模特称呼前缀(移除用)
const MODEL_TITLE_PREFIX: &[&str] = &[
"美女模特", "性感女神", "清纯美女", "气质美女", "可爱女孩",
"甜美少女", "时尚达人", "女神", "美女", "模特", "小姐姐",
"小仙女", "御姐", "萝莉", "辣妹", "潮人",
];
/// 停用词(标签过滤用)
const STOP_WORDS: &[&str] = &[
"写真", "套图", "图片", "照片", "摄影", "拍摄", "风格",
"系列", "作品", "专辑", "主题", "人像", "艺术", "唯美",
"诱惑", "性感", "迷人", "魅力", "时尚", "潮流", "高清",
"原版", "无水印", "私房", "内衣", "比基尼", "泳装",
"旗袍", "制服", "丝袜", "美腿", "美胸", "翘臀",
"cosplay", "cos", "角色扮演", "动漫", "游戏",
"classic", "exclusive", "premium", "collection",
"photo", "photos", "video", "videos", "set", "vol",
"no", "番号", "编号", "期号",
];
/// 保留词性(仅用于 jieba 分词场景,这里做占位符)
const _KEEP_POS: &[&str] = &["n", "nr", "ns", "nz", "v", "vn", "a", "an", "eng", "x"];
/// 工作室/平台排除名
const EXCLUDED_NAMES: &[&str] = &[
"Pure Media", "XiuRen秀人网", "尤蜜", "魅妍社", "花漾",
"瑞丝馆", "撸图", "情欲萝莉", "蜜桃社", "tgod",
"MiStar", "BLUECAKE", "HuaYang", "YouMi", "秀人",
];
lazy_static! {
/// 编译后的正则
static ref RE_COSPLAY: Regex = Regex::new(
r"(?i)^(.+?)\s+cos(play)?\s+(.+?)(?:\s*[–—\-]\s*(.+))?$"
).unwrap();
static ref RE_COSPLAY_BY: Regex = Regex::new(
r"(?i)^(.+?)\s+cos(play)?\s+by\s+(.+?)(?:\s*[–—\-]\s*(.+))?$"
).unwrap();
static ref RE_BRACKET_PREFIX: Regex = Regex::new(r"^\[([^\]]+)\]\s*").unwrap();
static ref RE_NUMBER_SUFFIX: Regex = Regex::new(r"[\s#]*(?:no\.?\s*|vol\.?\s*|#)?\d+[\s]*$").unwrap();
static ref RE_PAREN_CONTENT: Regex = Regex::new(r"[((]([^))]*)[))]").unwrap();
static ref RE_SPLIT_DASH: Regex = Regex::new(r"\s*[–—\-]\s*").unwrap();
static ref RE_PHOTOS_SUFFIX: Regex = Regex::new(r"(?i)\s*\d+\s*photos?\s*(?:and\s*\d+\s*videos?)?\s*$").unwrap();
static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
static ref RE_ENGLISH_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap();
static ref RE_TRAILING_PUNCT: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap();
/// 停用词集合
static ref STOP_WORDS_SET: HashSet<&'static str> = {
let mut s = HashSet::new();
for w in STOP_WORDS { s.insert(*w); }
s
};
/// 排除名集合(小写)
static ref EXCLUDED_LOWER: HashSet<String> = {
let mut s = HashSet::new();
for n in EXCLUDED_NAMES { s.insert(n.to_lowercase()); }
s.extend(["youmi", "huayang", "bluecake", "mistar", "xiuren", "秀人"].map(|s| s.to_string()));
s
};
}
// ── 人名提取 ──
/// 从标题中提取模特/人物名称
/// 对应 Python tag_index.extract_person_name_from_title
pub fn extract_person_name_from_title(title: &str) -> Option<String> {
let title = title.trim();
if title.is_empty() {
return None;
}
// 清理末尾的 "N photos" 等
let title = RE_PHOTOS_SUFFIX.replace(title, "").trim().to_string();
// 1. cosplay 格式: "Name cosplay Character – Series"
if let Some(caps) = RE_COSPLAY_BY.captures(&title) {
return Some(caps[3].trim().to_string());
}
if let Some(caps) = RE_COSPLAY.captures(&title) {
return Some(caps[1].trim().to_string());
}
// 2. 中文摄影工作室格式
let title = RE_BRACKET_PREFIX.replace(&title, "").trim().to_string();
// 移除模特称呼前缀
let mut title = title;
for prefix in MODEL_TITLE_PREFIX {
if title.starts_with(prefix) {
title = title[prefix.len()..].trim().to_string();
break;
}
}
// 移除编号后缀
let title = RE_NUMBER_SUFFIX.replace(&title, "").trim().to_string();
// 用 `–—\-` 分割,取第一部分作为人名
let parts: Vec<&str> = RE_SPLIT_DASH.split(&title).collect();
let name = parts[0].trim().to_string();
if name.len() >= 2 {
Some(name)
} else {
None
}
}
/// 提取中文核心(开头的连续中文字符)
/// 对应 Python merge_persons_by_core.extract_cn
pub fn extract_cn(name: &str) -> Option<String> {
let re = Regex::new(r"^([\u{4e00}-\u{9fff}]+)").unwrap();
re.captures(name).map(|c| c[1].to_string())
}
/// 判断后缀是否有效(不含中文=有效英文后缀)
/// 对应 Python merge_persons_by_core.is_valid_suffix
pub fn is_valid_suffix(suffix: &str) -> bool {
if suffix.is_empty() {
return true;
}
!RE_CHINESE.is_match(suffix)
}
/// 判断是否为工作室/平台名(需排除)
pub fn is_excluded_name(name: &str) -> bool {
EXCLUDED_LOWER.contains(&name.to_lowercase())
}
// ── 人名清洗 ──
/// 清洗人名:移除前缀、括号内容、末尾标点
/// 返回 (cleaned_name, aliases)
/// 对应 Python clean_person_names.clean_person_name
pub fn clean_person_name(raw_name: &str) -> (String, Vec<(String, String)>) {
let mut aliases = Vec::new();
let mut name = raw_name.to_string();
// 1. 移除前缀
for prefix in MODEL_TITLE_PREFIX {
if name.starts_with(prefix) {
aliases.push(("prefix".to_string(), prefix.to_string()));
name = name[prefix.len()..].trim().to_string();
break;
}
}
// 2. 移除末尾括号内容
let mut cleaned = String::new();
let mut last_end = 0;
for cap in RE_PAREN_CONTENT.captures_iter(&name) {
let m = cap.get(0).unwrap();
let content = cap.get(1).map_or("", |c| c.as_str());
cleaned.push_str(&name[last_end..m.start()]);
last_end = m.end();
if !content.is_empty() {
if RE_CHINESE.is_match(content) {
aliases.push(("nickname".to_string(), content.to_string()));
} else {
aliases.push(("english".to_string(), content.to_string()));
}
}
}
cleaned.push_str(&name[last_end..]);
let name = cleaned.trim().to_string();
// 3. 清理末尾标点
let name = RE_TRAILING_PUNCT.replace(&name, "").trim().to_string();
// 4. 从清洗后名字提取英文名别名
if let Some(caps) = RE_ENGLISH_SUFFIX.captures(&name) {
let chinese_part = caps[1].to_string();
let english_part = caps[2].to_string();
if !english_part.is_empty() {
aliases.push(("chinese".to_string(), chinese_part));
aliases.push(("english".to_string(), english_part));
}
}
if name.is_empty() {
(raw_name.to_string(), aliases)
} else {
(name, aliases)
}
}
/// 获取归并键(小写化+移除空格和标点)
pub fn get_person_key(name: &str) -> String {
name.to_lowercase()
.chars()
.filter(|c| !c.is_whitespace() && !c.is_ascii_punctuation())
.collect()
}
// ── 标签提取 ──
/// 从标题中提取标签(排除人名部分)
/// 返回 Vec<(tag, pos)>
/// 对应 Python tag_index.extract_tags_from_title
#[allow(dead_code)]
pub fn extract_tags_from_title_legacy(title: &str, person_name: Option<&str>) -> Vec<(String, String)> {
let mut title = title.to_string();
// 清理末尾的 "N photos" 等
title = RE_PHOTOS_SUFFIX.replace(&title, "").to_string();
// 移除人名
if let Some(name) = person_name {
title = title.replace(name, " ");
for part in name.split_whitespace() {
title = title.replace(part, " ");
}
}
// 移除 [工作室] 前缀
title = RE_BRACKET_PREFIX.replace(&title, "").to_string();
// 按空白分割,过滤停用词和短词
let mut tags = Vec::new();
let mut seen = HashSet::new();
for word in title.split_whitespace() {
let word = word.trim().to_lowercase();
if word.len() < 2 {
continue;
}
if STOP_WORDS_SET.contains(word.as_str()) {
continue;
}
if word.chars().all(|c| c.is_ascii_digit() || c == '.') {
continue;
}
if seen.contains(&word) {
continue;
}
seen.insert(word.clone());
tags.push((word, "x".to_string()));
}
tags
}
/// 标签归一化
pub fn normalize_tag(tag: &str) -> String {
tag.trim().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_person_name() {
assert_eq!(extract_person_name_from_title("杨晨晨Yome 私房写真").unwrap(), "杨晨晨Yome");
assert_eq!(extract_person_name_from_title("王馨瑶yanni 比基尼").unwrap(), "王馨瑶yanni");
assert_eq!(extract_person_name_from_title("[XiuRen] 杨晨晨 性感").unwrap(), "杨晨晨");
assert_eq!(extract_person_name_from_title("美女模特 杨晨晨 私房").unwrap(), "杨晨晨");
}
#[test]
fn test_extract_cn() {
assert_eq!(extract_cn("杨晨晨Yome").unwrap(), "杨晨晨");
assert_eq!(extract_cn("王馨瑶yanni").unwrap(), "王馨瑶");
assert!(extract_cn("Yome").is_none());
}
#[test]
fn test_clean_person_name() {
let (cleaned, aliases) = clean_person_name("美女模特 杨晨晨Yome");
assert_eq!(cleaned, "美女模特 杨晨晨Yome");
assert!(!aliases.is_empty());
}
}

62
src/models/album.rs Normal file
View File

@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Album {
pub id: Uuid,
pub title: String,
pub description: Option<String>,
pub cover_image_id: Option<Uuid>,
pub image_count: i64,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub person_name_cleaned: Option<String>,
#[serde(default)]
pub tags: Option<String>, // JSON: [tag, ...]
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AlbumInfo {
pub id: Uuid,
pub title: String,
pub document: String,
pub description: Option<String>,
pub cover_image_id: Option<String>,
pub image_count: i64,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AlbumWithImages {
pub album: Album,
pub images: Vec<super::image::Image>,
pub image_count: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateAlbum {
pub title: String,
pub document: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AlbumIndex {
pub title: String,
pub images: Vec<String>,
}
/// 相册完整元数据(含扩展字段)
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AlbumMeta {
pub id: String,
pub title: Option<String>,
pub person_name_original: Option<String>,
pub person_name_cleaned: Option<String>,
pub tags: Option<String>,
pub all_words: Option<String>,
pub raw_title: Option<String>,
pub processed_at: Option<String>,
pub image_count: Option<i64>,
}

30
src/models/favorite.rs Normal file
View File

@@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
pub struct Favorite {
pub id: Uuid,
pub user_id: String,
pub image_id: Uuid,
pub album_id: Uuid,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateFavorite {
pub image_id: Uuid,
pub album_id: Uuid,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FavoriteWithImage {
pub favorite: Favorite,
pub image: super::image::Image,
pub album_title: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FavoriteStatus {
pub is_favorited: bool,
pub favorite_id: Option<Uuid>,
}

30
src/models/image.rs Normal file
View File

@@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Image {
pub id: Uuid,
pub album_id: Uuid,
pub filename: String,
pub original_url: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub file_size: i64,
pub width: i32,
pub height: i32,
pub mime_type: String,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CreateImage {
pub album_id: Uuid,
pub filename: String,
pub original_url: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub file_size: i64,
pub width: i32,
pub height: i32,
pub mime_type: String,
}

13
src/models/mod.rs Normal file
View File

@@ -0,0 +1,13 @@
pub mod album;
pub mod image;
pub mod person;
pub mod tag;
pub mod favorite;
pub mod recommend;
pub use album::*;
pub use image::*;
pub use person::*;
pub use tag::*;
pub use favorite::*;
pub use recommend::*;

53
src/models/person.rs Normal file
View File

@@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
use super::album::AlbumInfo;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Person {
pub id: i64,
pub name: String,
pub display_name: Option<String>,
pub total_albums: i64,
pub total_images: i64,
pub original_names: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PersonAlias {
pub id: i64,
pub person_id: i64,
pub alias_type: String,
pub alias_name: String,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PersonWithAlbums {
pub person: Person,
pub albums: Vec<AlbumInfo>,
pub album_count: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PersonWithCounts {
pub id: i64,
pub name: String,
pub display_name: Option<String>,
pub total_albums: i64,
pub total_images: i64,
pub aliases: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PersonListResponse {
pub persons: Vec<PersonWithCounts>,
pub total: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchPersonResponse {
pub person: Option<PersonWithCounts>,
pub albums: Vec<AlbumInfo>,
pub total_albums: usize,
}

46
src/models/recommend.rs Normal file
View File

@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
/// 推荐请求
#[derive(Debug, Deserialize)]
pub struct RecommendQuery {
pub q: Option<String>,
pub limit: Option<usize>,
pub context: Option<String>, // 上下文标签(用于共现推荐)
}
/// 推荐条目(通用)
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RecommendItem {
pub value: String,
pub label: String,
pub score: f64,
pub count: Option<i64>,
pub source: String, // "tag" | "person" | "co-occurrence" | "popular"
}
/// 推荐响应
#[derive(Debug, Serialize, Deserialize)]
pub struct RecommendResponse {
pub items: Vec<RecommendItem>,
pub total: usize,
pub category: String, // "tags" | "persons" | "related"
}
/// 标签推荐(带上下文)
#[derive(Debug, Serialize, Deserialize)]
pub struct TagRecommendation {
pub tag: String,
pub related_tags: Vec<RecommendItem>,
pub similar_persons: Vec<RecommendItem>,
}
/// 热门人物
#[derive(Debug, Serialize, Deserialize)]
pub struct PopularPerson {
pub name: String,
pub display_name: Option<String>,
pub total_albums: i64,
pub total_images: i64,
pub top_tags: Vec<String>,
pub aliases: Vec<String>,
}

35
src/models/tag.rs Normal file
View File

@@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};
/// 标签统计
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TagStat {
pub tag: String,
pub count: i64,
pub album_count: i64,
}
/// 标签列表响应
#[derive(Debug, Serialize, Deserialize)]
pub struct TagListResponse {
pub tags: Vec<TagStat>,
pub total: usize,
}
/// 元数据统计
#[derive(Debug, Serialize, Deserialize)]
pub struct MetadataStats {
pub total_persons: i64,
pub total_aliases: i64,
pub total_albums_with_person: i64,
pub total_albums_without_person: i64,
pub total_tags: i64,
}
/// 标签共现关系
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TagCoOccurrence {
pub tag: String,
pub co_tag: String,
pub count: i64,
pub score: f64,
}

65
src/routes/album.rs Normal file
View File

@@ -0,0 +1,65 @@
use crate::cache::AlbumsCache;
use crate::db::Database;
use crate::models::*;
use actix_web::{HttpResponse, Responder, web};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
pub async fn get_albums(cache: web::Data<Arc<RwLock<AlbumsCache>>>) -> impl Responder {
let c = cache.read().await;
HttpResponse::Ok().json(&c.albums)
}
pub async fn search_albums(
cache: web::Data<Arc<RwLock<AlbumsCache>>>,
query: web::Query<HashMap<String, String>>,
) -> impl Responder {
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
if q.len() > 100 { return HttpResponse::BadRequest().json(serde_json::json!({"error": "搜索查询过长"})); }
if q.chars().any(|c| c.is_control() || c == '\0') { return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效字符"})); }
let page: usize = query.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(200);
let offset = (page - 1) * limit;
let c = cache.read().await;
let results = if q.is_empty() {
let total = c.albums.len();
let start = offset.min(total);
let end = (offset + limit).min(total);
c.albums[start..end].to_vec()
} else {
let mut results = c.search(q);
let total = results.len();
let start = offset.min(total);
let end = (offset + limit).min(total);
if start < end { results.drain(start..end).collect() } else { Vec::new() }
};
HttpResponse::Ok().json(results)
}
pub async fn get_random_album(cache: web::Data<Arc<RwLock<AlbumsCache>>>) -> impl Responder {
let c = cache.read().await;
if c.albums.is_empty() { return HttpResponse::NotFound().json(serde_json::json!({"error": "没有相册"})); }
let idx = rand::random::<usize>() % c.albums.len();
HttpResponse::Ok().json(&c.albums[idx])
}
pub async fn get_album_detail(db: web::Data<Arc<Database>>, path: web::Path<String>) -> impl Responder {
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
match db.get_album_with_images(id).await {
Ok(Some(album)) => HttpResponse::Ok().json(album),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "相册不存在"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
}
}
pub async fn create_album(db: web::Data<Arc<Database>>, body: web::Json<CreateAlbum>) -> impl Responder {
match db.create_album(body.into_inner()).await {
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
}
}

45
src/routes/favorite.rs Normal file
View File

@@ -0,0 +1,45 @@
use crate::db::Database;
use crate::models::*;
use actix_web::{HttpResponse, Responder, web};
use std::sync::Arc;
use uuid::Uuid;
static USER_ID: &str = "default_user";
pub async fn get_favorites(db: web::Data<Arc<Database>>) -> impl Responder {
match db.get_user_favorites(USER_ID).await {
Ok(favs) => HttpResponse::Ok().json(favs),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
}
}
pub async fn check_favorite_status(db: web::Data<Arc<Database>>, path: web::Path<String>) -> impl Responder {
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
match db.get_favorite_status(USER_ID, id).await {
Ok(s) => HttpResponse::Ok().json(s),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
}
}
pub async fn add_favorite(db: web::Data<Arc<Database>>, body: web::Json<CreateFavorite>) -> impl Responder {
match db.add_favorite(USER_ID, body.into_inner()).await {
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
}
}
pub async fn remove_favorite(db: web::Data<Arc<Database>>, path: web::Path<String>) -> impl Responder {
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
match db.remove_favorite(id).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "已取消"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
}
}
pub async fn remove_favorite_by_image(db: web::Data<Arc<Database>>, path: web::Path<String>) -> impl Responder {
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
match db.remove_favorite_by_image(USER_ID, id).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "已取消"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
}
}

29
src/routes/image.rs Normal file
View File

@@ -0,0 +1,29 @@
use crate::db::Database;
use crate::image_processor::ImageProcessor;
use actix_web::{HttpResponse, Responder, web};
use std::fs;
use std::sync::Arc;
use uuid::Uuid;
pub async fn get_image(
db: web::Data<Arc<Database>>,
ip: web::Data<Arc<ImageProcessor>>,
path: web::Path<String>,
) -> impl Responder {
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
match db.get_image(id).await {
Ok(Some(img)) => {
let fp = ip.config.images_dir.join(&img.filename);
if fp.exists() {
match fs::read(&fp) {
Ok(data) => HttpResponse::Ok().content_type(img.mime_type)
.insert_header(("Cache-Control", "public, max-age=31536000"))
.insert_header(("ETag", format!("\"{}\"", id))).body(data),
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "读取失败"})),
}
} else { HttpResponse::NotFound().json(serde_json::json!({"error": "文件不存在"})) }
}
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "图片不存在"})),
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "查询失败"})),
}
}

48
src/routes/migration.rs Normal file
View File

@@ -0,0 +1,48 @@
use crate::db::Database;
use crate::db::MigrationStats;
use actix_web::{HttpResponse, Responder, web};
use std::collections::HashMap;
use std::sync::Arc;
/// GET /api/migration/stats — 查看迁移状态
pub async fn migration_stats(db: web::Data<Arc<Database>>) -> impl Responder {
let persons = db.get_all_persons_with_counts().await.len();
let aliases = db.get_all_aliases().await.len();
let albums = db.get_all_albums().await.unwrap_or_default().len();
HttpResponse::Ok().json(serde_json::json!({
"persons": persons,
"aliases": aliases,
"albums": albums,
"message": if persons == 0 { "数据库为空,建议执行迁移" } else { "数据已就绪" }
}))
}
/// POST /api/migration/run — 执行从老数据库迁移
pub async fn migration_run(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
let old_db_path = match query.get("path") {
Some(p) if !p.is_empty() => p.clone(),
_ => return HttpResponse::BadRequest().json(serde_json::json!({
"error": "请提供老数据库路径: ?path=persons.db"
})),
};
match db.migrate_from_old_db(&old_db_path).await {
Ok(stats) => HttpResponse::Ok().json(serde_json::json!({
"success": true,
"summary": db.migration_summary(&stats),
"stats": {
"persons_imported": stats.persons_imported,
"persons_skipped": stats.persons_skipped,
"aliases_imported": stats.aliases_imported,
"albums_updated": stats.albums_updated,
"albums_missing": stats.albums_missing,
"tags_imported": stats.tags_imported,
"person_albums_created": stats.person_albums_created,
}
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("迁移失败: {}", e)
})),
}
}

21
src/routes/mod.rs Normal file
View File

@@ -0,0 +1,21 @@
//! API 路由层 — 按领域拆分
pub mod album;
pub mod image;
pub mod favorite;
pub mod person;
pub mod tag;
pub mod recommend;
pub mod queue;
pub mod migration;
pub mod pipeline;
pub use album::*;
pub use image::*;
pub use favorite::*;
pub use person::*;
pub use tag::*;
pub use recommend::*;
pub use queue::*;
pub use migration::*;
pub use pipeline::*;

36
src/routes/person.rs Normal file
View File

@@ -0,0 +1,36 @@
use crate::db::Database;
use crate::models::*;
use actix_web::{HttpResponse, Responder, web};
use std::collections::HashMap;
use std::sync::Arc;
pub async fn get_people(db: web::Data<Arc<Database>>) -> impl Responder {
let persons = db.get_all_persons_with_aliases().await;
HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons })
}
pub async fn search_people(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
if q.is_empty() {
let persons = db.get_all_persons_with_aliases().await;
return HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons });
}
let persons = db.search_persons(q).await;
HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons })
}
pub async fn get_person_detail(db: web::Data<Arc<Database>>, path: web::Path<String>) -> impl Responder {
let name = path.into_inner();
match db.get_person_by_name(&name).await {
Some(p) => {
let albums = db.get_person_albums(p.id).await;
HttpResponse::Ok().json(PersonWithAlbums { album_count: albums.len(), person: p, albums })
}
None => HttpResponse::NotFound().json(serde_json::json!({"error": format!("人员 '{}' 不存在", name)})),
}
}
pub async fn get_person_aliases(db: web::Data<Arc<Database>>, path: web::Path<i64>) -> impl Responder {
let aliases = db.get_person_aliases(path.into_inner()).await;
HttpResponse::Ok().json(aliases)
}

15
src/routes/pipeline.rs Normal file
View File

@@ -0,0 +1,15 @@
use crate::db::Database;
use crate::engine::pipeline;
use actix_web::{HttpResponse, Responder, web};
use std::sync::Arc;
/// POST /api/pipeline/run — 处理所有相册元数据(提取人名+标签)
pub async fn pipeline_run(db: web::Data<Arc<Database>>) -> impl Responder {
let (persons, tags) = pipeline::process_all_albums(db.get_ref()).await;
HttpResponse::Ok().json(serde_json::json!({
"success": true,
"persons_updated": persons,
"tags_updated": tags,
"message": format!("处理完成:{} 个人名,{} 个标签", persons, tags)
}))
}

8
src/routes/queue.rs Normal file
View File

@@ -0,0 +1,8 @@
use crate::queue::TaskQueue;
use actix_web::{HttpResponse, Responder, web};
use std::sync::Arc;
pub async fn get_queue_stats(q: web::Data<Arc<TaskQueue>>) -> impl Responder {
q.record_http_request().await;
HttpResponse::Ok().json(q.get_stats().await)
}

36
src/routes/recommend.rs Normal file
View File

@@ -0,0 +1,36 @@
use crate::db::Database;
use crate::engine::RecommendEngine;
use crate::models::*;
use actix_web::{HttpResponse, Responder, web};
use std::collections::HashMap;
use std::sync::Arc;
/// 标签推荐(自动补全)
pub async fn recommend_tags(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(10);
HttpResponse::Ok().json(RecommendEngine::recommend_tags(&db, q, limit).await)
}
/// 人物推荐(自动补全)
pub async fn recommend_persons(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(10);
HttpResponse::Ok().json(RecommendEngine::recommend_persons(&db, q, limit).await)
}
/// 关联推荐(标签共现 + 共现人物)
pub async fn recommend_related(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
let context = match query.get("context") {
Some(c) if !c.is_empty() => c,
_ => return HttpResponse::BadRequest().json(serde_json::json!({"error": "请提供context参数"})),
};
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(15);
HttpResponse::Ok().json(RecommendEngine::recommend_related(&db, context, limit).await)
}
/// 热门人物
pub async fn popular_persons(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(20).min(50);
HttpResponse::Ok().json(RecommendEngine::popular_persons(&db, limit).await)
}

24
src/routes/tag.rs Normal file
View File

@@ -0,0 +1,24 @@
use crate::db::Database;
use crate::models::*;
use actix_web::{HttpResponse, Responder, web};
use std::collections::HashMap;
use std::sync::Arc;
pub async fn get_metadata_stats(db: web::Data<Arc<Database>>) -> impl Responder {
HttpResponse::Ok().json(db.get_metadata_stats().await)
}
pub async fn search_by_tag(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
let tag = match query.get("tag") {
Some(t) if !t.is_empty() => t,
_ => return HttpResponse::BadRequest().json(serde_json::json!({"error": "请提供tag参数"})),
};
let albums = db.search_albums_by_tag(tag).await;
HttpResponse::Ok().json(albums)
}
pub async fn get_popular_tags(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
let limit: i64 = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50);
let tags = db.get_popular_tags(limit).await;
HttpResponse::Ok().json(TagListResponse { total: tags.len(), tags })
}

405
src/services/import.rs Normal file
View File

@@ -0,0 +1,405 @@
use std::fs;
use std::path::Path;
use std::sync::Arc;
use ::image::GenericImageView;
use tokio::task;
use uuid::Uuid;
use crate::config::Config;
use crate::db::Database;
use crate::models::*;
/// 批量插入图片并设置封面的辅助函数
async fn batch_insert_images_and_set_cover(
db: &Database,
images_to_insert: Vec<CreateImage>,
album_id: Uuid,
_album_path: &Path,
_folder_name: &str,
) -> Result<usize, Box<dyn std::error::Error>> {
if images_to_insert.is_empty() {
return Ok(0);
}
println!(" 批量插入 {} 张图片...", images_to_insert.len());
match db.add_images(images_to_insert).await {
Ok(ids) => {
println!(" 批量插入成功,共插入 {} 张图片", ids.len());
if let Some(first_image_id) = ids.first() {
println!(" 设置第一张图片为封面图片 (ID: {})...", first_image_id);
if let Err(e) = db.update_album_cover(album_id, *first_image_id).await {
eprintln!(" 更新相册封面失败: {}", e);
}
if let Ok(Some(first_image)) = db.get_image(*first_image_id).await {
println!(" 封面图片已设置: {}", first_image.filename);
}
}
Ok(ids.len())
}
Err(e) => {
eprintln!(" 批量插入失败: {}", e);
Ok(0)
}
}
}
pub async fn import_existing_data(config: &Config, db: Arc<Database>, _task_queue: Option<Arc<crate::queue::TaskQueue>>) -> Result<(), Box<dyn std::error::Error>> {
println!("开始扫描图片目录并导入新相册...");
let albums_dir = Path::new(&config.storage.images_dir);
if !albums_dir.exists() {
println!("albums目录不存在,跳过导入");
return Ok(());
}
let existing_albums = db.get_all_albums().await?;
let existing_titles: Vec<String> = existing_albums.iter()
.map(|album| album.document.to_lowercase())
.collect();
println!("数据库中现有相册数量: {}", existing_albums.len());
let mut reimported_count = 0;
let mut deleted_count = 0;
for album in &existing_albums {
let album_folder_path = albums_dir.join(&album.document);
if !album_folder_path.exists() {
println!("🗑️ 相册文件夹不存在,删除相册: {}", album.document);
if let Err(e) = db.delete_album(album.id).await {
eprintln!("❌ 删除相册失败: {}", e);
} else {
deleted_count += 1;
}
continue;
}
let image_count = db.get_album_image_count(album.id).await.unwrap_or(0);
if image_count == 0 {
println!("🔄 重新导入相册: {} (图片数量: 0)", album.document);
match reimport_single_album(&db, &album.document, &album_folder_path, album.id).await {
Ok(count) => {
if count > 0 {
reimported_count += 1;
println!("✅ 相册 {} 重新导入成功,导入 {} 张图片", album.document, count);
} else {
println!("⚠️ 相册 {} 重新导入但没有找到图片", album.document);
}
}
Err(e) => {
eprintln!("❌ 相册 {} 重新导入失败: {}", album.document, e);
}
}
}
}
if reimported_count > 0 || deleted_count > 0 {
println!("🎯 重新导入完成: 重新导入 {} 个相册,删除 {} 个相册", reimported_count, deleted_count);
}
let entries: Vec<_> = fs::read_dir(albums_dir)?.collect::<Result<Vec<_>, _>>()?;
let mut album_folders = Vec::new();
for entry in entries {
let path = entry.path();
if path.is_dir() {
if let Some(folder_name) = path.file_name().and_then(|s| s.to_str()) {
if existing_titles.contains(&folder_name.to_lowercase()) {
continue;
}
album_folders.push((folder_name.to_string(), path));
}
}
}
println!("发现 {} 个新相册需要导入", album_folders.len());
if album_folders.is_empty() {
println!("没有新相册需要导入");
return Ok(());
}
println!("📁 开始导入新相册...");
let mut total_imported = 0;
let mut successful_albums = 0;
for (folder_name, path) in album_folders {
println!("📁 开始导入相册: {}", folder_name);
match import_single_album(&db, &folder_name, &path).await {
Ok(count) => {
if count > 0 {
successful_albums += 1;
total_imported += count;
println!("✅ 相册 {} 导入完成,共导入 {} 张图片", folder_name, count);
} else {
println!("⚠️ 相册 {} 没有图片需要导入", folder_name);
}
}
Err(e) => {
eprintln!("❌ 相册 {} 导入失败: {}", folder_name, e);
}
}
}
println!("🎉 导入完成!成功导入 {} 个相册,共 {} 张图片", successful_albums, total_imported);
Ok(())
}
async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
println!("📁 开始导入相册: {}", folder_name);
let index_file_path = path.join("index.json");
let album_title = if index_file_path.exists() {
match fs::read_to_string(&index_file_path) {
Ok(content) => {
match serde_json::from_str::<AlbumIndex>(&content) {
Ok(index) => {
println!(" 从index.json读取相册标题: {}", index.title);
index.title
}
Err(e) => {
println!(" 解析index.json失败: {}, 使用文件夹名作为标题", e);
folder_name.to_string()
}
}
}
Err(e) => {
println!(" 读取index.json失败: {}, 使用文件夹名作为标题", e);
folder_name.to_string()
}
}
} else {
println!(" index.json不存在,使用文件夹名作为标题");
folder_name.to_string()
};
let new_album = CreateAlbum {
title: album_title.clone(),
document: folder_name.to_string(),
description: Some(format!("从文件夹 {} 自动导入的相册", folder_name)),
};
let album_id = db.create_album(new_album).await?;
println!(" 创建相册: {} (ID: {})", album_title, album_id);
let image_urls = if index_file_path.exists() {
match fs::read_to_string(&index_file_path) {
Ok(content) => {
match serde_json::from_str::<AlbumIndex>(&content) {
Ok(index) => {
println!(" 从index.json读取到 {} 个图片URL", index.images.len());
Some(index.images)
}
Err(_) => None
}
}
Err(_) => None
}
} else {
None
};
let image_files = scan_image_files(path, folder_name)?;
if image_files.is_empty() {
println!(" 相册 {} 中没有找到图片文件", folder_name);
return Ok(0);
}
println!(" 发现 {} 张图片,开始处理...", image_files.len());
let mut image_tasks = Vec::new();
for (index, (filename, image_path)) in image_files.iter().enumerate() {
let image_url = image_urls.as_ref().and_then(|urls| {
if urls.len() > index {
Some(urls[index].clone())
} else {
None
}
});
let filename_clone = filename.clone();
let image_path_clone = image_path.clone();
let album_id_clone = album_id;
let folder_name_clone = folder_name.to_string();
let task = task::spawn_blocking(move || {
let (file_size, width, height) = match get_image_info(&image_path_clone) {
Ok(info) => info,
Err(e) => {
eprintln!("获取图片信息失败: {} - {}", image_path_clone.display(), e);
(0, 0, 0)
}
};
CreateImage {
album_id: album_id_clone,
filename: format!("{}/{}", folder_name_clone, filename_clone),
original_url: image_url,
title: Some(format!("图片 {}", index + 1)),
description: Some(format!("从文件夹 {} 导入的图片", folder_name_clone)),
file_size,
width,
height,
mime_type: match image_path_clone.extension().and_then(|s| s.to_str()).unwrap_or("jpg").to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg".to_string(),
"png" => "image/png".to_string(),
"gif" => "image/gif".to_string(),
"webp" => "image/webp".to_string(),
"bmp" => "image/bmp".to_string(),
_ => "image/jpeg".to_string(),
},
}
});
image_tasks.push(task);
}
let mut images_to_insert = Vec::new();
for task in image_tasks {
match task.await {
Ok(image) => images_to_insert.push(image),
Err(e) => eprintln!("图片处理任务失败: {}", e),
}
}
batch_insert_images_and_set_cover(&db, images_to_insert, album_id, path, folder_name).await
}
pub(crate) fn scan_image_files(path: &Path, _folder_name: &str) -> Result<Vec<(String, std::path::PathBuf)>, Box<dyn std::error::Error>> {
let mut image_files = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
let image_path = entry.path();
if image_path.is_file() {
if let Some(extension) = image_path.extension().and_then(|s| s.to_str()) {
let valid_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
if valid_extensions.contains(&extension.to_lowercase().as_str()) {
let filename = image_path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown.jpg")
.to_string();
image_files.push((filename, image_path));
}
}
}
}
image_files.sort_by(|a, b| {
let num_a = a.0.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.unwrap_or(0);
let num_b = b.0.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.unwrap_or(0);
num_a.cmp(&num_b)
});
Ok(image_files)
}
fn get_image_info(image_path: &Path) -> Result<(i64, i32, i32), Box<dyn std::error::Error>> {
let metadata = fs::metadata(image_path)?;
let file_size = metadata.len() as i64;
let img = ::image::open(image_path)?;
let (width, height) = img.dimensions();
Ok((file_size, width as i32, height as i32))
}
async fn reimport_single_album(
db: &Database,
folder_name: &str,
path: &Path,
album_id: Uuid,
) -> Result<usize, Box<dyn std::error::Error>> {
println!("📁 重新导入相册: {} (ID: {})", folder_name, album_id);
let index_file_path = path.join("index.json");
let album_title = if index_file_path.exists() {
match fs::read_to_string(&index_file_path) {
Ok(content) => {
match serde_json::from_str::<AlbumIndex>(&content) {
Ok(index) => index.title,
Err(_) => folder_name.to_string(),
}
}
Err(_) => folder_name.to_string(),
}
} else {
folder_name.to_string()
};
println!(" 相册标题: {}", album_title);
let mut images_to_insert = Vec::new();
let image_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
let entries: Vec<_> = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
for entry in entries {
let file_path = entry.path();
if file_path.is_file() {
if let Some(extension) = file_path.extension().and_then(|ext| ext.to_str()) {
if image_extensions.contains(&extension.to_lowercase().as_str()) {
if let Some(filename) = file_path.file_name().and_then(|s| s.to_str()) {
if filename == "index.json" {
continue;
}
let (file_size, width, height) = match get_image_info(&file_path) {
Ok(info) => info,
Err(e) => {
eprintln!(" 获取图片信息失败 {}: {}", filename, e);
continue;
}
};
let image = CreateImage {
album_id,
filename: format!("{}/{}", folder_name, filename),
original_url: Some(format!("/images/{}/{}", folder_name, filename)),
title: Some(filename.to_string()),
description: Some(String::new()),
file_size,
width,
height,
mime_type: match extension.to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg".to_string(),
"png" => "image/png".to_string(),
"gif" => "image/gif".to_string(),
"webp" => "image/webp".to_string(),
"bmp" => "image/bmp".to_string(),
_ => "application/octet-stream".to_string(),
},
};
images_to_insert.push(image);
}
}
}
}
}
println!(" 发现 {} 张图片需要导入", images_to_insert.len());
batch_insert_images_and_set_cover(&db, images_to_insert, album_id, path, folder_name).await
}

5
src/services/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
//! 服务层 — 导入、处理等业务逻辑
pub mod import;
pub use import::*;