update
This commit is contained in:
74
.gitea/workflows/ci.yml
Normal file
74
.gitea/workflows/ci.yml
Normal 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
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/target
|
||||
/albmums
|
||||
/albums/
|
||||
.idea
|
||||
3417
Cargo.lock
generated
Normal file
3417
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
migrations/002_metadata.sql
Normal file
52
migrations/002_metadata.sql
Normal 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
284
scripts/import_test_data.py
Normal 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
872
src/bin/migrate.rs
Normal 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
132
src/cache/mod.rs
vendored
Normal 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
132
src/db/album.rs
Normal 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
63
src/db/connection.rs
Normal 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
67
src/db/favorite.rs
Normal 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
74
src/db/image.rs
Normal 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
203
src/db/metadata.rs
Normal 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
161
src/db/migration.rs
Normal 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
15
src/db/mod.rs
Normal 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
176
src/engine/crawler.rs
Normal 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
15
src/engine/mod.rs
Normal 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
383
src/engine/person_merger.rs
Normal 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
81
src/engine/pipeline.rs
Normal 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
203
src/engine/recommend.rs
Normal 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
|
||||
}
|
||||
}
|
||||
81
src/engine/tag_extractor.rs
Normal file
81
src/engine/tag_extractor.rs
Normal 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
285
src/engine/title_parser.rs
Normal 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
62
src/models/album.rs
Normal 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
30
src/models/favorite.rs
Normal 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
30
src/models/image.rs
Normal 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
13
src/models/mod.rs
Normal 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
53
src/models/person.rs
Normal 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
46
src/models/recommend.rs
Normal 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
35
src/models/tag.rs
Normal 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
65
src/routes/album.rs
Normal 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
45
src/routes/favorite.rs
Normal 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
29
src/routes/image.rs
Normal 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
48
src/routes/migration.rs
Normal 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
21
src/routes/mod.rs
Normal 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
36
src/routes/person.rs
Normal 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
15
src/routes/pipeline.rs
Normal 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
8
src/routes/queue.rs
Normal 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
36
src/routes/recommend.rs
Normal 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
24
src/routes/tag.rs
Normal 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
405
src/services/import.rs
Normal 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
5
src/services/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! 服务层 — 导入、处理等业务逻辑
|
||||
|
||||
pub mod import;
|
||||
|
||||
pub use import::*;
|
||||
Reference in New Issue
Block a user