commit 16239d75254f7e68a94d9c2f461c35efd233c4b2 Author: nanxun Date: Tue Aug 11 18:02:40 2026 +0800 feat: add ImageFind application and release pipelines diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b18a8c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.ci +.venv +.release-venv +.fnos-wheel-cache +.fnos-build-tools +.tools +.playwright-* +.pytest_cache +.ruff_cache +.build-fnos +build +dist +data +vendor +frontend/node_modules +frontend/dist +test-results +mytest +*.fpk diff --git a/.downloads/optimum-inspect/optimum-2.1.0-py3-none-any.whl b/.downloads/optimum-inspect/optimum-2.1.0-py3-none-any.whl new file mode 100644 index 0000000..4efc84c Binary files /dev/null and b/.downloads/optimum-inspect/optimum-2.1.0-py3-none-any.whl differ diff --git a/.downloads/optimum-inspect/optimum_intel-1.27.0-py3-none-any.whl b/.downloads/optimum-inspect/optimum_intel-1.27.0-py3-none-any.whl new file mode 100644 index 0000000..e8b71a0 Binary files /dev/null and b/.downloads/optimum-inspect/optimum_intel-1.27.0-py3-none-any.whl differ diff --git a/.downloads/optimum-inspect/transformers-4.57.6-py3-none-any.whl b/.downloads/optimum-inspect/transformers-4.57.6-py3-none-any.whl new file mode 100644 index 0000000..40e334e Binary files /dev/null and b/.downloads/optimum-inspect/transformers-4.57.6-py3-none-any.whl differ diff --git a/.downloads/postgres17/libpq5_17.10-0+deb13u1_amd64.deb b/.downloads/postgres17/libpq5_17.10-0+deb13u1_amd64.deb new file mode 100644 index 0000000..6345d92 Binary files /dev/null and b/.downloads/postgres17/libpq5_17.10-0+deb13u1_amd64.deb differ diff --git a/.downloads/postgres17/postgresql-17-pgvector_0.8.0-1_amd64.deb b/.downloads/postgres17/postgresql-17-pgvector_0.8.0-1_amd64.deb new file mode 100644 index 0000000..c7cb505 Binary files /dev/null and b/.downloads/postgres17/postgresql-17-pgvector_0.8.0-1_amd64.deb differ diff --git a/.downloads/postgres17/postgresql-17_17.10-0+deb13u1_amd64.deb b/.downloads/postgres17/postgresql-17_17.10-0+deb13u1_amd64.deb new file mode 100644 index 0000000..17d3688 Binary files /dev/null and b/.downloads/postgres17/postgresql-17_17.10-0+deb13u1_amd64.deb differ diff --git a/.downloads/postgres17/postgresql-client-17_17.10-0+deb13u1_amd64.deb b/.downloads/postgres17/postgresql-client-17_17.10-0+deb13u1_amd64.deb new file mode 100644 index 0000000..6b0d236 Binary files /dev/null and b/.downloads/postgres17/postgresql-client-17_17.10-0+deb13u1_amd64.deb differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..681c87b --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +.venv/ +.data/ +data/ +frontend/node_modules/ +frontend/dist/ +ui-prototype/node_modules/ +ui-prototype/dist/ +.npm-cache/ +*.fpk +*.pyc +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.pip-tmp/ +.build-fnos/ +build/ +dist/ +vendor/ +.ci/ +.fnos-wheel-cache/ +.fnos-build-tools/ +.tools/ +.release-venv/ +.playwright-*/ +.runtime-check-data/ +.smoke-*/ +test-results/ +playwright-report/ +mytest/ +imagefind-frames-*/ +sshpass_*.deb +.claude/ diff --git a/.impeccable/critique/2026-08-03T17-47-41Z__frontend-src-app-tsx.md b/.impeccable/critique/2026-08-03T17-47-41Z__frontend-src-app-tsx.md new file mode 100644 index 0000000..18ab4cb --- /dev/null +++ b/.impeccable/critique/2026-08-03T17-47-41Z__frontend-src-app-tsx.md @@ -0,0 +1,142 @@ +--- +target: 前端 UI +total_score: 23 +max_score: 40 +na_heuristics: +p0_count: 0 +p1_count: 2 +timestamp: 2026-08-03T17-47-41Z +slug: frontend-src-app-tsx +--- +Method: dual-agent (A: /root/impeccable_design_review · B: /root/impeccable_detector) + +# ImageFind 前端 UI Critique + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|---|---:|---| +| 1 | 系统状态可见性 | 3/4 | 加载、重试、Toast 和上传分阶段反馈完整,但部分加载状态占据整页且缺少上下文。 | +| 2 | 系统与现实世界匹配 | 3/4 | 中文表达总体自然,但 WebDAV、AList、aria2、模型路径等技术概念仍直接暴露。 | +| 3 | 用户控制与自由 | 2/4 | 移动端二级页隐藏顶栏和底栏后缺少显式返回与当前位置。 | +| 4 | 一致性与标准 | 2/4 | 组件体系基本统一,但“上传中心/传输中心”、黑色/蓝色选中态以及二级页外壳存在分裂。 | +| 5 | 错误预防 | 3/4 | 危险操作确认、禁用态、密码约束和备份检查完善,但大部分操作无法撤销。 | +| 6 | 识别优于回忆 | 2/4 | 多数按钮有文字;平板图标导航、横向隐藏控件、悬停入口和移动端退出路径增加记忆成本。 | +| 7 | 灵活性与效率 | 2/4 | 搜索、批量操作与筛选具备基础效率,但缺少快捷键、命令入口、保存搜索等专家路径。 | +| 8 | 美观与极简 | 2/4 | 表面干净,但原始文件名、重复缺省封面、完整展开的历史上传卡片造成视觉噪声。 | +| 9 | 错误识别与恢复 | 3/4 | 内联错误、重试和失败任务操作良好;少量后端技术语言仍会直达用户。 | +| 10 | 帮助与文档 | 1/4 | 有局部提示,但 AI、存储、备份和网络配置缺少易找到的任务型帮助。 | +| **Total** | | **23/40** | **Acceptable;需要一次有重点的结构优化** | + +## Design Specificity Verdict + +**语义很 ImageFind,视觉仍较通用。** “本地 AI”、AList 加密、封面遮罩、多模态搜索、三阶段上传和备份保护都明显属于私有 NAS 媒体产品;但蓝灰后台、白色圆角卡片、胶囊筛选和 Lucide 图标组合仍像可换文案复用的管理控制台。 + +真正应成为视觉主角的“在私有媒体里找到某一刻”、识别来源、隐私状态、处理就绪度和观看回报,目前大多被压成小标签。 + +### Deterministic scan + +- `detect.mjs --json frontend/src/App.tsx`:0 条发现,退出码 0。 +- 无规则命中、无文件位置、无 detector 误报。 +- 自动扫描证明没有明显模板级机械违规,但无法发现信息架构、小字号、点击语义和认知负担问题。 + +### Visual evidence + +- 覆盖 320、390、768、1024、1440px,共 35 个页面/尺寸组合。 +- 五个尺寸均无页面级横向溢出,33 张首页卡片无技术性封面加载错误。 +- 上传分页全部可进入第 2 页;手机按钮 44px。 +- 手机“更多”面板完全位于视口内,6 个入口内容居中且背景透明。 +- 无原生交互式 Browser/[Human] 页签,因此未注入可视化 overlay;使用当前部署的 Playwright 报告与截图作为证据。 + +## Overall Impression + +这是一个技术成熟、反馈机制可靠的产品外壳,但仍更像“功能齐全的内部控制台”,而不是用户拥有的私人媒体空间。最大机会不是增加装饰,而是让移动端路径更可控、提高文字与操作可访问性,并让 ImageFind 最独特的“搜索视频内容”成为首页与搜索页的真正视觉中心。 + +## What's Working + +1. **系统状态设计扎实。** 上传分阶段、字节数、重试/取消、刷新失败、AI 模型与资源状态都能明确反馈。 +2. **响应式基础可靠。** 桌面常驻导航、手机底栏、中心上传入口、安全区、44px 控件和播放器手势构成了可继续打磨的稳定底座。 +3. **隐私表达建立信任。** 本地 AI、加密来源、封面遮罩、备份限制和不上传派生数据等文案具体可信。 + +## Priority Issues + +### [P1] 移动端二级页缺少显式返回与当前位置 + +**Why it matters:** 资料库、合集、上传、分类、人物、设置和播放器隐藏顶栏/底栏后,用户只能猜测浏览器返回,是典型移动端死胡同。 + +**Fix:** 增加紧凑的 sticky 二级页头:返回、页面标题、必要的溢出操作;播放器仅让视频舞台沉浸,不要让整个详情页失去导航。 + +**Suggested command:** `$impeccable adapt` + +### [P1] 核心卡片语义与小字号不满足稳定可访问性 + +**Why it matters:** 搜索结果、来源、人物、合集、相关推荐等存在非交互元素承载 `onClick`;部分关键元数据只有 8.5–11px,`--subtle` 功能文字对比度约 2.42:1。 + +**Fix:** 将可点击卡片改为真实链接/按钮,统一 `:focus-visible`,补充图片替代文本;功能文字至少提升到舒适字号并提高对比度。 + +**Suggested command:** `$impeccable audit` + +### [P2] 搜索筛选同时“过载”与“隐藏” + +**Why it matters:** 搜索页一次展示 7 种识别模式和 6 个次级筛选;移动端依赖无提示横滑,桌面端则在输入查询前就提供 13 个决策。 + +**Fix:** 常驻 3–4 个高频模式,其余进入带已选数量和“清除全部”的筛选面板;保留已应用筛选摘要,并给横向列表加入可发现的滚动提示。让截图搜索虚线区本身可点击/拖放。 + +**Suggested command:** `$impeccable distill` + +### [P2] 首页没有突出 ImageFind 的独特价值 + +**Why it matters:** 首页主要由普通视频网格、原始文件名、`0:00` 和重复“暂无封面”组成,用户先感知到的是文件库存,而不是“找到视频中的任意内容”。 + +**Fix:** 强化内容搜索、最近识别、处理就绪和私密状态的层级;缺省封面区分待处理、隐私遮罩、失败和确实无封面,避免所有状态长得一样。 + +**Suggested command:** `$impeccable bolder` + +### [P2] 上传完成态没有转化为下一步行动 + +**Why it matters:** 完成任务继续保留完整三段进度和多次“完成”,形成很长的历史墙,用户却找不到“查看影片/进入合集”的回报。 + +**Fix:** 完成项折叠成紧凑历史行,活动和失败任务保持展开;按日期分组,并增加“查看影片”“在媒体库中显示”。 + +**Suggested command:** `$impeccable clarify` + +## Persona Red Flags + +### Alex(高效专家用户) + +- 搜索、上传、导航、筛选和播放器缺少可发现快捷键。 +- 没有保存搜索、最近查询或命令入口。 +- 已完成上传无法批量折叠/清理,合集与搜索卡片仍偏鼠标优先。 + +### Sam(依赖无障碍能力的用户) + +- 多类核心卡片是 click-only 的非语义元素。 +- 全局 focus-visible 规范不完整,播放器返回又依赖悬停表现。 +- 8.5–10px 关键文字与低对比 `--subtle` token 对低视力用户不友好。 +- 平板导航只显示图标,人物/演员图片存在替代文本缺口。 + +### Casey(分心的单手手机用户) + +- 中心上传和 44px 控件表现好,但进入二级页后熟悉的底栏消失。 +- 搜索模式、设置标签、播放器动作和合集条目依赖无提示横滑。 +- “新建合集”等主操作位于顶部,远离拇指热区。 +- 上传页固定“继续上传”比处理失败或打开刚上传内容更抢眼。 + +## Minor Observations + +- “上传中心”“传输中心”“上传”三个概念需要统一命名边界。 +- 黑色选中 chip 与全局蓝色品牌状态冲突。 +- `AI SEARCH`、`SYSTEM`、`ADMIN PROFILE` 等英文 eyebrow 使中文界面显得模板化。 +- 未知时长显示 `0:00` 容易被理解为真正的零长度视频。 +- “暂无封面”应区分缺失、排队、失败和隐私遮罩。 +- 401 控制台信息在五个尺寸都出现,但发生于登录前认证探测,当前没有用户可见影响。 +- 手机搜索容器比客户区宽 1px,但 document/body 未溢出,属于非阻塞几何误差。 +- 320px 时间点面板的报告值是时序误判;截图明确显示面板已经打开。 + +## Questions to Consider + +- ImageFind 的第一承诺究竟是“管理媒体文件”,还是“找到私有媒体中的任意时刻”?首页为什么更像前者? +- 如果隐私是核心差异,为什么移动端最需要的封面遮罩快捷操作反而不可见? +- 一次大文件上传完成后,用户最自然的下一步是播放、整理到合集,还是继续上传? +- 移动端二级页是否真的需要沉浸到移除所有导航? +- 如果原始文件名和缺省封面是边缘状态,首页的信息结构应怎样改变? diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cfa1f00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +ARG NODE_IMAGE=node:22-bookworm-slim +ARG PYTHON_IMAGE=python:3.12-slim-bookworm + +FROM ${NODE_IMAGE} AS frontend-build +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM ${PYTHON_IMAGE} AS wheel-build +WORKDIR /src +COPY pyproject.toml README.md ./ +COPY backend/ ./backend/ +RUN python -m pip install --no-cache-dir 'hatchling>=1.25,<2' \ + && python -m hatchling build -t wheel -d /wheels + +FROM ${PYTHON_IMAGE} +ARG PGVECTOR_VERSION=0.8.6 +ARG PGVECTOR_PACKAGE_VERSION_URL=0.8.6-1.pgdg12%2B1 +ARG PGVECTOR_SHA256=b27ff894d1e2d23ebd7528fcb986923391977cbd5c5379ed74527875246854ca +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg postgresql-15 postgresql-client-15 rclone tini \ + && curl --fail --location --retry 4 --retry-all-errors \ + "https://apt.postgresql.org/pub/repos/apt/pool/main/p/pgvector/postgresql-15-pgvector_${PGVECTOR_PACKAGE_VERSION_URL}_amd64.deb" \ + --output /tmp/pgvector.deb \ + && printf '%s %s\n' "$PGVECTOR_SHA256" /tmp/pgvector.deb | sha256sum --check --status \ + && dpkg -i /tmp/pgvector.deb \ + && rm -f /tmp/pgvector.deb \ + && rm -rf /var/lib/apt/lists/* +COPY requirements/runtime-core.txt /opt/imagefind/runtime-core.txt +COPY --from=wheel-build /wheels/imagefind-*.whl /opt/imagefind/ +RUN python -m pip install --no-cache-dir -r /opt/imagefind/runtime-core.txt /opt/imagefind/imagefind-*.whl \ + && useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin imagefind +COPY --from=frontend-build /src/frontend/dist/ /opt/imagefind/frontend/ +COPY scripts/docker-entrypoint.sh /usr/local/bin/imagefind-entrypoint +RUN chmod 0755 /usr/local/bin/imagefind-entrypoint && mkdir -p /data +ENV IMAGEFIND_DATA_DIR=/data/imagefind \ + IMAGEFIND_FRONTEND_DIR=/opt/imagefind/frontend \ + IMAGEFIND_HOST=0.0.0.0 \ + IMAGEFIND_PORT=8765 \ + IMAGEFIND_DIRECT_ACCESS=true \ + IMAGEFIND_POSTGRES_CONF=/data/postgres-client.conf \ + TZ=Asia/Shanghai +VOLUME ["/data"] +EXPOSE 8765 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=4 \ + CMD pg_isready -h 127.0.0.1 -p 5432 -d imagefind -q \ + && curl -fsS http://127.0.0.1:8765/ >/dev/null || exit 1 +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/imagefind-entrypoint"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..891495d --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,239 @@ +pipeline { + agent { + node { + label '构建机1' + customWorkspace '/home/nanxunai/goujian/workspace/imagefind-release' + } + } + + options { + timestamps() + disableConcurrentBuilds() + skipDefaultCheckout(true) + timeout(time: 240, unit: 'MINUTES') + buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '15')) + } + + environment { + PATH = '/home/nanxunai/.local/bin:/usr/local/bin:/usr/bin:/bin' + FNPACK_BIN = '/home/nanxunai/.local/bin/fnpack' + REGISTRY_URL = 'reg.nxsir.cn' + IMAGE_REPO = 'reg.nxsir.cn/imagefind' + HARBOR_CREDENTIALS = 'imagefind_key' + OPENLIST_CREDENTIALS = 'openlist_key' + NODE_CREDENTIALS = 'bbb939ea-4f01-4b47-aecb-c5ee2a551ef4' + OPENLIST_BASE_URL = 'https://openlist.nxsir.cn' + OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/imagefind' + HTTP_PROXY_URL = 'http://192.168.5.200:7890' + NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn,openlist.nxsir.cn,docker.m.daocloud.io,pypi.tuna.tsinghua.edu.cn' + BUILDER_NAME = "imagefind-${BUILD_NUMBER}" + DOCKER_CONFIG = "${WORKSPACE}/.ci/docker" + NPM_CONFIG_CACHE = "${WORKSPACE}/.ci/npm-cache" + PIP_CACHE_DIR = "${WORKSPACE}/.ci/pip-cache" + IMAGEFIND_WHEEL_CACHE = "${WORKSPACE}/.ci/wheelhouse-python312" + IMAGEFIND_DEPENDENCY_TMPDIR = "${WORKSPACE}/.ci/dependency-tmp" + IMAGEFIND_VERIFY_TMPDIR = "${WORKSPACE}/.ci/fnos-verify" + } + + stages { + stage('Checkout') { + steps { + deleteDir() + checkout scm + } + } + + stage('Metadata And Preflight') { + steps { + script { + env.APP_VERSION = sh(script: "sed -n 's/^version=//p' fnos/manifest | head -n 1", returnStdout: true).trim() + env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim() + env.FNOS_VERSION = sh(script: ''' + set -euo pipefail + base="$APP_VERSION"; major=${base%%.*}; remainder=${base#*.} + minor=${remainder%%.*}; patch=${remainder#*.} + test "$base" = "$major.$minor.$patch" && test "$BUILD_NUMBER" -lt 100000 + printf '%s.%s.%s\n' "$major" "$minor" "$((patch * 100000 + BUILD_NUMBER))" + ''', returnStdout: true).trim() + env.IMMUTABLE_TAG = "${env.APP_VERSION}-b${env.BUILD_NUMBER}-${env.SHORT_SHA}" + env.IMAGE_REF = "${env.IMAGE_REPO}:${env.IMMUTABLE_TAG}" + env.FPK_BASENAME = "imagefind-${env.FNOS_VERSION}-x86_64.fpk" + env.FPK_PATH = "${env.WORKSPACE}/artifacts/${env.FPK_BASENAME}" + env.MANIFEST_PATH = "${env.WORKSPACE}/artifacts/imagefind-${env.IMMUTABLE_TAG}-build-manifest.json" + currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.IMMUTABLE_TAG}" + currentBuild.description = "fnOS ${env.FNOS_VERSION}" + } + sh ''' + set -euo pipefail + test "$(uname -m)" = x86_64 + for command_name in git sudo docker curl python3 npm node sha256sum tar dpkg-deb; do command -v "$command_name" >/dev/null; done + test -x "$FNPACK_BIN" + mkdir -p "$DOCKER_CONFIG" "$NPM_CONFIG_CACHE" "$PIP_CACHE_DIR" "$IMAGEFIND_WHEEL_CACHE" \ + "$IMAGEFIND_DEPENDENCY_TMPDIR" "$IMAGEFIND_VERIFY_TMPDIR" artifacts + chmod 700 "$DOCKER_CONFIG" + available_kb=$(df -Pk "$WORKSPACE" | awk 'NR == 2 { print $4 }') + test "$available_kb" -ge 4194304 + ''' + } + } + + stage('Backend And Frontend Tests') { + steps { + sh ''' + set -euo pipefail + python3 -m venv .ci/venv + .ci/venv/bin/python -m pip install --disable-pip-version-check --upgrade pip + .ci/venv/bin/python -m pip install --disable-pip-version-check '.[dev]' + .ci/venv/bin/ruff check backend tests + # Unit tests that do not require the fnOS shared PostgreSQL service. + # PostgreSQL integration is covered by the real-container smoke stage below. + PYTHONPATH=. .ci/venv/bin/pytest -q \ + tests/test_openlist_client.py \ + tests/test_workflow_contracts.py \ + tests/test_accelerator_runtime.py \ + tests/test_inference.py + npm ci --prefix frontend + npm run lint --prefix frontend + npm run build --prefix frontend + ''' + } + } + + stage('Prepare And Build fnOS x64') { + steps { + sh ''' + set -euo pipefail + PYTHON=.ci/venv/bin/python ./scripts/prepare-fnos-dependencies.sh + PACKAGE_VERSION="$FNOS_VERSION" PYTHON=.ci/venv/bin/python FNPACK="$FNPACK_BIN" \ + ./scripts/build-fnos.sh "$FPK_PATH" + ./scripts/verify-fnos-package.sh "$FPK_PATH" "$FNOS_VERSION" + ''' + } + } + + stage('Build And Smoke Docker amd64') { + steps { + withCredentials([usernamePassword(credentialsId: "${NODE_CREDENTIALS}", usernameVariable: 'JENKINS_NODE_USERNAME', passwordVariable: 'JENKINS_NODE_PASSWORD')]) { + sh ''' + set -euo pipefail + ./scripts/ci-docker.sh buildx rm -f "$BUILDER_NAME" >/dev/null 2>&1 || true + ./scripts/ci-docker.sh buildx create --name "$BUILDER_NAME" --driver docker-container --driver-opt network=host --use + ./scripts/ci-docker.sh buildx inspect "$BUILDER_NAME" --bootstrap + ./scripts/ci-docker.sh buildx build --builder "$BUILDER_NAME" --platform linux/amd64 --network host \ + --progress=plain --provenance=false --no-cache --load \ + --build-arg HTTP_PROXY="$HTTP_PROXY_URL" --build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \ + --build-arg NO_PROXY="$NO_PROXY_HOSTS" -t "$IMAGE_REF" . + smoke_name="imagefind-smoke-$BUILD_NUMBER" + mkdir -p .ci/docker-data + ./scripts/ci-docker.sh rm -f "$smoke_name" >/dev/null 2>&1 || true + ./scripts/ci-docker.sh run -d --name "$smoke_name" -p 127.0.0.1::8765 \ + -e IMAGEFIND_ADMIN_PASSWORD='ImageFind-Smoke-2026!' \ + -v "$WORKSPACE/.ci/docker-data:/data" "$IMAGE_REF" + host_port=$(./scripts/ci-docker.sh port "$smoke_name" 8765/tcp | sed -n 's/.*://p' | head -n 1) + test -n "$host_port" + ready=0 + for attempt in $(seq 1 90); do + if curl -fsS "http://127.0.0.1:$host_port/" >/dev/null; then ready=1; break; fi + sleep 2 + done + test "$ready" = 1 || { ./scripts/ci-docker.sh logs "$smoke_name" || true; exit 1; } + curl -fsS -H 'Content-Type: application/json' \ + --data '{"password":"ImageFind-Smoke-2026!"}' \ + "http://127.0.0.1:$host_port/api/v1/auth/login" | grep -q 'csrf' + ./scripts/ci-docker.sh restart "$smoke_name" >/dev/null + ready=0 + for attempt in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:$host_port/" >/dev/null; then ready=1; break; fi + sleep 2 + done + test "$ready" = 1 + curl -fsS -H 'Content-Type: application/json' \ + --data '{"password":"ImageFind-Smoke-2026!"}' \ + "http://127.0.0.1:$host_port/api/v1/auth/login" | grep -q 'csrf' + test -s .ci/docker-data/postgresql/PG_VERSION + test -s .ci/docker-data/postgres-client.conf + test -e .ci/docker-data/.imagefind-admin-initialized + ./scripts/ci-docker.sh rm -f "$smoke_name" + ./scripts/ci-docker.sh buildx prune --builder "$BUILDER_NAME" --all --force + ''' + } + } + } + + stage('Push Immutable And Describe') { + steps { + withCredentials([ + usernamePassword(credentialsId: "${NODE_CREDENTIALS}", usernameVariable: 'JENKINS_NODE_USERNAME', passwordVariable: 'JENKINS_NODE_PASSWORD'), + usernamePassword(credentialsId: "${HARBOR_CREDENTIALS}", usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD') + ]) { + sh ''' + set -euo pipefail + auth=$(printf '%s:%s' "$HARBOR_USERNAME" "$HARBOR_PASSWORD" | base64 -w0) + printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY_URL" "$auth" >"$DOCKER_CONFIG/config.json" + chmod 600 "$DOCKER_CONFIG/config.json" + ./scripts/ci-docker.sh push "$IMAGE_REF" + digest=$(./scripts/ci-docker.sh image inspect --format '{{index .RepoDigests 0}}' "$IMAGE_REF" | sed -n 's/.*@//p'); test -n "$digest"; export digest + fpk_sha=$(awk '{print $1}' "$FPK_PATH.sha256"); export fpk_sha + python3 - <<'PY' +import json, os, pathlib, subprocess +path = pathlib.Path(os.environ['MANIFEST_PATH']) +payload = { + 'project':'imagefind','buildNumber':os.environ['BUILD_NUMBER'], + 'commit':subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), + 'productVersion':os.environ['APP_VERSION'],'fnosVersion':os.environ['FNOS_VERSION'], + 'fpk':pathlib.Path(os.environ['FPK_PATH']).name,'fpkSha256':os.environ['fpk_sha'], + 'image':os.environ['IMAGE_REF'],'digest':os.environ['digest'],'platform':'linux/amd64', + 'database':'PostgreSQL 15 + pgvector 0.8.6 (embedded Docker runtime)' +} +path.write_text(json.dumps(payload,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') +PY + sha256sum "$MANIFEST_PATH" >"$MANIFEST_PATH.sha256" + ''' + } + } + } + + stage('Upload Artifacts') { + steps { + withCredentials([usernamePassword(credentialsId: "${OPENLIST_CREDENTIALS}", usernameVariable: 'OPENLIST_USERNAME', passwordVariable: 'OPENLIST_PASSWORD')]) { + sh ''' + set -euo pipefail + for artifact in "$FPK_PATH" "$FPK_PATH.sha256" "$MANIFEST_PATH" "$MANIFEST_PATH.sha256"; do + ./scripts/upload-openlist-artifact.sh "$artifact" "$OPENLIST_REMOTE_DIR" + done + ''' + } + } + } + + stage('Publish Stable Image Tags') { + steps { + withCredentials([usernamePassword(credentialsId: "${NODE_CREDENTIALS}", usernameVariable: 'JENKINS_NODE_USERNAME', passwordVariable: 'JENKINS_NODE_PASSWORD')]) { + sh ''' + set -euo pipefail + for tag in "$APP_VERSION" latest; do + ./scripts/ci-docker.sh tag "$IMAGE_REF" "$IMAGE_REPO:$tag" + ./scripts/ci-docker.sh push "$IMAGE_REPO:$tag" + done + ''' + } + } + } + } + + post { + success { archiveArtifacts artifacts: 'artifacts/*.fpk,artifacts/*.sha256,artifacts/*.json', fingerprint: true } + always { + withCredentials([usernamePassword(credentialsId: "${NODE_CREDENTIALS}", usernameVariable: 'JENKINS_NODE_USERNAME', passwordVariable: 'JENKINS_NODE_PASSWORD')]) { + sh ''' + set +e + ./scripts/ci-docker.sh rm -f "imagefind-smoke-$BUILD_NUMBER" >/dev/null 2>&1 || true + ./scripts/ci-docker.sh buildx rm -f "$BUILDER_NAME" >/dev/null 2>&1 || true + for tag in "$IMMUTABLE_TAG" "$APP_VERSION" latest; do ./scripts/ci-docker.sh rmi "$IMAGE_REPO:$tag" >/dev/null 2>&1 || true; done + : >"$DOCKER_CONFIG/config.json" 2>/dev/null || true + ''' + } + cleanWs(deleteDirs: true, notFailBuild: true) + } + } +} diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..f41f1ab --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,60 @@ +# Product + + + +## Platform + +web + +## Users + +ImageFind 面向在飞牛 NAS 上管理个人与家庭私有媒体的单管理员。用户会在桌面浏览器和手机 Web/App 容器中上传、整理、搜索、播放和维护媒体,希望在设备资源有限、媒体来源多样的情况下仍能清楚掌握后台状态。 + +## Product Purpose + +ImageFind 将本地目录、WebDAV 与 AList 媒体集中到一个私有媒体空间,通过本地 AI 识别画面语义、OCR 文字、人物、音频和字幕,让用户可以搜索到视频中的具体内容并直接跳转到对应时间点。 + +## Positioning + +ImageFind 的核心差异是:在 NAS 本地把视频的画面、文字、人物和声音转化为可搜索、可播放的时间片段,同时保留私有部署、加密来源和本地推理能力。 + +## Operating Context + +- 单管理员在桌面端进行来源配置、模型管理、批量整理、存储和备份操作。 +- 手机端主要用于浏览、搜索、播放、上传、收藏、继续观看和快速整理。 +- 视频可能来自本地目录、WebDAV、AList 加密来源、浏览器上传、WebDAV 服务端上传或后台下载。 +- AI、转存、扫描和下载任务会在 NAS 资源保护策略下后台运行。 + +## Capabilities and Constraints + +- 保持现有单管理员架构、中文界面、浅色蓝色品牌、深色模式及跟随系统能力。 +- 保留首页、搜索、资料库、合集、上传中心、分类标签、人物、个人中心、设置和播放器等现有能力。 +- 移动端底栏固定首页、搜索、中央上传、个人中心和更多;二级页必须有明确返回与当前位置。 +- 触控目标至少 44px;功能文字需达到 WCAG AA 对比度并可通过键盘完成主要流程。 +- 高保真原型不连接真实后端,不读取 NAS 数据,也不承诺现有 API 之外的新能力。 + +## Brand Commitments + +- 产品名称保持 ImageFind。 +- 视觉基调是克制、可信、私密的原生媒体应用,而不是营销页或通用文件管理后台。 +- 交互层级参考 B 站的移动导航与合集、夸克的播放器、Infuse 的封面优先媒体库,但不复制其品牌色或组件外观。 +- 品牌色保持清爽蓝色,封面内容承担页面主要色彩。 + +## Evidence on Hand + +- 生产前端:`frontend/src/App.tsx` 与 `frontend/src/styles.css`。 +- 当前部署五种视口截图:`dist/live-ui-audit-current/`。 +- 既有 UI critique:`.impeccable/critique/2026-08-03T17-47-41Z__frontend-src-app-tsx.md`。 +- 无可用于本原型的真实媒体封面资产;演示内容必须明确为合成数据,不得虚构商业证明或真实用户内容。 + +## Product Principles + +1. 任何系统状态都应清楚可见,后台工作不能让用户猜测。 +2. 内容、播放和搜索优先于文件路径与技术实现细节。 +3. 手机体验应像完整应用,而不是缩小后的桌面后台。 +4. 私密、本地和可恢复能力必须通过明确状态与文案建立信任。 +5. 复杂能力使用渐进披露,常用操作保持一步可达。 + +## Accessibility & Inclusion + +主要流程需支持键盘、可见焦点、语义化控件、屏幕阅读器标签、减少动画、200% 缩放以及不依赖颜色单独表达状态。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..865f388 --- /dev/null +++ b/README.md @@ -0,0 +1,284 @@ +# ImageFind + +ImageFind 是面向 fnOS/NAS 的私有 AI 媒体库。它提供类似视频网站的浏览、在线播放、上传、 +下载和资料管理体验,可索引本地目录、通用 WebDAV、AList 直连库与 AList 加密库,并通过 +中英文关键词、番号/演员/标签、OCR/字幕/音频语音、查询图片和已命名人物定位到视频中的具体时间片段。 + +> 当前版本为可侧载验证的 `0.5.45`。应用声明 fnOS Python 3.12 和 PostgreSQL 依赖,核心运行环境离线安装, +> PyTorch、OpenVINO 和各类 AI 依赖在安装对应模型时按需下载;AI 模型权重可在“设置”中 +> 按组件下载、通过离线脚本准备或上传完整模型包。 +> 上传、移动和回收站操作只会对明确启用写入的资料库生效。 + +## 本地开发 + +要求 Python 3.11–3.13、Node.js 20+、FFmpeg/FFprobe,以及启用 `vector`、`pg_trgm` 扩展的 +PostgreSQL 17。业务数据、全文索引和语义向量均由 PostgreSQL 保存,不再使用 SQLite 或 Qdrant +作为运行数据库。启动前通过 `IMAGEFIND_POSTGRES_CONF` 指向 PostgreSQL 客户端配置文件。 + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -e '.[dev]' +npm install --prefix frontend +npm run build --prefix frontend +IMAGEFIND_DATA_DIR="$PWD/.data" imagefind +``` + +打开 `http://127.0.0.1:8765`。本地开发时首次访问需创建不少于 10 个字符的管理员密码; +fnOS 安装包会在安装向导中完成这一步。 + +UI 回归使用 Playwright。Linux 截图机必须安装 `fonts-noto-cjk`,缺少中文字体时测试会直接失败, +避免把方框字保存为视觉基线: + +```bash +cd frontend +npm run test:ui +``` + +飞牛真机测试使用独立配置,不在仓库保存 NAS 密码或浏览器会话。先把已登录管理员浏览器的 +Playwright `storageState` 保存到仓库外,并准备一个只放测试文件的可写资料库: + +```bash +IMAGEFIND_FNOS_BASE_URL="https://NAS地址/app/imagefind/" \ +IMAGEFIND_FNOS_STORAGE_STATE="/安全目录/fnos-storage-state.json" \ +IMAGEFIND_E2E_SOURCE_NAME="ImageFind E2E" \ +npm run test:ui:fnos +``` + +写入测试只清理本次创建且以 `E2E-` 开头的文件,不会操作其他媒体。 + +常用配置: + +| 环境变量 | 默认值 | 说明 | +| --- | --- | --- | +| `IMAGEFIND_DATA_DIR` | `./data` | 数据库、缩略图、模型和缓存目录 | +| `IMAGEFIND_HOST` / `IMAGEFIND_PORT` | `127.0.0.1` / `8765` | 监听地址与端口 | +| `IMAGEFIND_POSTGRES_CONF` | 数据目录上级的 `postgres-client.conf` | PostgreSQL 客户端配置文件 | +| `IMAGEFIND_DB_POOL_MIN` / `IMAGEFIND_DB_POOL_MAX` | `1` / `8` | PostgreSQL 进程内连接池上下限 | +| `IMAGEFIND_DB_POOL_TIMEOUT` | `5` | 等待可用数据库连接的最长秒数 | +| `IMAGEFIND_EMBEDDING_BACKEND` | `auto` | `auto`、`openvino`、`torch` 或测试用 `hash` | +| `IMAGEFIND_MODEL_BUNDLE_URL` | 空 | 可选:用自建模型包代替默认官方仓库 | +| `IMAGEFIND_MODEL_BUNDLE_SHA256` | 空 | 自建模型包的 SHA-256 | +| `IMAGEFIND_MODEL_HF_ENDPOINT` | `https://huggingface.co` | Hugging Face 地址,可改为可信镜像 | +| `IMAGEFIND_PIP_INDEX_URL` | `https://pypi.tuna.tsinghua.edu.cn/simple` | AI 运行依赖使用的 PyPI 主镜像 | +| `IMAGEFIND_PYTORCH_INDEX_URL` | `https://download.pytorch.org/whl/cpu` | PyTorch CPU 专用轮子源,可在设置页留空或修改 | +| `IMAGEFIND_MODEL_UPLOAD_GB` | `10` | 手动上传模型包上限(GiB) | +| `IMAGEFIND_FFMPEG_PATH` | `ffmpeg` | FFmpeg 可执行文件 | +| `IMAGEFIND_REMOTE_MAX_CONNECTIONS` | `3` | WebDAV 扫描、索引和播放的全局并发上限 | +| `IMAGEFIND_PREVIEW_CACHE_GB` | `5` | HLS 兼容预览缓存上限(GiB) | +| `IMAGEFIND_UPLOAD_CHUNK_MB` | `16` | 浏览器上传分块大小(MiB) | +| `IMAGEFIND_UPLOAD_STAGING_GB` | `100` | 未完成上传的暂存配额(GiB) | +| `IMAGEFIND_UPLOAD_RESERVE_GB` | `5` | 创建上传任务后仍需保留的磁盘空间(GiB) | +| `IMAGEFIND_TRASH_RETENTION_DAYS` | `30` | 回收站记录的默认保留天数 | + +## 远程媒体库与加密 + +通用 WebDAV 和 AList 均在“资料库 → 添加资料库”中配置。扫描器使用递归 `Depth: 1 +PROPFIND`,以 ETag、大小和修改时间识别变更。服务端不支持 Range 时,索引或播放可能退化为 +完整文件传输,建议先在小目录验证带宽占用。 + +AList 有两种互斥模式: + +- **AList 直连**:文件和文件名以明文保存在远端。播放接口向浏览器返回 AList 提供的 302 + 临时直链,视频流量通常不经过 NAS;直链解析失败时自动回退到认证代理。 +- **AList 加密**:使用 `rclone crypt` 加密文件内容、文件名和目录名。应用优先使用 fnOS/系统 + 已安装的兼容 rclone,缺失时再下载经过版本与 SHA-256 固定的私有副本。上传、 + 播放、下载、抽帧和 AI 索引均由 NAS 即时解密,因此不能再使用 302 直链,带宽会经过 NAS。 + +ImageFind 每 60 秒安全核验上次异常退出遗留的 rclone 实例。只有进程身份和应用目录匹配、连续 +3 次没有活动连接或 I/O 且观察满 2 分钟时才会自动回收;身份不一致的进程只标记为“待处理”, +不会强制终止。也可以在“设置 → 任务与偏好 → 资源保护”中执行一次立即安全核验。 + +创建加密库时浏览器会自动下载一次 `imagefind-vault-recovery-*.json`。必须把它存放在密码管理器 +或离线介质中;它含有解密所需密钥,但不包含 AList 登录密码。重装后选择“AList 加密 → 从恢复 +文件导入”,再输入当前 AList 密码,即可重新挂载原有密文。丢失恢复文件和应用内部数据后, +远端密文无法恢复。 + +AI 对加密库仍完整可用:NAS 读取并解密所需片段后执行抽帧、OCR、人物与向量分析,远端不会 +得到明文索引。代价是首次扫描和播放都需要 NAS 与网盘之间的传输。 + +也可以先在 fnOS 中挂载 WebDAV,再作为本地目录添加。这种模式通常拥有更好的断线重连和 +系统级缓存能力。 + +## 上传、下载与文件管理 + +浏览器上传采用 16 MiB 分块,每块带 SHA-256 校验,单文件 API 上限为 200 GiB,前端按当前 +产品约束提示 100 GiB。文件会先完整暂存在应用私有数据目录,再由独立后台通道传输到目标库; +上传过程中断后,服务端会保留已接收分块用于续传;上传中心分别显示“上传至 ImageFind”、 +“转存 WebDAV/AList”和“校验加入媒体库”三个阶段,失败的目标传输可直接重试。创建任务 +时若无法满足暂存配额或 5 GiB 安全余量,接口会拒绝任务而不是写满系统盘。 + +开启飞牛“直接 Web/API 访问”后,可在“设置 → 账户与 API”启用 WebDAV 后台上传。标准 +WebDAV 客户端必须连接 `http://NAS_IP:8765/webdav/`(端口以应用配置为准),用户名固定为 +`imagefind`,密码使用页面创建且只显示一次的专用 REST API Token。飞牛 5666 统一网页网关 +不转发标准 DAV 认证和方法,不能作为 WebDAV 服务器地址。上传路径的第一层目录会自动映射为 +同名合集,后续目录映射为可任意嵌套的章节或小节;已入库视频可浏览和读取。服务端兼容普通 +`PUT`、临时文件加 `MOVE`、`Content-Range` 与 `Upload-Offset` 续传。完整上传会在接收时增量 +校验且不再二次扫描暂存文件;客户端因响应丢失重传相同路径和内容时会幂等返回,不会再生成 +`(2)`、`(3)` 副本。文件接收完成到媒体入库之间,`HEAD`/`PROPFIND` 仍会返回已接收状态, +避免客户端因短暂 404 重新上传。 + +本地目录默认只读,必须在资料库卡片上主动启用写入;AList 资料库默认可写。文件面板支持浏览、 +新建目录、移动/重命名和移入回收站。回收站内可恢复或永久删除;卸载应用不会删除共享目录、 +已授权目录或远端库中的源视频。播放接口支持 HTTP Range;AList 直连下载/播放可使用 302, +其余来源经 ImageFind 认证代理读取,下载会保留原文件名。 + +后台任务按 AI 识别、上传转存、后台下载和来源扫描分为四条独立通道,各自保持单并发。这样 +大文件上云不会阻塞索引,同时避免低功耗 NAS 因多个 AI 或磁盘任务并发而耗尽内存。 + +## 自定义分类与 AI 标签 + +“分类与标签”支持创建任意分类组,例如类型、场景、服装、片商或自定义收藏维度。每个组可设置为 +单选或多选;影片资料编辑和批量赋值都会遵守该约束。标签可以移动、合并和删除,旧版字符串标签 +在升级时会自动迁移到“未分组”。 + +每个标签都可以独立启用一种 AI 建议方式: + +- **画面语义**:使用已生成的 CLIP 画面向量与自定义描述做零样本匹配,适合能从画面判断的场景、 + 服装和视觉类型。 +- **文本规则**:匹配文件名、路径、影片资料、OCR 和文本字幕,适合番号、片商、系列或明确关键词。 + +AI 结果只会进入“待确认建议”,不会直接覆盖人工标签。索引新视频完成后会自动排队分析已启用 +AI 的标签,也可从影片资料或分类页手动重跑。服务启动、状态检查和仅浏览页面都不会加载模型; +模型仍然只在实际索引、图片/语义检索或手动分析需要时延迟初始化。自定义标签的准确度取决于描述、 +阈值和素材,不能可靠由画面判断的抽象标签应使用文本规则或保持手动。 + +## 系统备份与恢复 + +“设置 → 系统备份与恢复”可下载单一 `.ifbackup` 加密文件。每个备份都包含本地、WebDAV 与 +AList 来源配置、登录凭据和 rclone crypt 密钥;完整范围还包含影片身份、手工资料、分类与标签、 +演员、收藏、喜欢、播放进度、偏好和模型/运行依赖镜像配置。备份密码独立于管理员密码,长度为 10–256 个 +字符,不会持久化;忘记密码后无法解密。 + +恢复前需在新系统创建管理员并登录。只有没有来源、影片、AI 人物、演员或用户标签的空系统可以 +恢复。当前管理员、会话和 API Token 不会被导入或覆盖;媒体文件、模型、缩略图、OCR、人物聚类、 +向量与其他可重建索引也不进入备份。恢复后影片先处于不可播放的待扫描状态,启用的来源会自动排队 +扫描,并按来源 ID 与源文件键重新关联原影片 ID。 + +## fnOS 视频目录 + +安装或升级后,fnOS 会创建共享目录 `imagefind/videos`,应用运行用户会自动获得访问权限。 +可以把待索引视频放入该目录,再在 ImageFind 中将它添加为本地资料库。 + +如果视频已经位于其他 NAS 目录,无需搬动文件:在 fnOS 的 ImageFind 应用设置中授权这些 +目录,再在 ImageFind 中添加对应路径。`imagefind/videos` 是便于直接导入文件的应用共享目录, +授权目录可用于只读索引已有媒体库;只有在 ImageFind 内明确启用写入后,文件管理和上传操作 +才允许修改该目录。AI 索引过程本身不会修改源视频。 + +fnOS 全新安装时必须输入两次 ImageFind 管理员密码。升级向导中的密码可以留空以保留 +现有密码,也可以填写两次来初始化或重置密码。卸载向导默认保留应用数据;勾选清除后会 +删除数据库、索引、缓存、模型、凭据和日志,但不会删除 `imagefind/videos`、用户授权目录 +或 WebDAV 上的任何源视频。 + +## 播放与 HLS 兼容预览 + +搜索结果优先通过认证后的 Range 接口直接播放,并跳转到命中时间。浏览器不支持源视频的 +封装格式或编码时,前端会自动请求命中点附近的 HLS 兼容预览。预览默认最长 180 秒,优先 +使用 Intel VAAPI,随后尝试 OpenH264 或 libx264;缓存达到上限后按最久未使用顺序清理。 + +生成兼容预览会消耗额外 CPU/GPU 和临时空间。远程源还会读取对应时间段;WebDAV 服务端若 +忽略 Range,可能退化为完整文件传输。 + +## 模型包约定 + +fnOS 安装包包含 Web 服务与 PostgreSQL 客户端运行依赖,数据库和 pgvector 由声明的 +`nxsir.postgresql` fnOS 依赖应用提供;OpenVINO、PyTorch 等**推理运行时** +与体积更大的**模型权重**不随包发布,而是在首次安装对应 AI 组件时按需下载。设置页的在线安装 +会从 Hugging Face 下载画面/多语言模型及 Whisper small,从 Open Model Zoo 下载人物模型。Whisper +会导出为 OpenVINO FP16;安装后以单任务串行方式自动补齐旧影片的带时间轴语音索引。应用启动 +和状态检查都不会加载模型到内存,只有搜索或索引实际需要时才延迟初始化。 + +语音识别默认采用“中文优先 + 准确率优先”:先读取音轨语言,再从最多 3 个高语音占比片段检测, +无法判断时使用中文,并以 5-beam 解码。每个时间片段会经过字符损坏、异常文字脚本、重复输出、 +无语音幻觉和文本密度校验,低质量片段不会进入搜索。设置页可切换智能检测、固定中文和三档质量, +也可扫描存量旧转写;明显低质量的视频会自动排队重新识别。 + +“设置 → AI 与网络”可分别配置 Hugging Face、PyPI 和 PyTorch CPU 下载源。PyPI 默认使用已直连 +验证的清华镜像;HTTP/HTTPS 代理有独立开关,关闭后保留加密凭据但所有 AI 下载直接连接,启用后 +才用于模型与运行依赖下载。pip 安装期间每隔约 5 秒更新阶段和等待时间,任务重启排队时会重置为 +0%,避免把等待中的组件误显示为卡住。 + +无法直接访问官方仓库时,可以在其他电脑准备下述 ImageFind 模型包,再通过设置页“上传模型包” +手动安装。服务端以流式方式写入应用数据目录,不会先占用 fnOS 的 `/tmp`;安装前会检查压缩包 +路径、解压体积和必需目录,并以原子方式替换现有模型。 + +模型包是 `.tar.gz`,根目录必须包含 `manifest.json` 以及: + +```text +visual/image/ # clip-ViT-B-32 SentenceTransformer 模型 +visual/text/ # clip-ViT-B-32-multilingual-v1 文本模型 +ocr/ # 可选:det.onnx、rec.onnx、cls.onnx +faces/ # 可选:detector.xml/.bin、reidentification.xml/.bin +audio/ # 可选:Whisper small OpenVINO FP16 模型和处理器配置 +``` + +视觉模型变化后需要重建画面向量;应用不会静默切换向量空间。 + +生成模型包不会修改源模型目录: + +```bash +python3 scripts/build-model-bundle.py /path/to/models dist/imagefind-models.tar.gz --version 1 +``` + +## 构建 fnOS 安装包 + +发布构建只面向 x86_64 fnOS,需要官方 `fnpack`、Node.js、带 Hatchling/Pillow 的构建环境, +以及 `.fnos-wheel-cache/python312` 中完整的 Python 3.12 核心 wheelhouse。fnOS 端声明依赖 +`python312`;AI 推理依赖不会进入 FPK,而是在安装对应模型时按锁文件下载。发布包只内置: + +```text +vendor/libOpenCL.so.1 +``` + +安装依赖并构建: + +```bash +python3 -m venv .release-venv +.release-venv/bin/pip install -e '.[dev]' +./scripts/build-fnos.sh +``` + +默认输出为 `dist/imagefind-0.5.45-x86_64.fpk`。FFmpeg/FFprobe 与 rclone 在运行时优先使用 +兼容的系统版本,缺失时按固定版本和 SHA-256 动态下载。0.5.45 不读取或迁移旧 SQLite 数据库; +安装时直接初始化 PostgreSQL schema,升级脚本不会擅自删除用户数据或旧 runtime 目录。 +当前仓库中的 manifest、权限和资源声明是 +侧载模板;正式发布前必须用目标 fnOS 版本配套的官方 `fnpack` 校验,并在真实 N100 设备上 +验证安装/升级向导、启停、GPU 权限、端口跳转和两种卸载数据行为。 + +解出安装包内的 `app.tgz` 后,可以在 x86_64 Linux 构建机上运行制品级冒烟: + +```bash +./scripts/smoke-fnos-package.sh /path/to/extracted/app /tmp/imagefind-smoke 18765 +``` + +## API + +登录后在“设置”创建独立 API Token。请求使用 `Authorization: Bearer `。 +OpenAPI 文档位于 `/api/docs`,主要入口是: + +- `POST /api/v1/query-images` +- `POST /api/v1/search` +- `POST /api/v1/uploads` 与 `/api/v1/uploads/{id}/chunks/{index}` +- `POST /api/v1/models/install`、`POST /api/v1/models/upload` +- `PATCH /api/v1/models/config`、`POST /api/v1/models/proxy/test`、`DELETE /api/v1/models/{component}` +- `/api/v1/sources`、`/api/v1/files`、`/api/v1/trash` +- `POST /api/v1/sources/alist/restore` +- `GET/POST /api/v1/backups`、`GET /api/v1/backups/{id}/download`、`POST /api/v1/backups/restore` +- `/api/v1/tag-groups`、`/api/v1/tags`、`POST /api/v1/videos/tags/bulk` +- `/api/v1/tag-suggestions`、`POST /api/v1/tag-suggestions/analyze`、`POST /api/v1/tag-suggestions/decide` +- `PATCH /api/v1/videos/{video_id}/state` +- `GET/PATCH /api/v1/speech/config`、`POST /api/v1/speech/reconcile` +- `GET /api/v1/videos/{video_id}/transcript`、`POST /api/v1/videos/{video_id}/transcript/reindex` +- `/api/v1/profile`、`/api/v1/series`、`POST /api/v1/videos/series/bulk` +- `GET /api/v1/storage/usage` +- `/api/v1/preferences`、`/api/v1/activity`、`GET /api/v1/events` +- `/api/v1/jobs`、`/api/v1/people`、`/api/v1/actors` +- `GET /api/v1/system/diagnostics` +- `POST /api/v1/system/resources/rclone/reconcile` + +## 隐私与范围 + +- 视频可位于本机或用户配置的远端;关键帧、人脸特征、文字、向量和密钥只保存在本机。 +- WebDAV 凭据通过应用主密钥加密;主密钥文件权限为 `0600`。 +- 首版只支持管理员;fnOS 提供稳定的用户身份 API 后再接入系统多用户 ACL。 +- 支持文本字幕、烧录在画面内的文字和带时间轴的 Whisper 语音转写;不包含 PGS/VobSub 图片字幕。 diff --git a/backend/imagefind/__init__.py b/backend/imagefind/__init__.py new file mode 100644 index 0000000..3c85243 --- /dev/null +++ b/backend/imagefind/__init__.py @@ -0,0 +1,3 @@ +"""ImageFind application package.""" + +__version__ = "0.5.45" diff --git a/backend/imagefind/accelerator.py b/backend/imagefind/accelerator.py new file mode 100644 index 0000000..92228f5 --- /dev/null +++ b/backend/imagefind/accelerator.py @@ -0,0 +1,606 @@ +from __future__ import annotations + +import logging +import os +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path +from threading import Lock + +from .config import Settings + +logger = logging.getLogger(__name__) +COMPONENTS = ("visual", "ocr", "faces", "audio") +GPU_MODES = { + "low": {"hint": "LATENCY", "streams": 1, "batch_size": 1}, + "balanced": {"hint": "THROUGHPUT", "streams": 2, "batch_size": 2}, + "throughput": {"hint": "THROUGHPUT", "streams": 3, "batch_size": 4}, +} +AUDIO_CIRCUIT_FAILURES = 3 +AUDIO_CIRCUIT_COOLDOWN_SECONDS = 600 + + +class AcceleratorService: + """Detect and track the OpenVINO device used by each AI component. + + ImageFind uses explicit GPU compilation instead of OpenVINO AUTO so the + settings page can truthfully report where inference ran. A failed GPU + component is pinned to a bounded CPU fallback until it is reset. + """ + + def __init__(self, settings: Settings): + self.settings = settings + self._lock = Lock() + self._runtime_gpu_mode: str | None = None + self._resource_pressure_samples = 0 + self._resource_recovery_samples = 0 + self._gpu_busy_previous: dict[str, tuple[int, float]] = {} + self.openvino = False + self.devices: list[str] = [] + self.device_names: dict[str, str] = {} + self.gpu_device: str | None = None + self.render_nodes: list[str] = [] + self.render_accessible = False + self.unavailable_reason = "" + self._components = { + name: { + "state": "not_loaded", + "device": None, + "requested_device": None, + "actual_device": None, + "execution_devices": [], + "failure_stage": None, + "fallback_reason": None, + "fallback_scope": None, + "circuit_state": "closed", + "failure_count": 0, + "retry_at": None, + "last_verified_at": None, + } + for name in COMPONENTS + } + self.refresh() + + def refresh(self) -> None: + nodes = sorted(Path("/dev/dri").glob("renderD*")) + self.render_nodes = [str(path) for path in nodes] + self.render_accessible = any(os.access(path, os.R_OK | os.W_OK) for path in nodes) + try: + import openvino as ov + + core = ov.Core() + devices = list(core.available_devices) + names: dict[str, str] = {} + for device in devices: + try: + names[device] = str(core.get_property(device, "FULL_DEVICE_NAME")) + except Exception: + names[device] = device + self.openvino = True + self.devices = devices + self.device_names = names + self.gpu_device = next((device for device in devices if device.upper().startswith("GPU")), None) + except ModuleNotFoundError: + logger.info("OpenVINO runtime is not installed; AI acceleration will use CPU fallbacks") + self.openvino = False + self.devices = [] + self.device_names = {} + self.gpu_device = None + except Exception: + logger.warning("OpenVINO device discovery failed", exc_info=True) + self.openvino = False + self.devices = [] + self.device_names = {} + self.gpu_device = None + + if self.gpu_device: + self.unavailable_reason = "" + elif not self.openvino: + self.unavailable_reason = "OpenVINO 运行时不可用" + elif not nodes: + self.unavailable_reason = "未检测到 Intel render 设备" + elif not self.render_accessible: + self.unavailable_reason = "Intel render 设备权限不足" + else: + self.unavailable_reason = "OpenVINO 未发现 Intel GPU,请检查核显驱动" + + logger.info( + "AI accelerator devices=%s gpu=%s render_accessible=%s", + self.devices, + self.gpu_device or "none", + self.render_accessible, + ) + + def device_for(self, component: str) -> str: + with self._lock: + state = self._components[component] + if state["state"] == "fallback": + retry_at = state.get("retry_at") + if component == "audio" and retry_at and self.gpu_device: + try: + retry_due = datetime.fromisoformat(str(retry_at)) <= datetime.now(UTC) + except ValueError: + retry_due = True + if retry_due: + state.update( + state="probing_gpu", + device=self.gpu_device, + requested_device=self.gpu_device, + actual_device=None, + execution_devices=[], + fallback_scope="component", + circuit_state="half_open", + retry_at=None, + ) + return self.gpu_device + return "CPU" + return self.gpu_device or "CPU" + + def ov_config(self, device: str) -> dict[str, object]: + if device.upper().startswith("CPU"): + return { + "INFERENCE_NUM_THREADS": self.settings.ai_cpu_threads, + "PERFORMANCE_HINT": "LATENCY", + } + cache_dir = self.settings.runtime_dir / "openvino-cache" + cache_dir.mkdir(parents=True, exist_ok=True) + mode = self.effective_gpu_mode() + profile = GPU_MODES[mode] + return { + "PERFORMANCE_HINT": profile["hint"], + "NUM_STREAMS": str(profile["streams"]), + "CACHE_DIR": str(cache_dir), + "INFERENCE_PRECISION_HINT": "f32", + } + + def gpu_profile(self) -> dict[str, object]: + selected = self.settings.ai_gpu_mode if self.settings.ai_gpu_mode in GPU_MODES else "balanced" + mode = self.effective_gpu_mode() + return {"mode": mode, "selected_mode": selected, "degraded": mode != selected, **GPU_MODES[mode]} + + def effective_gpu_mode(self) -> str: + selected = self.settings.ai_gpu_mode if self.settings.ai_gpu_mode in GPU_MODES else "balanced" + with self._lock: + return self._runtime_gpu_mode or selected + + def reset_gpu_profile(self) -> None: + with self._lock: + self._runtime_gpu_mode = None + self._resource_pressure_samples = 0 + self._resource_recovery_samples = 0 + + def apply_resource_pressure(self, pressured: bool) -> None: + """Temporarily reduce GPU concurrency under sustained host pressure.""" + + with self._lock: + if pressured: + self._resource_pressure_samples += 1 + self._resource_recovery_samples = 0 + if self._resource_pressure_samples >= 3: + selected = self.settings.ai_gpu_mode if self.settings.ai_gpu_mode in GPU_MODES else "balanced" + self._runtime_gpu_mode = "low" if selected in {"balanced", "throughput"} else selected + else: + self._resource_pressure_samples = 0 + self._resource_recovery_samples += 1 + if self._resource_recovery_samples >= 5: + self._runtime_gpu_mode = None + self._resource_recovery_samples = 0 + + def hardware_metrics(self) -> dict: + """Collect Intel DRM engine utilization without inventing VRAM data.""" + + now = time.monotonic() + engines: dict[str, float | None] = {} + busy_files = sorted(Path("/sys/class/drm").glob("card*/engine/*/busy")) + for path in busy_files: + try: + value = int(path.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + continue + key = f"{path.parents[2].name}/{path.parent.name}" + previous = self._gpu_busy_previous.get(str(path)) + utilization = None + if previous is not None and now > previous[1] and value >= previous[0]: + utilization = max(0.0, min(100.0, (value - previous[0]) / ((now - previous[1]) * 1_000_000_000) * 100)) + self._gpu_busy_previous[str(path)] = (value, now) + engines[key] = round(utilization, 1) if utilization is not None else None + sampled = [value for value in engines.values() if value is not None] + utilization = max(sampled) if sampled else None + memory_total = memory_used = None + for card in sorted(Path("/sys/class/drm").glob("card*/device")): + total_path = card / "mem_info_vram_total" + used_path = card / "mem_info_vram_used" + try: + total_value = int(total_path.read_text(encoding="utf-8").strip()) + used_value = int(used_path.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + continue + memory_total = max(0, total_value) + memory_used = max(0, used_value) + break + supported = bool(engines) + reason = None + if not supported: + reason = ( + "Intel DRM 未提供 engine busy 指标" + if self.gpu_device + else self.unavailable_reason or "未检测到可用 GPU" + ) + return { + "supported": supported, + "utilization_percent": utilization, + "engines": engines, + "memory_supported": memory_total is not None, + "memory_total_bytes": memory_total, + "memory_used_bytes": memory_used, + "collector": "drm_sysfs" if supported else None, + "reason": reason, + "collected_at": datetime.now(UTC).isoformat(), + } + + def _degrade_gpu_profile(self) -> None: + order = ("low", "balanced", "throughput") + current = self.effective_gpu_mode() + index = order.index(current) + if index <= 0: + return + with self._lock: + self._runtime_gpu_mode = order[index - 1] + logger.warning("GPU profile degraded from %s to %s after inference failure", current, order[index - 1]) + + @staticmethod + def _now() -> str: + return datetime.now(UTC).isoformat() + + @staticmethod + def _normalise_execution_devices(devices) -> list[str]: + if devices is None: + return [] + if isinstance(devices, str): + values = [devices] + else: + try: + values = list(devices) + except TypeError: + values = [devices] + result = [] + for value in values: + name = str(value).strip() + if name and name not in result: + result.append(name) + return result + + @classmethod + def execution_devices_from(cls, *roots) -> list[str]: + """Read EXECUTION_DEVICES from OpenVINO/Optimum model wrappers.""" + + devices: list[str] = [] + seen: set[int] = set() + pending = [root for root in roots if root is not None] + attribute_names = ( + "model", + "auto_model", + "compiled_model", + "request", + "encoder", + "decoder", + "decoder_with_past", + ) + while pending: + value = pending.pop() + identity = id(value) + if identity in seen: + continue + seen.add(identity) + try: + reported = value.get_property("EXECUTION_DEVICES") + # OpenVINO plugins do not expose exactly the same property set. + # In particular, some Intel GPU driver/runtime combinations raise + # ValueError rather than RuntimeError for EXECUTION_DEVICES even + # though the compiled request itself is valid. Treat that branch + # as non-reporting and continue inspecting the other wrappers. + except (AttributeError, RuntimeError, TypeError, ValueError): + reported = None + for device in cls._normalise_execution_devices(reported): + if device not in devices: + devices.append(device) + try: + compiled = value.get_compiled_model() + except (AttributeError, RuntimeError, TypeError, ValueError): + compiled = None + if compiled is not None: + pending.append(compiled) + for name in attribute_names: + try: + child = getattr(value, name) + except (AttributeError, RuntimeError, ValueError): + continue + if child is not None and child is not value: + pending.append(child) + try: + modules = list(value.children()) + except (AttributeError, RuntimeError, TypeError, ValueError): + modules = [] + pending.extend(modules) + return devices + + def mark_ready(self, component: str, device: str, execution_devices=None) -> None: + actual_devices = self._normalise_execution_devices(execution_devices) or [device] + actual = " / ".join(actual_devices) + with self._lock: + prior_reason = self._components[component].get("fallback_reason") + self._components[component] = { + "state": "fallback" if prior_reason and device == "CPU" else "ready", + "device": actual, + "requested_device": device, + "actual_device": actual, + "execution_devices": actual_devices, + "failure_stage": self._components[component].get("failure_stage") if device == "CPU" else None, + "fallback_reason": prior_reason if device == "CPU" else None, + "fallback_scope": self._components[component].get("fallback_scope") if device == "CPU" else None, + "circuit_state": self._components[component].get("circuit_state", "closed"), + "failure_count": int(self._components[component].get("failure_count") or 0), + "retry_at": self._components[component].get("retry_at"), + "last_verified_at": self._now(), + } + + def mark_inference_success(self, component: str, device: str, execution_devices=None) -> None: + """Close a transient circuit only after inference, not merely model compilation.""" + + actual_devices = self._normalise_execution_devices(execution_devices) or [device] + actual = " / ".join(actual_devices) + with self._lock: + self._components[component] = { + "state": "ready", + "device": actual, + "requested_device": device, + "actual_device": actual, + "execution_devices": actual_devices, + "failure_stage": None, + "fallback_reason": None, + "fallback_scope": None, + "circuit_state": "closed", + "failure_count": 0, + "retry_at": None, + "last_verified_at": self._now(), + } + + def mark_verifying_cpu( + self, + component: str, + reason: str = "GPU 未返回文字,正在使用 CPU 复核", + *, + stage: str = "empty_result", + ) -> None: + """Expose a bounded CPU cross-check without pinning the component to CPU. + + Empty Whisper output can be either valid no-speech content or a GPU + generation defect. The verifier is deliberately transient: callers + still get the preferred GPU from :meth:`device_for` until a CPU sample + proves that the GPU result was wrong. + """ + + with self._lock: + self._components[component] = { + "state": "verifying_cpu", + "device": "CPU", + "requested_device": self.gpu_device, + "actual_device": "CPU", + "execution_devices": ["CPU"], + "failure_stage": stage, + "fallback_reason": reason[:160], + "fallback_scope": "job", + "circuit_state": self._components[component].get("circuit_state", "closed"), + "failure_count": int(self._components[component].get("failure_count") or 0), + "retry_at": self._components[component].get("retry_at"), + "last_verified_at": self._now(), + } + + def record_transient_failure( + self, + component: str, + reason: BaseException | str, + *, + stage: str = "inference", + ) -> bool: + """Record one task-local device failure and open the audio circuit after a threshold.""" + + if component != "audio": + return self.fall_back(component, reason, stage=stage) + detail = type(reason).__name__ if isinstance(reason, BaseException) else str(reason).strip()[:120] + detail = detail or "未知错误" + with self._lock: + current = self._components[component] + failures = int(current.get("failure_count") or 0) + 1 + if failures < AUDIO_CIRCUIT_FAILURES: + current.update( + state="job_fallback", + device="CPU", + requested_device=self.gpu_device, + actual_device="CPU", + execution_devices=["CPU"], + failure_stage=stage, + fallback_reason=f"当前任务 GPU 推理失败,使用 CPU({detail})", + fallback_scope="job", + circuit_state="closed", + failure_count=failures, + retry_at=None, + last_verified_at=self._now(), + ) + logger.warning( + "AI component %s task-local GPU failure %s/%s: %s", + component, + failures, + AUDIO_CIRCUIT_FAILURES, + detail, + ) + return False + retry_at = datetime.now(UTC) + timedelta(seconds=AUDIO_CIRCUIT_COOLDOWN_SECONDS) + current.update( + state="fallback", + device="CPU", + requested_device=self.gpu_device, + actual_device="CPU", + execution_devices=["CPU"], + failure_stage=stage, + fallback_reason=f"GPU 连续推理失败,暂时回退 CPU({detail})", + fallback_scope="component", + circuit_state="open", + failure_count=failures, + retry_at=retry_at.isoformat(), + last_verified_at=self._now(), + ) + logger.warning("AI component %s circuit opened after %s failures: %s", component, failures, detail) + return True + + def fall_back(self, component: str, reason: BaseException | str, *, stage: str = "inference") -> bool: + """Pin one GPU component to CPU and report whether a retry is useful.""" + + with self._lock: + current = self._components[component] + # A temporary CPU verification reports device=CPU as well, but it + # must still be promotable to a real, persistent fallback when the + # comparison sample contains speech that GPU failed to return. + if current.get("state") == "fallback": + return False + if isinstance(reason, BaseException): + detail = f"{type(reason).__name__}" + else: + detail = str(reason).strip()[:120] or "未知错误" + current.update( + state="fallback", + device="CPU", + requested_device=self.gpu_device, + actual_device="CPU", + execution_devices=["CPU"], + failure_stage=stage, + fallback_reason=f"GPU 推理失败,已回退 CPU({detail})", + fallback_scope="component", + circuit_state="open" if component == "audio" else "closed", + failure_count=max(1, int(current.get("failure_count") or 0)), + retry_at=( + (datetime.now(UTC) + timedelta(seconds=AUDIO_CIRCUIT_COOLDOWN_SECONDS)).isoformat() + if component == "audio" + else None + ), + last_verified_at=self._now(), + ) + if stage in {"encoder_compile", "decoder_compile", "inference", "inference_stall"}: + self._degrade_gpu_profile() + logger.warning("AI component %s fell back from GPU to CPU: %s", component, detail) + return True + + @staticmethod + def is_device_error(reason: BaseException) -> bool: + """Only retry errors that plausibly originate from OpenVINO device compilation/inference.""" + + module = type(reason).__module__.lower() + name = type(reason).__name__.lower() + text = str(reason).lower() + if "huggingface" in module or "hfvalidation" in name or name == "modelunavailable": + return False + non_device = ("repo id", "repository id", "modules.json", "config.json", "local path", "not a valid model") + if any(value in text for value in non_device): + return False + # Python bindings for Intel GPU compilation and property discovery can + # surface plugin failures as ValueError. The same model is still worth + # retrying on CPU; Hugging Face/configuration ValueErrors were excluded + # above so genuine model layout problems remain visible. + if isinstance(reason, ValueError): + return True + device_markers = ( + "openvino", + "gpu", + "device", + "compile_model", + "cldnn", + "level zero", + "ze_result", + "intel", + ) + return "openvino" in module or any(value in text for value in device_markers) + + def mark_unavailable( + self, + component: str, + reason: BaseException | str, + *, + stage: str = "inference", + ) -> None: + detail = type(reason).__name__ if isinstance(reason, BaseException) else str(reason).strip()[:120] + with self._lock: + self._components[component] = { + "state": "unavailable", + "device": None, + "requested_device": self.gpu_device or "CPU", + "actual_device": None, + "execution_devices": [], + "failure_stage": stage, + "fallback_reason": detail or "模型不可用", + "fallback_scope": "component", + "circuit_state": "open" if component == "audio" else "closed", + "failure_count": int(self._components[component].get("failure_count") or 0), + "retry_at": None, + "last_verified_at": self._now(), + } + + def reset(self, component: str) -> None: + with self._lock: + previous = self._components[component] + self._components[component] = { + "state": "not_loaded", + "device": None, + # Releasing a model only means it is no longer resident in + # memory. Keep the last verified device as observability + # history so a completed stage does not immediately revert + # to the misleading "never loaded" state in the UI. + "requested_device": previous.get("requested_device"), + "actual_device": previous.get("actual_device"), + "execution_devices": list(previous.get("execution_devices") or []), + "failure_stage": None, + "fallback_reason": None, + "fallback_scope": None, + "circuit_state": "closed", + "failure_count": 0, + "retry_at": None, + "last_verified_at": previous.get("last_verified_at"), + } + + def merge_worker_status(self, status: dict) -> None: + """Mirror observable state reported by the isolated inference worker.""" + + components = status.get("components") + if not isinstance(components, dict): + return + with self._lock: + for name in COMPONENTS: + value = components.get(name) + if isinstance(value, dict): + self._components[name] = dict(value) + + def status(self) -> dict: + with self._lock: + components = {name: dict(value) for name, value in self._components.items()} + active = sorted({value["device"] for value in components.values() if value.get("device")}) + if active: + selected = " / ".join(active) + elif self.gpu_device: + selected = "Intel GPU 优先(首次推理时加载)" + else: + selected = "CPU(未检测到可用 Intel GPU)" + return { + "policy": "gpu_preferred", + "openvino": self.openvino, + "devices": list(self.devices), + "device_names": dict(self.device_names), + "selected": selected, + "render_device": { + "available": bool(self.render_nodes), + "accessible": self.render_accessible, + "count": len(self.render_nodes), + }, + "unavailable_reason": self.unavailable_reason or None, + "cpu_threads": self.settings.ai_cpu_threads, + "gpu_profile": self.gpu_profile(), + "components": components, + } diff --git a/backend/imagefind/ai.py b/backend/imagefind/ai.py new file mode 100644 index 0000000..3085352 --- /dev/null +++ b/backend/imagefind/ai.py @@ -0,0 +1,714 @@ +from __future__ import annotations + +import hashlib +import logging +import math +import threading +from contextlib import contextmanager +from pathlib import Path + +from PIL import Image + +from .accelerator import AcceleratorService +from .config import Settings +from .model_validation import ( + sentence_transformer_module_path, + validate_face_model_root, + validate_openvino_ir_directory, + validate_sentence_transformer_root, +) +from .text import search_tokens +from .vectors import normalize + +logger = logging.getLogger(__name__) +_OPTIMUM_LOCAL_LOAD_LOCK = threading.Lock() + + +class ModelUnavailable(RuntimeError): + pass + + +class VisualModelStageError(RuntimeError): + """Keep the failing visual tower visible without hiding its root cause.""" + + def __init__(self, stage: str, label: str, reason: BaseException): + self.stage = stage + self.reason = reason + detail = " ".join(str(reason).split()) + suffix = f":{detail}" if detail else "" + super().__init__(f"{label}加载失败:{type(reason).__name__}{suffix}") + + +@contextmanager +def _allow_existing_absolute_local_hf_paths(): + """Let Optimum/HF validators accept fnOS absolute model paths. + + Several Optimum 2.x loaders still pass local ``/vol1/...`` paths through + Hugging Face repo-id validation, even when the caller has already set + ``local_files_only=True`` and supplied a validated OpenVINO IR directory. + Keep the bypass scoped to this local load window and only accept paths that + already exist on disk. + """ + + try: + from huggingface_hub.utils import _validators + except Exception: + yield + return + + original = _validators.validate_repo_id + + def validate_repo_id(repo_id): + if isinstance(repo_id, (str, Path)): + try: + candidate = Path(repo_id) + except TypeError: + candidate = None + if candidate is not None and candidate.is_absolute() and candidate.exists(): + return None + return original(repo_id) + + _validators.validate_repo_id = validate_repo_id + try: + yield + finally: + _validators.validate_repo_id = original + + +class _OpenVINOClipImageEncoder: + """Small ``encode`` adapter around Optimum's CLIP OpenVINO model. + + SentenceTransformers' generic OpenVINO backend is designed around text + feature-extraction inputs. A CLIP image tower needs the dedicated + zero-shot image model so that ``pixel_values`` and ``image_embeds`` are + preserved during export and inference. + """ + + def __init__(self, path: Path, device: str, ov_config: dict[str, object]): + export = path / "openvino" + try: + validate_openvino_ir_directory(export, "画面语义图像") + except ValueError as exc: + raise ModelUnavailable(str(exc)) from exc + try: + from optimum.intel.openvino import OVModelForZeroShotImageClassification + from transformers import AutoConfig, AutoProcessor + except ImportError as exc: + raise ModelUnavailable("缺少 CLIP OpenVINO 图像运行依赖") from exc + processor_path = sentence_transformer_module_path(path, "CLIPModel") + with _allow_existing_absolute_local_hf_paths(): + config = AutoConfig.from_pretrained( + str(processor_path.resolve()), local_files_only=True + ) + # Optimum 2.x's public ``from_pretrained`` always calls + # ``TasksManager.infer_library_from_model`` before delegating to the + # OpenVINO loader. On fnOS an absolute ``/vol1/...`` model path can be + # handed to huggingface_hub as though it were a repository id, even + # when ``local_files_only`` is set. The IR directory has already been + # validated above and the Transformers config is explicit, so enter + # the local loader directly and make any Hub/library inference + # impossible. + self.model = OVModelForZeroShotImageClassification._from_pretrained( + str(export.resolve()), + config=config, + file_name="openvino_model.xml", + local_files_only=True, + device=device, + ov_config=ov_config, + ) + self.processor = AutoProcessor.from_pretrained( + str(processor_path.resolve()), local_files_only=True + ) + + def encode(self, images, normalize_embeddings: bool = True): + import numpy as np + + values = list(images) + inputs = self.processor( + text=[""] * len(values), + images=values, + return_tensors="pt", + padding=True, + ) + output = self.model(**inputs).image_embeds + if hasattr(output, "detach"): + output = output.detach() + if hasattr(output, "cpu"): + output = output.cpu() + vectors = np.asarray(output, dtype=np.float32) + if normalize_embeddings: + norms = np.linalg.norm(vectors, axis=1, keepdims=True) + vectors = vectors / np.maximum(norms, np.finfo(np.float32).eps) + return vectors + + +def _load_local_openvino_sentence_transformer(model_class, path: Path, kwargs: dict): + """Load a persisted text IR without Optimum's Hub library inference. + + SentenceTransformer delegates its Transformer module to + ``OVModelForFeatureExtraction.from_pretrained``. Optimum 2.x performs Hub + library detection in that public method even for an existing absolute + fnOS ``/vol1/...`` directory. Intercept only that construction call and + enter the already validated local IR loader directly. + """ + + try: + from optimum.intel.openvino import OVModelForFeatureExtraction + except ImportError as exc: + raise ModelUnavailable("缺少 CLIP OpenVINO 文本运行依赖") from exc + + model_type = OVModelForFeatureExtraction + inherited = "from_pretrained" not in vars(model_type) + original = vars(model_type).get("from_pretrained") + + @classmethod + def from_local_ir(cls, model_id, config=None, export=False, **model_kwargs): + if export: + raise ModelUnavailable("画面语义文本禁止在推理阶段重新导出") + return cls._from_pretrained(model_id=model_id, config=config, **model_kwargs) + + # The override is process-global, so serialize the very short module + # construction window and restore the exact original descriptor. + with _OPTIMUM_LOCAL_LOAD_LOCK: + model_type.from_pretrained = from_local_ir + try: + with _allow_existing_absolute_local_hf_paths(): + return model_class(str(path), **kwargs) + finally: + if inherited: + delattr(model_type, "from_pretrained") + else: + model_type.from_pretrained = original + + +class EmbeddingService: + """Lazy visual/text encoder. + + A release model bundle contains two SentenceTransformer-compatible directories. + The multilingual text tower is trained to match the CLIP image tower's space. + """ + + dimensions = 512 + + def __init__(self, settings: Settings, accelerator: AcceleratorService | None = None): + self.settings = settings + self.accelerator = accelerator or AcceleratorService(settings) + self._image_model = None + self._text_model = None + + @property + def image_path(self) -> Path: + return self.settings.models_dir / "visual" / "image" + + @property + def text_path(self) -> Path: + return self.settings.models_dir / "visual" / "text" + + def status(self) -> dict: + exported = False + if self.image_path.exists() and self.text_path.exists(): + try: + validate_openvino_ir_directory( + self.image_path / "openvino", "画面语义图像" + ) + text_module = sentence_transformer_module_path(self.text_path, "Transformer") + validate_openvino_ir_directory( + text_module / "openvino", "画面语义文本" + ) + exported = True + except ValueError: + pass + return { + "backend": self.settings.embedding_backend, + "visual_ready": self.settings.embedding_backend == "hash" + or (self.image_path.exists() and self.text_path.exists()), + "image_model": self.settings.visual_image_model, + "text_model": self.settings.visual_text_model, + "openvino_exported": exported, + } + + def _load(self) -> None: + if self.settings.embedding_backend == "hash": + return + if not self.image_path.exists() or not self.text_path.exists(): + raise ModelUnavailable("视觉模型尚未下载,请先在设置中安装模型包") + try: + from sentence_transformers import SentenceTransformer + except ImportError as exc: + raise ModelUnavailable("缺少 AI 运行依赖,请安装 imagefind[ai]") from exc + if self._image_model is None: + validate_sentence_transformer_root(self.image_path, "画面语义图像") + validate_sentence_transformer_root(self.text_path, "画面语义文本") + backend = "openvino" if self.settings.embedding_backend in {"auto", "openvino"} else "torch" + device = self.accelerator.device_for("visual") if backend == "openvino" else "CPU" + try: + self._build_models(SentenceTransformer, backend, device) + except Exception as exc: + self._image_model = None + self._text_model = None + reason = exc.reason if isinstance(exc, VisualModelStageError) else exc + stage = exc.stage if isinstance(exc, VisualModelStageError) else "encoder_compile" + if ( + backend == "openvino" + and device != "CPU" + and self.accelerator.is_device_error(reason) + and self.accelerator.fall_back("visual", reason, stage=stage) + ): + logger.warning("visual GPU model load failed; retrying on CPU", exc_info=True) + try: + self._build_models(SentenceTransformer, backend, "CPU") + except Exception as cpu_exc: + self._image_model = None + self._text_model = None + cpu_reason = ( + cpu_exc.reason if isinstance(cpu_exc, VisualModelStageError) else cpu_exc + ) + cpu_stage = ( + cpu_exc.stage + if isinstance(cpu_exc, VisualModelStageError) + else "encoder_compile" + ) + self.accelerator.mark_unavailable( + "visual", cpu_reason, stage=cpu_stage + ) + raise + else: + self.accelerator.mark_unavailable("visual", reason, stage=stage) + raise + + def _build_models(self, model_class, backend: str, device: str) -> None: + image_path = self.image_path.resolve() + text_path = self.text_path.resolve() + # Optimum's public examples and Intel GPU plugin use the canonical GPU + # alias. Core discovery may return GPU.0, which remains useful for + # reporting but has caused inconsistent Python-binding errors when + # passed through higher-level model wrappers. + compile_device = "GPU" if device.upper().startswith("GPU") else device + + def load_text(path: Path): + kwargs: dict[str, object] = {"backend": backend, "local_files_only": True} + if backend == "openvino": + transformer_path = sentence_transformer_module_path(path, "Transformer") + export = transformer_path / "openvino" + try: + validate_openvino_ir_directory(export, "画面语义文本") + except ValueError as exc: + raise ModelUnavailable(str(exc)) from exc + model_kwargs: dict[str, object] = { + "device": compile_device, + "ov_config": self.accelerator.ov_config(device), + # SentenceTransformers otherwise scans the wrapper and may + # silently ask Optimum to export again. Point it at the + # exact persisted IR and make runtime export impossible. + "export": False, + "file_name": "openvino_model.xml", + # SentenceTransformer instantiates this module with the + # resolved Transformer module directory (for example + # ``text/0_Transformer``), not with the wrapper root. + # Supplying ``0_Transformer/openvino`` here duplicates the + # module segment and makes Optimum fall through to Hub + # repository validation. The IR is always directly below + # the module directory. + "subfolder": "openvino", + } + kwargs["model_kwargs"] = model_kwargs + if backend == "openvino" and str(getattr(model_class, "__module__", "")).startswith( + "sentence_transformers" + ): + return _load_local_openvino_sentence_transformer(model_class, path, kwargs) + return model_class(str(path), **kwargs) + + if backend == "openvino": + try: + self._image_model = _OpenVINOClipImageEncoder( + image_path, + compile_device, + self.accelerator.ov_config(device), + ) + except ModelUnavailable: + raise + except Exception as exc: + raise VisualModelStageError( + "image_encoder_compile", "画面语义图像 OpenVINO", exc + ) from exc + else: + self._image_model = model_class(str(image_path), backend=backend, local_files_only=True) + try: + self._text_model = load_text(text_path) + except ModelUnavailable: + raise + except Exception as exc: + raise VisualModelStageError( + "text_encoder_compile", "画面语义文本 OpenVINO", exc + ) from exc + reader = getattr(self.accelerator, "execution_devices_from", None) + execution_devices = reader(self._image_model, self._text_model) if callable(reader) else [device] + if device.upper().startswith("GPU") and not any( + str(actual).upper().startswith("GPU") for actual in execution_devices + ): + raise RuntimeError("OpenVINO 未确认画面语义模型在 GPU 上执行") + self.accelerator.mark_ready("visual", device, execution_devices) + + def _retry_cpu(self, kind: str, value, error: Exception): + if self.accelerator.device_for("visual") == "CPU": + raise error + self.accelerator.fall_back("visual", error, stage="inference") + self._image_model = None + self._text_model = None + self._load() + model = self._text_model if kind == "text" else self._image_model + return model.encode([value], normalize_embeddings=True)[0] + + @staticmethod + def _hash_vector(data: bytes) -> list[float]: + values = [0.0] * 512 + digest = hashlib.shake_256(data).digest(2048) + for index, byte in enumerate(digest): + values[index % 512] += (byte - 127.5) / 127.5 + return normalize(values) + + def encode_text(self, text: str) -> list[float]: + if self.settings.embedding_backend == "hash": + values = [0.0] * self.dimensions + for token in search_tokens(text): + digest = hashlib.sha256(token.encode()).digest() + index = int.from_bytes(digest[:2], "big") % self.dimensions + values[index] += 1 if digest[2] & 1 else -1 + return normalize(values) + self._load() + try: + vector = self._text_model.encode([text], normalize_embeddings=True)[0] + except Exception as exc: + vector = self._retry_cpu("text", text, exc) + return [float(value) for value in vector] + + def encode_image(self, image: Image.Image | Path) -> list[float]: + if isinstance(image, Path): + with Image.open(image) as opened: + return self.encode_image(opened.convert("RGB")) + if self.settings.embedding_backend == "hash": + sample = image.convert("RGB").resize((32, 32)).tobytes() + return self._hash_vector(sample) + self._load() + converted = image.convert("RGB") + try: + vector = self._image_model.encode([converted], normalize_embeddings=True)[0] + except Exception as exc: + vector = self._retry_cpu("image", converted, exc) + return [float(value) for value in vector] + + def encode_images(self, images: list[Image.Image | Path]) -> list[list[float]]: + """Encode a bounded batch while keeping input order. + + OpenVINO and SentenceTransformers both benefit from a small batch. The + caller controls the batch size from the GPU profile; paths are opened + here so file handles never escape the call. + """ + if not images: + return [] + if self.settings.embedding_backend == "hash": + return [self.encode_image(image) for image in images] + self._load() + opened: list[Image.Image] = [] + try: + for image in images: + if isinstance(image, Path): + with Image.open(image) as source: + opened.append(source.convert("RGB")) + else: + opened.append(image.convert("RGB")) + try: + vectors = self._image_model.encode(opened, normalize_embeddings=True) + except Exception as exc: + if self.accelerator.device_for("visual") == "CPU": + raise + self.accelerator.fall_back("visual", exc, stage="inference") + self._image_model = None + self._text_model = None + self._load() + vectors = self._image_model.encode(opened, normalize_embeddings=True) + return [[float(value) for value in vector] for vector in vectors] + finally: + for image in opened: + image.close() + + def verify_acceleration(self) -> dict: + """Load both persisted IR towers and verify finite normalized vectors.""" + + self.reset() + vectors = ( + self.encode_text("ImageFind Intel GPU verification"), + self.encode_image(Image.new("RGB", (32, 32), (103, 145, 244))), + ) + for vector in vectors: + norm = math.sqrt(sum(value * value for value in vector)) + if len(vector) != self.dimensions or not all(math.isfinite(value) for value in vector): + raise RuntimeError("画面语义模型输出不是有效的 512 维向量") + if not 0.98 <= norm <= 1.02: + raise RuntimeError("画面语义模型输出未正确归一化") + return self.accelerator.status()["components"]["visual"] + + def reset(self) -> None: + self._image_model = None + self._text_model = None + self.accelerator.reset("visual") + + +class OcrService: + def __init__(self, settings: Settings, accelerator: AcceleratorService | None = None): + self.settings = settings + self.accelerator = accelerator or AcceleratorService(settings) + self._engine = None + self._engine_device: str | None = None + + def ready(self) -> bool: + root = self.settings.models_dir / "ocr" + return (root / "det.onnx").is_file() and (root / "rec.onnx").is_file() + + def recognize(self, image: Path) -> list[tuple[str, float]]: + if not self.ready(): + return [] + if self._engine is None: + device = self.accelerator.device_for("ocr") + try: + self._build_engine(device) + except Exception as exc: + if ( + device != "CPU" + and self.accelerator.is_device_error(exc) + and self.accelerator.fall_back("ocr", exc, stage="encoder_compile") + ): + logger.warning("OCR GPU load failed; retrying on CPU", exc_info=True) + try: + self._build_engine("CPU") + except Exception as retry_exc: + self.accelerator.mark_unavailable("ocr", retry_exc, stage="encoder_compile") + raise + else: + self.accelerator.mark_unavailable("ocr", exc, stage="encoder_compile") + raise + try: + result, _ = self._engine(str(image)) + except Exception as exc: + if self._engine_device != "CPU" and self.accelerator.fall_back("ocr", exc, stage="inference"): + self._engine = None + self._build_engine("CPU") + result, _ = self._engine(str(image)) + else: + self.accelerator.mark_unavailable("ocr", exc, stage="inference") + raise + if not result: + return [] + return [(str(line[1]), float(line[2])) for line in result if len(line) >= 3] + + def recognize_batch(self, images: list[Path]) -> list[list[tuple[str, float]]]: + """Process a bounded batch, reusing one compiled OCR engine.""" + return [self.recognize(image) for image in images] + + def _build_engine(self, device: str) -> None: + root = self.settings.models_dir / "ocr" + paths = { + "det_model_path": str(root / "det.onnx"), + "rec_model_path": str(root / "rec.onnx"), + "cls_model_path": str(root / "cls.onnx") if (root / "cls.onnx").exists() else None, + } + if device != "CPU": + from rapidocr_onnxruntime import RapidOCR + from rapidocr_onnxruntime.ch_ppocr_cls import text_cls + from rapidocr_onnxruntime.ch_ppocr_det import text_detect + from rapidocr_onnxruntime.ch_ppocr_rec import text_recognize + + # Reuse RapidOCR's mature OCR pre/post-processing while replacing + # its three short-lived ONNX Runtime session constructors with + # explicit modern OpenVINO GPU compilations. + modules = (text_detect, text_cls, text_recognize) + originals = [module.OrtInferSession for module in modules] + + def session_factory(config): + return _OpenVINOOrtSession(config["model_path"], device, self.accelerator.ov_config(device)) + + try: + for module in modules: + module.OrtInferSession = session_factory + self._engine = RapidOCR(**{name: value for name, value in paths.items() if value is not None}) + finally: + for module, original in zip(modules, originals, strict=True): + module.OrtInferSession = original + else: + # The CPU fallback intentionally keeps the bounded ONNX runtime; + # it is predictable on older fnOS kernels and honours thread caps. + from rapidocr_onnxruntime import RapidOCR + + self._engine = RapidOCR( + intra_op_num_threads=self.settings.ai_cpu_threads, + inter_op_num_threads=1, + **paths, + ) + self._engine_device = device + self.accelerator.mark_ready("ocr", device) + + def reset(self) -> None: + self._engine = None + self._engine_device = None + self.accelerator.reset("ocr") + + +class _OpenVINOOrtSession: + """Adapter matching RapidOCR's ONNX session contract on an OpenVINO device.""" + + def __init__(self, model_path: str, device: str, config: dict[str, object]): + import openvino as ov + + core = ov.Core() + model = core.read_model(model_path) + self.characters: list[str] | None = None + try: + metadata = model.get_rt_info()["framework"]["character"] + value = metadata.value if hasattr(metadata, "value") else str(metadata) + self.characters = value.splitlines() or None + except (AttributeError, KeyError, TypeError): + pass + self.compiled = core.compile_model(model, device, config) + self.output = self.compiled.output(0) + + def __call__(self, input_content): + result = self.compiled([input_content]) + return [result[self.output]] + + def have_key(self, key: str = "character") -> bool: + return key == "character" and bool(self.characters) + + def get_character_list(self, key: str = "character") -> list[str] | None: + return self.characters if key == "character" else None + + +class FaceService: + def __init__(self, settings: Settings, accelerator: AcceleratorService | None = None): + self.settings = settings + self.accelerator = accelerator or AcceleratorService(settings) + self._compiled = None + self._compiled_device: str | None = None + + def ready(self) -> bool: + root = self.settings.models_dir / "faces" + try: + validate_face_model_root(root) + except ValueError: + return False + return True + + def detect_and_embed(self, image_path: Path) -> list[dict]: + if not self.ready(): + return [] + try: + import cv2 + import numpy as np + import openvino as ov + except ImportError: + logger.warning("face models exist but OpenVINO/OpenCV is unavailable") + return [] + root = self.settings.models_dir / "faces" + if self._compiled is None: + device = self.accelerator.device_for("faces") + try: + self._compile(ov, root, device) + except Exception as exc: + if ( + device != "CPU" + and self.accelerator.is_device_error(exc) + and self.accelerator.fall_back("faces", exc, stage="encoder_compile") + ): + logger.warning("face GPU model load failed; retrying on CPU", exc_info=True) + try: + self._compile(ov, root, "CPU") + except Exception as retry_exc: + self.accelerator.mark_unavailable("faces", retry_exc, stage="encoder_compile") + raise + else: + self.accelerator.mark_unavailable("faces", exc, stage="encoder_compile") + raise + try: + return self._detect(image_path, cv2, np) + except Exception as exc: + if self._compiled_device != "CPU" and self.accelerator.fall_back("faces", exc, stage="inference"): + self._compiled = None + self._compile(ov, root, "CPU") + return self._detect(image_path, cv2, np) + self.accelerator.mark_unavailable("faces", exc, stage="inference") + raise + + def detect_and_embed_batch(self, image_paths: list[Path]) -> list[list[dict]]: + """Run face detection for a bounded batch without reloading models.""" + return [self.detect_and_embed(image_path) for image_path in image_paths] + + def _compile(self, ov, root: Path, device: str) -> None: + core = ov.Core() + config = self.accelerator.ov_config(device) + detector = core.compile_model(root / "detector.xml", device, config) + reid = core.compile_model(root / "reidentification.xml", device, config) + self._compiled = detector, reid + self._compiled_device = device + execution_devices = self.accelerator.execution_devices_from(detector, reid) + if device.upper().startswith("GPU") and not any( + actual.upper().startswith("GPU") for actual in execution_devices + ): + raise RuntimeError("OpenVINO 未确认人物模型在 GPU 上执行") + self.accelerator.mark_ready("faces", device, execution_devices) + + def _detect(self, image_path: Path, cv2, np) -> list[dict]: + detector, reid = self._compiled + image = cv2.imread(str(image_path)) + if image is None: + return [] + height, width = image.shape[:2] + + def input_blob(compiled, frame): + shape = tuple(compiled.input(0).shape) + resized = cv2.resize(frame, (shape[3], shape[2])) + return resized.transpose(2, 0, 1)[None].astype(np.float32) + + detections = detector([input_blob(detector, image)])[detector.output(0)] + results = [] + for row in detections.reshape(-1, 7): + confidence = float(row[2]) + if confidence < 0.65: + continue + x1 = max(0, min(width - 1, int(row[3] * width))) + y1 = max(0, min(height - 1, int(row[4] * height))) + x2 = max(x1 + 1, min(width, int(row[5] * width))) + y2 = max(y1 + 1, min(height, int(row[6] * height))) + crop = image[y1:y2, x1:x2] + if min(crop.shape[:2]) < 32: + continue + embedding = reid([input_blob(reid, crop)])[reid.output(0)].reshape(-1) + norm = float(np.linalg.norm(embedding)) + if norm: + embedding = embedding / norm + results.append({"bbox": [x1, y1, x2, y2], "confidence": confidence, "vector": embedding.tolist()}) + return results + + def reset(self) -> None: + self._compiled = None + self._compiled_device = None + self.accelerator.reset("faces") + + +def difference_hash(image: Image.Image) -> str: + small = image.convert("L").resize((9, 8)) + flattened = getattr(small, "get_flattened_data", None) + pixels = list(flattened() if flattened else small.getdata()) + value = 0 + for row in range(8): + for column in range(8): + value = (value << 1) | (pixels[row * 9 + column] > pixels[row * 9 + column + 1]) + return f"{value:016x}" + + +def hash_similarity(left: str | None, right: str | None) -> float: + if not left or not right: + return 0.0 + try: + distance = (int(left, 16) ^ int(right, 16)).bit_count() + except ValueError: + return 0.0 + return 1.0 - distance / 64 diff --git a/backend/imagefind/api.py b/backend/imagefind/api.py new file mode 100644 index 0000000..5f8fe3f --- /dev/null +++ b/backend/imagefind/api.py @@ -0,0 +1,4460 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import mimetypes +import os +import secrets +import shutil +import sqlite3 +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Annotated, Literal +from urllib.parse import quote, urlsplit + +import httpx +from fastapi import APIRouter, Cookie, Depends, File, Form, Header, HTTPException, Request, Response, UploadFile +from fastapi.responses import RedirectResponse, StreamingResponse +from pydantic import BaseModel, Field, HttpUrl + +from . import __version__ +from .ai import ModelUnavailable +from .backups import BackupError, BackupLimitError, BackupNotEmptyError, BackupStorageError +from .collections import ( + assign_videos, + collection_by_name, + collection_tree, + create_collection, + create_group, + delete_group, + dissolve_collection, + get_or_create_collection, + merge_collections, + move_item, + rename_group, + reorder_videos, + set_collection_tags, + video_item_metadata, +) +from .container import Services +from .database import DEFAULT_TAG_GROUP_ID, utcnow +from .downloads import Aria2Unavailable +from .gateway import is_gateway_scope +from .models import ModelBusyError +from .offline_helper import offline_helper_script +from .remote import safe_relative_path +from .security import LoginRateLimitError +from .speech import SPEECH_INDEX_REVISION, SpeechStageError +from .speech_quality import transcript_quality_state + +router = APIRouter(prefix="/api") +API_READ_DEADLINE_SECONDS = 2.0 + + +async def _background_api(function, /, *args, **kwargs): + """Run blocking service reads away from the ASGI event loop.""" + + return await asyncio.to_thread(function, *args, **kwargs) + + +async def _background_api_with_fallback(function, fallback, /, *args, **kwargs): + """Bound UI read latency while allowing a slow PostgreSQL refresh to finish.""" + + task = asyncio.create_task(_background_api(function, *args, **kwargs)) + try: + return await asyncio.wait_for( + asyncio.shield(task), timeout=API_READ_DEADLINE_SECONDS + ) + except TimeoutError: + # asyncio.to_thread cannot stop a running database call. Let it refresh + # the service cache, consume any eventual exception, and serve the last + # complete snapshot now so the UI remains usable under disk writeback. + def consume_result(completed: asyncio.Task) -> None: + try: + completed.result() + except Exception: + pass + + task.add_done_callback(consume_result) + return fallback(*args, **kwargs) + + +def services(request: Request) -> Services: + return request.app.state.services + + +def _bearer(value: str | None) -> str | None: + if value and value.lower().startswith("bearer "): + return value[7:].strip() + return None + + +def require_auth( + request: Request, + authorization: Annotated[str | None, Header()] = None, + imagefind_session: Annotated[str | None, Cookie()] = None, + imagefind_gateway_session: Annotated[str | None, Cookie()] = None, + x_imagefind_gateway_session: Annotated[str | None, Header()] = None, + x_csrf_token: Annotated[str | None, Header()] = None, +) -> dict: + app = services(request) + if is_gateway_scope(request.scope): + identity = _gateway_identity(request) + gateway_token = imagefind_gateway_session + session = app.auth.session(gateway_token) + if ( + ( + not session + or session.get("auth_source") != "gateway" + or session.get("external_user_id") != identity["user_id"] + ) + and x_imagefind_gateway_session + and x_imagefind_gateway_session != gateway_token + ): + gateway_token = x_imagefind_gateway_session + session = app.auth.session(gateway_token) + if ( + not session + or session.get("auth_source") != "gateway" + or session.get("external_user_id") != identity["user_id"] + ): + raise HTTPException(401, "飞牛管理员会话需要重新建立") + if request.method not in {"GET", "HEAD", "OPTIONS"} and x_csrf_token != session["csrf_token"]: + raise HTTPException(403, "CSRF 校验失败") + return { + "kind": "gateway", + "csrf": session["csrf_token"], + "nas_user_id": identity["user_id"], + "nas_username": identity["username"], + "session_token": gateway_token, + } + token = _bearer(authorization) + if token and app.auth.verify_api_token(token, "admin"): + return {"kind": "api"} + session = app.auth.session(imagefind_session) + if not session: + raise HTTPException(401, "需要管理员登录") + if request.method not in {"GET", "HEAD", "OPTIONS"} and x_csrf_token != session["csrf_token"]: + raise HTTPException(403, "CSRF 校验失败") + return {"kind": "session", "csrf": session["csrf_token"]} + + +def require_media_auth( + request: Request, + authorization: Annotated[str | None, Header()] = None, + imagefind_session: Annotated[str | None, Cookie()] = None, + imagefind_gateway_session: Annotated[str | None, Cookie()] = None, + x_imagefind_gateway_session: Annotated[str | None, Header()] = None, + media_token: str | None = None, +) -> dict: + """Authenticate GET/HEAD media without widening query-token access to APIs.""" + if request.method not in {"GET", "HEAD"}: + raise HTTPException(405, "媒体令牌只允许读取媒体") + app = services(request) + if is_gateway_scope(request.scope): + identity = _gateway_identity(request) + if media_token: + session = app.auth.gateway_media_session(media_token, str(identity["user_id"])) + if not session: + raise HTTPException(401, "媒体访问凭证已失效,请刷新页面") + return { + "kind": "gateway-media", + "nas_user_id": identity["user_id"], + "nas_username": identity["username"], + "media_token": media_token, + } + gateway_token = imagefind_gateway_session + session = app.auth.session(gateway_token) + if ( + ( + not session + or session.get("auth_source") != "gateway" + or session.get("external_user_id") != identity["user_id"] + ) + and x_imagefind_gateway_session + and x_imagefind_gateway_session != gateway_token + ): + gateway_token = x_imagefind_gateway_session + session = app.auth.session(gateway_token) + if ( + not session + or session.get("auth_source") != "gateway" + or session.get("external_user_id") != identity["user_id"] + ): + raise HTTPException(401, "飞牛管理员会话需要重新建立") + return {"kind": "gateway", "session_token": gateway_token} + token = _bearer(authorization) + if token and app.auth.verify_api_token(token, "media:read"): + return {"kind": "api"} + if not app.auth.session(imagefind_session): + raise HTTPException(401, "需要管理员登录") + return {"kind": "session"} + + +def _gateway_identity(request: Request) -> dict[str, str | None]: + if not is_gateway_scope(request.scope): + raise HTTPException(404, "接口不存在") + is_admin = request.headers.get("X-Trim-Isadmin") + if is_admin is None: + raise HTTPException(401, "飞牛登录状态已失效,请重新从桌面打开 ImageFind") + if is_admin.strip().lower() not in {"1", "true"}: + raise HTTPException(403, "仅飞牛管理员可以访问 ImageFind") + user_id = (request.headers.get("X-Trim-Userid") or "").strip() + if not user_id or len(user_id) > 256: + raise HTTPException(401, "飞牛用户身份无效,请重新登录") + username = (request.headers.get("X-Trim-Username") or "").strip()[:256] or None + return {"user_id": user_id, "username": username} + + +class SetupBody(BaseModel): + password: str = Field(min_length=10, max_length=256) + remember_device: bool = True + + +class LoginBody(BaseModel): + password: str + remember_device: bool = True + + +class LocalSourceBody(BaseModel): + name: str = Field(default="", max_length=100) + path: str = Field(min_length=1, max_length=4096) + + +class WebDavSourceBody(BaseModel): + name: str = Field(default="", max_length=100) + base_url: HttpUrl + username: str = Field(default="", max_length=500) + password: str = Field(default="", max_length=2000) + verify_tls: bool = True + + +class AlistSourceBody(BaseModel): + name: str = Field(default="", max_length=100) + base_url: HttpUrl + root_path: str = Field(default="", max_length=4096) + username: str = Field(min_length=1, max_length=500) + password: str = Field(min_length=1, max_length=2000) + mode: Literal["direct", "encrypted"] = "encrypted" + verify_tls: bool = True + + +class OpenListNativeBody(BaseModel): + enabled: bool = True + endpoint: HttpUrl | None = None + username: str | None = Field(default=None, max_length=500) + password: str | None = Field(default=None, max_length=2000) + local_staging_path: str = Field(default="", max_length=4096) + source_path: str = Field(default="", max_length=4096) + target_path: str | None = Field(default=None, max_length=4096) + verify_tls: bool = True + + +class AlistRecoveryData(BaseModel): + format: Literal["imagefind-rclone-crypt-v1"] + base_url: HttpUrl + root_path: str = Field(default="", max_length=4096) + username: str = Field(min_length=1, max_length=500) + crypt_password: str = Field(min_length=1, max_length=2000) + crypt_salt: str = Field(min_length=1, max_length=2000) + + +class AlistRestoreBody(BaseModel): + name: str = Field(default="", max_length=100) + password: str = Field(min_length=1, max_length=2000) + verify_tls: bool = True + recovery: AlistRecoveryData + + +class SourceWritableBody(BaseModel): + writable: bool + + +class SourceCapacityOverrideBody(BaseModel): + total_bytes: int | None = Field(default=None, ge=1, le=10 * 1024**6) + available_bytes: int | None = Field(default=None, ge=0, le=10 * 1024**6) + + +class UploadCreateBody(BaseModel): + source_id: str + relative_path: str = Field(default="", max_length=4096) + filename: str = Field(min_length=1, max_length=1024) + title: str | None = Field(default=None, max_length=500) + collection_id: str | None = None + collection_parent_id: str | None = None + tag_ids: list[str] = Field(default_factory=list, max_length=500) + size_bytes: int = Field(gt=0, le=200 * 1024**3) + sha256: str | None = Field(default=None, pattern=r"^[0-9a-fA-F]{64}$") + conflict: Literal["rename", "replace", "skip"] = "rename" + + +class UploadUpdateBody(BaseModel): + title: str | None = Field(default=None, max_length=500) + collection_id: str | None = None + collection_parent_id: str | None = None + tag_ids: list[str] | None = Field(default=None, max_length=500) + + +class DownloadCreateBody(BaseModel): + url: str = Field(min_length=1, max_length=8192) + source_id: str + relative_path: str = Field(default="", max_length=4096) + + +class FileMkdirBody(BaseModel): + source_id: str + path: str = Field(min_length=1, max_length=4096) + + +class FileMoveBody(BaseModel): + source_id: str + source_key: str = Field(min_length=1, max_length=4096) + destination_key: str = Field(min_length=1, max_length=4096) + overwrite: bool = False + + +class FileTrashBody(BaseModel): + source_id: str + keys: list[str] = Field(min_length=1, max_length=500) + + +class VideoMetadataBody(BaseModel): + title: str | None = Field(default=None, max_length=500) + catalog_code: str | None = Field(default=None, max_length=100) + studio: str | None = Field(default=None, max_length=200) + series: str | None = Field(default=None, max_length=200) + release_date: str | None = Field(default=None, max_length=20) + description: str | None = Field(default=None, max_length=5000) + actors: list[str] = Field(default_factory=list, max_length=100) + tags: list[str] = Field(default_factory=list, max_length=100) + tag_ids: list[str] | None = Field(default=None, max_length=100) + + +class SearchBody(BaseModel): + text: str | None = Field(default=None, max_length=1000) + image_id: str | None = None + source_ids: list[str] | None = None + min_duration_ms: int | None = Field(default=None, ge=0) + max_duration_ms: int | None = Field(default=None, ge=0) + min_width: int | None = Field(default=None, ge=0) + has_people: bool | None = None + tag_ids: list[str] | None = None + sort: Literal["relevance", "newest", "duration"] = "relevance" + limit: int = Field(default=50, ge=1, le=100) + cursor: str | None = None + recognition_types: list[Literal["visual", "ocr", "person", "audio", "subtitle", "metadata"]] | None = None + + +class PersonBody(BaseModel): + name: str = Field(min_length=1, max_length=100) + hidden: bool | None = None + + +class MergePeopleBody(BaseModel): + source_ids: list[str] = Field(min_length=1) + + +class SplitFacesBody(BaseModel): + face_ids: list[str] = Field(min_length=1) + name: str = Field(default="", max_length=100) + + +class TokenBody(BaseModel): + name: str = Field(default="API Token", max_length=100) + scopes: list[Literal["admin", "webdav", "media:read"]] = Field(default_factory=lambda: ["admin"]) + + +class ModelInstallBody(BaseModel): + component: Literal["visual", "ocr", "faces", "audio", "all"] = "all" + variant: Literal["small", "medium"] | None = None + url: str | None = None + sha256: str | None = Field(default=None, pattern=r"^[0-9a-fA-F]{64}$") + + +class ModelConfigBody(BaseModel): + hf_endpoint: str | None = Field(default=None, min_length=8, max_length=2048) + pip_index_url: str | None = Field(default=None, min_length=8, max_length=2048) + pytorch_index_url: str | None = Field(default=None, max_length=2048) + proxy_enabled: bool | None = None + proxy_url: str | None = Field(default=None, max_length=2048) + proxy_username: str | None = Field(default=None, max_length=500) + proxy_password: str | None = Field(default=None, max_length=2000) + clear_proxy_password: bool = False + + +class SpeechConfigBody(BaseModel): + language_policy: Literal["zh_priority", "auto", "zh"] | None = None + quality_profile: Literal["speed", "balanced", "accuracy"] | None = None + model_variant: Literal["small", "medium"] | None = None + + +class TranscriptReindexBody(BaseModel): + language: Literal["auto", "zh", "en", "ja", "ko"] | None = None + + +class ResourcePolicyBody(BaseModel): + profile: Literal["quiet", "balanced", "turbo"] | None = None + manual_pause: bool | None = None + cpu_pause_percent: float | None = Field(default=None, ge=20, le=100) + cpu_resume_percent: float | None = Field(default=None, ge=10, le=95) + memory_reserve_mb: int | None = Field(default=None, ge=128, le=65536) + memory_reserve_percent: float | None = Field(default=None, ge=5, le=80) + disk_reserve_gb: float | None = Field(default=None, ge=0.5, le=1024) + ai_gpu_mode: Literal["low", "balanced", "throughput"] | None = None + + +class CacheCleanupBody(BaseModel): + targets: list[Literal["remote_media", "rclone_vfs"]] = Field(default_factory=lambda: ["remote_media"]) + + +class ProfileBody(BaseModel): + nickname: str = Field(min_length=1, max_length=60) + + +class ProfilePasswordBody(BaseModel): + current_password: str = Field(min_length=1, max_length=256) + new_password: str = Field(min_length=10, max_length=256) + + +class SeriesUpdateBody(BaseModel): + name: str = Field(min_length=1, max_length=200) + new_name: str = Field(min_length=1, max_length=200) + + +class BulkSeriesBody(BaseModel): + video_ids: list[str] = Field(min_length=1, max_length=500) + series: str | None = Field(default=None, max_length=200) + + +class CollectionCreateBody(BaseModel): + name: str = Field(min_length=1, max_length=200) + description: str = Field(default="", max_length=5000) + video_ids: list[str] = Field(default_factory=list, max_length=500) + + +class CollectionUpdateBody(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=5000) + cover_video_id: str | None = None + + +class CollectionVideosBody(BaseModel): + video_ids: list[str] = Field(min_length=1, max_length=500) + position: int | None = Field(default=None, ge=0) + parent_id: str | None = None + + +class CollectionOrderBody(BaseModel): + video_ids: list[str] = Field(max_length=500) + + +class CollectionGroupCreateBody(BaseModel): + name: str = Field(min_length=1, max_length=200) + parent_id: str | None = None + position: int | None = Field(default=None, ge=0) + + +class CollectionGroupUpdateBody(BaseModel): + name: str = Field(min_length=1, max_length=200) + + +class CollectionItemMoveBody(BaseModel): + parent_id: str | None = None + position: int = Field(ge=0) + + +class CollectionTagsBody(BaseModel): + tag_ids: list[str] = Field(default_factory=list, max_length=500) + + +class MarkerCreateBody(BaseModel): + position_ms: int = Field(ge=0) + title: str | None = Field(default=None, max_length=120) + + +class MarkerUpdateBody(BaseModel): + position_ms: int | None = Field(default=None, ge=0) + title: str | None = Field(default=None, max_length=120) + + +class PreviewBody(BaseModel): + start_ms: int = Field(default=0, ge=0) + + +class TagGroupCreateBody(BaseModel): + name: str = Field(min_length=1, max_length=100) + selection_mode: Literal["single", "multi"] = "multi" + sort_order: int = 0 + + +class TagGroupUpdateBody(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + selection_mode: Literal["single", "multi"] | None = None + sort_order: int | None = None + + +class TagCreateBody(BaseModel): + group_id: str + name: str = Field(min_length=1, max_length=100) + ai_enabled: bool = False + ai_method: Literal["visual", "text"] = "visual" + ai_description: str = Field(default="", max_length=1000) + ai_threshold: float = Field(default=0.28, ge=-1, le=1) + match_terms: list[str] = Field(default_factory=list, max_length=100) + + +class TagUpdateBody(BaseModel): + group_id: str | None = None + name: str | None = Field(default=None, min_length=1, max_length=100) + ai_enabled: bool | None = None + ai_method: Literal["visual", "text"] | None = None + ai_description: str | None = Field(default=None, max_length=1000) + ai_threshold: float | None = Field(default=None, ge=-1, le=1) + match_terms: list[str] | None = Field(default=None, max_length=100) + + +class TagMergeBody(BaseModel): + source_ids: list[str] = Field(min_length=1, max_length=100) + + +class BulkTagsBody(BaseModel): + video_ids: list[str] = Field(min_length=1, max_length=500) + tag_ids: list[str] = Field(min_length=1, max_length=100) + action: Literal["add", "remove", "replace"] = "add" + + +class AnalyzeTagsBody(BaseModel): + video_ids: list[str] = Field(min_length=1, max_length=500) + force: bool = False + + +class SuggestionDecisionBody(BaseModel): + suggestion_ids: list[str] = Field(min_length=1, max_length=500) + action: Literal["accept", "reject"] + replace_single: bool = False + + +class VideoStateBody(BaseModel): + liked: bool | None = None + favorited: bool | None = None + progress_ms: int | None = Field(default=None, ge=0) + completed: bool | None = None + + +class BulkVideoStateBody(BaseModel): + video_ids: list[str] = Field(min_length=1, max_length=500) + action: Literal["unfavorite", "unlike", "clear_history"] + + +class PreferencesBody(BaseModel): + autoplay: bool | None = None + mask_covers: bool | None = None + theme: Literal["system", "light", "dark"] | None = None + upload_paths: dict[str, str] | None = None + home_video_columns: Literal[1, 2] | None = None + + +class BackupExportBody(BaseModel): + scope: Literal["keys", "full"] = "full" + password: str = Field(min_length=10, max_length=256) + + +class ActorBody(BaseModel): + name: str = Field(min_length=1, max_length=100) + aliases: list[str] = Field(default_factory=list, max_length=100) + person_id: str | None = None + + +class ActorMergeBody(BaseModel): + source_ids: list[str] = Field(min_length=1, max_length=100) + + +def _preferences(app: Services) -> dict: + value = app.db.setting("preferences", {}) + if not isinstance(value, dict): + value = {} + home_video_columns = value.get("home_video_columns", 2) + if isinstance(home_video_columns, bool) or home_video_columns not in (1, 2): + home_video_columns = 2 + return { + "autoplay": bool(value.get("autoplay", True)), + "mask_covers": bool(value.get("mask_covers", False)), + "theme": value.get("theme") if value.get("theme") in {"system", "light", "dark"} else "system", + "upload_paths": value.get("upload_paths", {}), + "home_video_columns": home_video_columns, + "notifications_seen_at": value.get("notifications_seen_at"), + } + + +def _set_cookie( + response: Response, + app: Services, + *, + name: str, + path: str, + token: str, + csrf: str, + expires: datetime, + remember_device: bool, + secure: bool | None = None, +) -> dict: + max_age = app.settings.session_days * 24 * 60 * 60 if remember_device else None + response.set_cookie( + name, + token, + max_age=max_age, + httponly=True, + secure=app.settings.cookie_secure if secure is None else secure, + samesite="lax", + expires=expires if remember_device else None, + path=path, + ) + return {"authenticated": True, "csrf_token": csrf} + + +def _set_session( + response: Response, + app: Services, + password: str, + remember_device: bool = True, + *, + client_key: str = "local", +) -> dict: + token, csrf, expires = app.auth.login( + password, remember_device=remember_device, client_key=client_key + ) + return _set_cookie( + response, + app, + name="imagefind_session", + path="/", + token=token, + csrf=csrf, + expires=expires, + remember_device=remember_device, + ) + + +@router.get("/v1/status") +def status(request: Request, app: Annotated[Services, Depends(services)]): + return { + "configured": app.auth.is_configured(), + "version": __version__, + "access_mode": "gateway" if is_gateway_scope(request.scope) else "direct", + } + + +@router.get("/v1/backups/status") +def backup_status( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + return app.backups.status() + + +@router.get("/v1/backups") +def backup_exports( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + return app.backups.list_exports() + + +@router.post("/v1/backups", status_code=202) +def queue_backup_export( + body: BackupExportBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + return app.backups.queue_export(body.scope, body.password) + except BackupStorageError as exc: + raise HTTPException(507, str(exc)) from exc + except BackupError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.get("/v1/backups/{export_id}/download") +def download_backup_export( + export_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + path, filename = app.backups.export_file(export_id) + except KeyError as exc: + raise HTTPException(404, "备份记录不存在") from exc + except BackupError as exc: + raise HTTPException(409, str(exc)) from exc + + def content(): + with path.open("rb") as backup_file: + while chunk := backup_file.read(1024 * 1024): + yield chunk + + return StreamingResponse( + content(), + media_type="application/vnd.imagefind.backup", + headers={ + "Cache-Control": "no-store", + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Length": str(path.stat().st_size), + }, + ) + + +@router.post("/v1/backups/export") +async def export_backup( + body: BackupExportBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + artifact = await asyncio.to_thread(app.backups.export, body.scope, body.password) + except BackupStorageError as exc: + raise HTTPException(507, str(exc)) from exc + except OSError as exc: + raise HTTPException(507, "备份临时空间不足或无法写入") from exc + except BackupError as exc: + raise HTTPException(400, str(exc)) from exc + + async def content(): + try: + with artifact.path.open("rb") as backup_file: + while chunk := backup_file.read(1024 * 1024): + yield chunk + await asyncio.sleep(0) + finally: + artifact.cleanup() + + return StreamingResponse( + content(), + media_type="application/vnd.imagefind.backup", + headers={ + "Cache-Control": "no-store", + "Content-Disposition": f'attachment; filename="{artifact.filename}"', + "Content-Length": str(artifact.size_bytes), + "X-Content-Type-Options": "nosniff", + "X-ImageFind-Backup-Scope": artifact.scope, + }, + ) + + +@router.post("/v1/backups/restore") +async def restore_backup( + file: Annotated[UploadFile, File()], + password: Annotated[str, Form(min_length=10, max_length=256)], + confirmed: Annotated[bool, Form()], + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + if not (file.filename or "").lower().endswith(".ifbackup"): + raise HTTPException(400, "请选择 .ifbackup 备份文件") + temporary = app.backups.temporary_directory() + upload_path = temporary / "upload.ifbackup" + total = 0 + try: + with app.backups.secure_open(upload_path, "xb") as output: + while chunk := await file.read(1024 * 1024): + total += len(chunk) + if total > app.backups.max_upload_bytes: + raise BackupLimitError("备份文件超过上传上限") + app.backups.ensure_free_space(len(chunk), temporary) + output.write(chunk) + if not total: + raise BackupError("备份文件为空") + return await asyncio.to_thread(app.backups.restore, upload_path, password, confirmed) + except BackupNotEmptyError as exc: + raise HTTPException(409, str(exc)) from exc + except BackupLimitError as exc: + raise HTTPException(413, str(exc)) from exc + except BackupStorageError as exc: + raise HTTPException(507, str(exc)) from exc + except OSError as exc: + raise HTTPException(507, "备份临时空间不足或无法写入") from exc + except BackupError as exc: + raise HTTPException(400, str(exc)) from exc + finally: + await file.close() + shutil.rmtree(temporary, ignore_errors=True) + + +@router.post("/v1/setup") +def setup( + body: SetupBody, + request: Request, + response: Response, + app: Annotated[Services, Depends(services)], +): + if is_gateway_scope(request.scope): + raise HTTPException(404, "接口不存在") + try: + app.auth.setup(body.password) + return _set_session(response, app, body.password, body.remember_device) + except ModelUnavailable as exc: + raise HTTPException(409, str(exc)) from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.post("/v1/auth/login") +def login( + body: LoginBody, + request: Request, + response: Response, + app: Annotated[Services, Depends(services)], +): + if is_gateway_scope(request.scope): + raise HTTPException(404, "接口不存在") + try: + client_key = request.client.host if request.client else "unknown" + return _set_session( + response, + app, + body.password, + body.remember_device, + client_key=client_key, + ) + except LoginRateLimitError as exc: + raise HTTPException(429, str(exc), headers={"Retry-After": str(exc.retry_after)}) from exc + except ValueError as exc: + raise HTTPException(401, str(exc)) from exc + + +@router.post("/v1/auth/gateway") +def gateway_login( + request: Request, + response: Response, + app: Annotated[Services, Depends(services)], +): + identity = _gateway_identity(request) + try: + token, csrf, expires = app.auth.login_gateway( + str(identity["user_id"]), + identity["username"], + ) + media_token, media_expires = app.auth.create_gateway_media_token(token) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + root_path = request.scope.get("root_path", "").rstrip("/") + payload = _set_cookie( + response, + app, + name="imagefind_gateway_session", + path=f"{root_path}/" if root_path else "/", + token=token, + csrf=csrf, + expires=expires, + remember_device=True, + secure=app.settings.cookie_secure or request.url.scheme == "https", + ) + return { + **payload, + "kind": "gateway", + "gateway_session_token": token, + "gateway_media_token": media_token, + "gateway_media_expires_at": media_expires.isoformat(), + "nas_user_id": identity["user_id"], + "nas_username": identity["username"], + } + + +@router.post("/v1/auth/gateway/media-token") +def refresh_gateway_media_token( + request: Request, + app: Annotated[Services, Depends(services)], + principal: Annotated[dict, Depends(require_auth)], +): + if not is_gateway_scope(request.scope) or principal.get("kind") != "gateway": + raise HTTPException(404, "接口不存在") + try: + token, expires = app.auth.create_gateway_media_token(principal.get("session_token")) + except ValueError as exc: + raise HTTPException(401, str(exc)) from exc + return {"gateway_media_token": token, "gateway_media_expires_at": expires.isoformat()} + + +@router.get("/v1/auth/me") +def me(principal: Annotated[dict, Depends(require_auth)]): + return {"authenticated": True, **principal} + + +@router.post("/v1/auth/logout") +def logout( + request: Request, + response: Response, + app: Annotated[Services, Depends(services)], + principal: Annotated[dict, Depends(require_auth)], + imagefind_session: Annotated[str | None, Cookie()] = None, + imagefind_gateway_session: Annotated[str | None, Cookie()] = None, +): + if principal["kind"] == "gateway": + app.auth.logout(principal.get("session_token") or imagefind_gateway_session) + root_path = request.scope.get("root_path", "").rstrip("/") + response.delete_cookie( + "imagefind_gateway_session", + path=f"{root_path}/" if root_path else "/", + secure=app.settings.cookie_secure or request.url.scheme == "https", + samesite="lax", + ) + else: + app.auth.logout(imagefind_session) + response.delete_cookie("imagefind_session", path="/", secure=app.settings.cookie_secure, samesite="lax") + return {"ok": True} + + +def _profile(app: Services) -> dict: + nickname = app.db.setting("profile_nickname", "管理员") + if not isinstance(nickname, str) or not nickname.strip(): + nickname = "管理员" + nickname = nickname.strip() + digest = hashlib.sha256(nickname.encode("utf-8")).digest() + palette = ( + ("#3974dc", "#6f9df1"), + ("#6256c7", "#9588ea"), + ("#16877d", "#55b8a8"), + ("#b16720", "#e19a4b"), + ("#ad486a", "#df7e9e"), + ("#38789e", "#70afd0"), + ) + first = nickname[0] + if "\u3400" <= first <= "\u9fff": + avatar_text = first + else: + ascii_letters = "".join(value for value in nickname if value.isascii() and value.isalnum()) + avatar_text = (ascii_letters[:2] or first).upper() + color, accent = palette[digest[0] % len(palette)] + with app.db.read() as conn: + admin = conn.execute("SELECT created_at FROM admin WHERE id=1").fetchone() + row = conn.execute( + "SELECT coalesce(sum(favorited),0) AS favorites,coalesce(sum(liked),0) AS likes," + "coalesce(sum(CASE WHEN last_played_at IS NOT NULL THEN 1 ELSE 0 END),0) AS history," + "coalesce(sum(CASE WHEN progress_ms>0 AND completed=0 THEN 1 ELSE 0 END),0) AS continue_watching " + "FROM video_state" + ).fetchone() + counts = {key: int(row[key] or 0) for key in ("favorites", "likes", "history", "continue_watching")} + return { + "nickname": nickname, + "avatar": {"text": avatar_text, "color": color, "accent": accent}, + "role": "administrator", + "created_at": admin["created_at"] if admin else None, + "counts": counts, + **counts, + } + + +@router.get("/v1/profile") +def get_profile( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + return _profile(app) + + +@router.patch("/v1/profile") +def update_profile( + body: ProfileBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + nickname = body.nickname.strip() + if not nickname: + raise HTTPException(400, "昵称不能为空") + app.db.set_setting("profile_nickname", nickname) + return _profile(app) + + +@router.patch("/v1/profile/password") +def change_profile_password( + body: ProfilePasswordBody, + request: Request, + response: Response, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + app.auth.change_password(body.current_password, body.new_password) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + if is_gateway_scope(request.scope): + root_path = request.scope.get("root_path", "").rstrip("/") + response.delete_cookie( + "imagefind_gateway_session", + path=f"{root_path}/" if root_path else "/", + secure=app.settings.cookie_secure or request.url.scheme == "https", + samesite="lax", + ) + else: + response.delete_cookie("imagefind_session", path="/", secure=app.settings.cookie_secure, samesite="lax") + return {"ok": True, "reauthenticate": True} + + +@router.get("/v1/sources") +def list_sources(app: Annotated[Services, Depends(services)], _: Annotated[dict, Depends(require_auth)]): + return app.sources.list_sources() + + +@router.post("/v1/sources/local", status_code=201) +def add_local( + body: LocalSourceBody, app: Annotated[Services, Depends(services)], _: Annotated[dict, Depends(require_auth)] +): + try: + source_id = app.sources.add_local(body.name, body.path) + except (ValueError, OSError) as exc: + raise HTTPException(400, str(exc)) from exc + job_id = app.jobs.enqueue("scan_source", {"source_id": source_id}, dedupe_key=f"scan:{source_id}") + return {"id": source_id, "scan_job_id": job_id} + + +@router.post("/v1/sources/webdav", status_code=201) +def add_webdav( + body: WebDavSourceBody, app: Annotated[Services, Depends(services)], _: Annotated[dict, Depends(require_auth)] +): + try: + source_id = app.sources.add_webdav(body.name, str(body.base_url), body.username, body.password, body.verify_tls) + except Exception as exc: + raise HTTPException(400, f"无法连接 WebDAV:{exc}") from exc + job_id = app.jobs.enqueue("scan_source", {"source_id": source_id}, dedupe_key=f"scan:{source_id}") + return {"id": source_id, "scan_job_id": job_id} + + +@router.post("/v1/sources/alist", status_code=201) +def add_alist( + body: AlistSourceBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + source_id, recovery = app.sources.add_alist( + body.name, + str(body.base_url), + body.root_path, + body.username, + body.password, + mode=body.mode, + verify_tls=body.verify_tls, + ) + except Exception as exc: + raise HTTPException(400, f"无法连接 AList:{exc}") from exc + job_id = app.jobs.enqueue("scan_source", {"source_id": source_id}, dedupe_key=f"scan:{source_id}") + return {"id": source_id, "scan_job_id": job_id, "recovery": recovery} + + +@router.post("/v1/sources/alist/restore", status_code=201) +def restore_alist( + body: AlistRestoreBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + recovery = body.recovery + try: + source_id = app.sources.restore_alist( + body.name, + str(recovery.base_url), + recovery.root_path, + recovery.username, + body.password, + recovery.crypt_password, + recovery.crypt_salt, + verify_tls=body.verify_tls, + ) + except Exception as exc: + raise HTTPException(400, f"无法恢复 AList 加密库:{exc}") from exc + job_id = app.jobs.enqueue("scan_source", {"source_id": source_id}, dedupe_key=f"scan:{source_id}") + return {"id": source_id, "scan_job_id": job_id} + + +@router.post("/v1/sources/{source_id}/openlist/test") +def test_openlist_native( + source_id: str, + body: OpenListNativeBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + return app.openlist_native.probe(source_id, body.model_dump(mode="json")) + except KeyError as exc: + raise HTTPException(404, "媒体库不存在") from exc + except Exception as exc: + raise HTTPException(400, f"OpenList 完整链路测试失败:{exc}") from exc + + +@router.patch("/v1/sources/{source_id}/openlist") +def configure_openlist_native( + source_id: str, + body: OpenListNativeBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + return app.openlist_native.configure(source_id, body.model_dump(mode="json")) + except KeyError as exc: + raise HTTPException(404, "媒体库不存在") from exc + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + except Exception as exc: + raise HTTPException(400, f"无法启用 OpenList 原生后台复制:{exc}") from exc + + +@router.patch("/v1/sources/{source_id}/writable") +def set_source_writable( + source_id: str, + body: SourceWritableBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + return app.storage.set_writable(source_id, body.writable) + except KeyError as exc: + raise HTTPException(404, "数据源不存在") from exc + except (OSError, PermissionError, ValueError) as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.post("/v1/sources/{source_id}/scan", status_code=202) +def scan_source( + source_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + app.sources.get(source_id) + except KeyError as exc: + raise HTTPException(404, "数据源不存在") from exc + job_id = app.jobs.enqueue("scan_source", {"source_id": source_id}, dedupe_key=f"scan:{source_id}") + return {"job_id": job_id} + + +@router.delete("/v1/sources/{source_id}", status_code=204) +def delete_source( + source_id: str, app: Annotated[Services, Depends(services)], _: Annotated[dict, Depends(require_auth)] +): + app.sources.delete(source_id) + + +@router.get("/v1/videos") +def videos( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], + source_id: str | None = None, + tag_ids: str | None = None, + min_width: int | None = None, + min_duration_ms: int | None = None, + has_people: bool | None = None, + organized: bool | None = None, + favorite: bool | None = None, + liked: bool | None = None, + continue_only: bool = False, + played_only: bool = False, + series: str | None = None, + collection_id: str | None = None, + source_mode: Literal["local", "encrypted", "direct", "webdav"] | None = None, + sort: Literal["updated", "added", "last_played", "collection"] = "updated", + limit: int = 100, + offset: int = 0, +): + args: list[object] = [] + filters: list[str] = ["v.available=1"] + if source_id: + filters.append("v.source_id=?") + args.append(source_id) + selected_tags = [value for value in (tag_ids or "").split(",") if value] + if selected_tags: + filters.append( + f"EXISTS(SELECT 1 FROM video_tags filter_vt WHERE filter_vt.video_id=v.id " + f"AND filter_vt.tag_id IN ({','.join('?' for _ in selected_tags)}))" + ) + args.extend(selected_tags) + if min_width is not None: + filters.append("coalesce(v.width,0)>=?") + args.append(max(0, min_width)) + if min_duration_ms is not None: + filters.append("coalesce(v.duration_ms,0)>=?") + args.append(max(0, min_duration_ms)) + if has_people is not None: + expression = ( + "(EXISTS(SELECT 1 FROM faces ff WHERE ff.video_id=v.id) " + "OR EXISTS(SELECT 1 FROM video_actors fva WHERE fva.video_id=v.id))" + ) + filters.append(expression if has_people else f"NOT {expression}") + organized_expression = ( + "(vm.video_id IS NOT NULL OR EXISTS(SELECT 1 FROM video_tags ovt WHERE ovt.video_id=v.id) " + "OR EXISTS(SELECT 1 FROM video_actors ova WHERE ova.video_id=v.id))" + ) + if organized is not None: + filters.append(organized_expression if organized else f"NOT {organized_expression}") + if favorite is not None: + filters.append("coalesce(vs.favorited,0)=?") + args.append(int(favorite)) + if liked is not None: + filters.append("coalesce(vs.liked,0)=?") + args.append(int(liked)) + if continue_only: + filters.append("coalesce(vs.progress_ms,0)>0 AND coalesce(vs.completed,0)=0") + if played_only: + filters.append("vs.last_played_at IS NOT NULL") + if series is not None: + normalized_series = series.strip() + if normalized_series: + filters.append("lower(coalesce(c.name,vm.series))=lower(?)") + args.append(normalized_series) + else: + filters.append("(c.id IS NULL AND (vm.series IS NULL OR trim(vm.series)=''))") + if collection_id is not None: + filters.append("cv.collection_id=?") + args.append(collection_id) + if source_mode == "local": + filters.append("s.kind='local'") + elif source_mode in {"encrypted", "direct"}: + filters.append("(s.config_json::jsonb ->> 'mode')=?") + args.append(source_mode) + elif source_mode == "webdav": + filters.append("s.kind='webdav' AND coalesce(s.config_json::jsonb ->> 'driver','webdav')='webdav'") + where = f"WHERE {' AND '.join(filters)}" if filters else "" + ordering = { + "updated": "v.updated_at DESC", + "added": "v.created_at DESC", + "last_played": "vs.last_played_at DESC", + "collection": "cv.position,cv.added_at,v.id", + }[sort] + if sort == "collection" and collection_id is None: + raise HTTPException(422, "sort=collection 需要 collection_id") + args.extend([min(max(limit, 1), 500), max(offset, 0)]) + tag_items_by_video: dict[str, list[dict]] = {} + with app.db.read() as conn: + rows = conn.execute( + "SELECT v.id,v.source_id,v.display_name,v.source_key,v.size_bytes,v.modified_at,v.duration_ms," + "v.width,v.height,v.codec,v.container,v.status,v.available,v.error,v.created_at,v.updated_at," + "v.fingerprint,v.basic_fingerprint,v.visual_model_version,v.ocr_model_version,v.faces_model_version," + "s.name AS source_name," + "s.kind AS source_kind,s.config_json,vm.title,vm.catalog_code,vm.studio," + "coalesce(c.name,vm.series) AS series,c.id AS collection_id,c.name AS collection_name," + "cv.position AS collection_position,vm.release_date," + "vm.description,(SELECT f.id FROM frames f WHERE f.video_id=v.id ORDER BY f.timestamp_ms LIMIT 1) " + "AS frame_id,(SELECT string_agg(a.name,'||' ORDER BY a.name) FROM video_actors va " + "JOIN actors a ON a.id=va.actor_id WHERE va.video_id=v.id) AS actor_names," + "(SELECT string_agg(t.name,'||' ORDER BY t.name) FROM video_tags vt " + "JOIN tags t ON t.id=vt.tag_id WHERE vt.video_id=v.id) AS tag_names," + "coalesce(vs.liked,0) AS liked,coalesce(vs.favorited,0) AS favorited," + "coalesce(vs.progress_ms,0) AS progress_ms,coalesce(vs.completed,0) AS completed,vs.last_played_at " + "FROM videos v JOIN sources s ON s.id=v.source_id LEFT JOIN video_metadata vm ON vm.video_id=v.id " + "LEFT JOIN collection_videos cv ON cv.video_id=v.id LEFT JOIN collections c ON c.id=cv.collection_id " + "LEFT JOIN video_state vs ON vs.video_id=v.id " + f"{where} ORDER BY {ordering} LIMIT ? OFFSET ?", + args, + ).fetchall() + video_ids = [row["id"] for row in rows] + if video_ids: + tag_rows = conn.execute( + "SELECT vt.video_id,t.id,t.name,t.group_id,g.name AS group_name,g.selection_mode " + "FROM video_tags vt JOIN tags t ON t.id=vt.tag_id " + "JOIN tag_groups g ON g.id=t.group_id " + f"WHERE vt.video_id IN ({','.join('?' for _ in video_ids)}) " + "ORDER BY vt.video_id,g.sort_order,g.name,t.name", + video_ids, + ).fetchall() + for tag_row in tag_rows: + value = dict(tag_row) + video_id = value.pop("video_id") + tag_items_by_video.setdefault(video_id, []).append(value) + result = [] + model_versions = app.models.component_versions() + for row in rows: + item = dict(row) + config = json.loads(item.pop("config_json")) + item["title"] = item["title"] or item["display_name"] + actor_names = item.pop("actor_names") + tag_names = item.pop("tag_names") + item["actors"] = actor_names.split("||") if actor_names else [] + item["tags"] = tag_names.split("||") if tag_names else [] + item["tag_items"] = tag_items_by_video.get(item["id"], []) + frame_id = item.pop("frame_id") + item["thumbnail_url"] = f"/api/v1/frames/{frame_id}/thumbnail" if frame_id else None + item["playback_url"] = f"/api/v1/videos/{item['id']}/stream" + item["download_url"] = f"/api/v1/videos/{item['id']}/download" + item["source_driver"] = config.get("driver", item["source_kind"]) + item["source_mode"] = config.get("mode") + basic_ready = bool(item.pop("basic_fingerprint") == item.pop("fingerprint")) + index_state = {"basic": "ready" if basic_ready else ("failed" if item["status"] == "error" else "pending")} + for capability in ("visual", "ocr", "faces"): + indexed_version = item.pop(f"{capability}_model_version") + current_version = model_versions[capability] + if not current_version: + state = "unavailable" + elif not basic_ready or not indexed_version: + state = "pending" + elif indexed_version != current_version: + state = "outdated" + else: + state = "ready" + index_state[capability] = state + item["index_state"] = index_state + result.append(item) + return result + + +COLLECTION_SELECT = ( + "SELECT c.id,c.name,c.description,c.cover_video_id,c.created_at,c.updated_at," + "count(cv.video_id) AS video_count,coalesce(sum(CASE WHEN v.available=1 THEN v.duration_ms ELSE 0 END),0) " + "AS duration_ms,(SELECT f.id FROM frames f WHERE f.video_id=coalesce(c.cover_video_id," + "(SELECT ordered.video_id FROM collection_videos ordered WHERE ordered.collection_id=c.id " + "ORDER BY ordered.position,ordered.added_at LIMIT 1)) ORDER BY f.timestamp_ms LIMIT 1) AS frame_id " + "FROM collections c LEFT JOIN collection_videos cv ON cv.collection_id=c.id " + "LEFT JOIN videos v ON v.id=cv.video_id " +) + + +def _collection_payload(row: sqlite3.Row) -> dict: + return { + "id": row["id"], + "name": row["name"], + "description": row["description"] or "", + "cover_video_id": row["cover_video_id"], + "video_count": int(row["video_count"] or 0), + "duration_ms": int(row["duration_ms"] or 0), + "thumbnail_url": f"/api/v1/frames/{row['frame_id']}/thumbnail" if row["frame_id"] else None, + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def _read_collection(conn: sqlite3.Connection, collection_id: str) -> sqlite3.Row | None: + return conn.execute( + COLLECTION_SELECT + "WHERE c.id=? GROUP BY c.id", + (collection_id,), + ).fetchone() + + +@router.get("/v1/collections") +def list_collections( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + with app.db.read() as conn: + rows = conn.execute(COLLECTION_SELECT + "GROUP BY c.id ORDER BY c.updated_at DESC,c.name").fetchall() + return [_collection_payload(row) for row in rows] + + +@router.get("/v1/home") +def home_feed( + app: Annotated[Services, Depends(services)], + current_user: Annotated[dict, Depends(require_auth)], + item_limit: int = 10, + tag_limit: int = 3, +): + """Return a bounded, deterministic home feed instead of a flat library dump.""" + + item_limit = min(max(item_limit, 4), 20) + tag_limit = min(max(tag_limit, 0), 6) + with app.db.read() as conn: + summary = conn.execute( + "SELECT count(*) AS video_count,max(updated_at) AS updated_at FROM videos WHERE available=1" + ).fetchone() + unorganized_count = int( + conn.execute( + "SELECT count(*) AS total FROM videos v LEFT JOIN video_metadata vm ON vm.video_id=v.id " + "WHERE v.available=1 AND NOT (vm.video_id IS NOT NULL " + "OR EXISTS(SELECT 1 FROM video_tags vt WHERE vt.video_id=v.id) " + "OR EXISTS(SELECT 1 FROM video_actors va WHERE va.video_id=v.id))" + ).fetchone()["total"] + ) + tag_rows = conn.execute( + "SELECT t.id,t.name,t.group_id,g.name AS group_name,g.sort_order," + "count(DISTINCT vt.video_id) AS video_count " + "FROM tags t JOIN tag_groups g ON g.id=t.group_id " + "JOIN video_tags vt ON vt.tag_id=t.id JOIN videos v ON v.id=vt.video_id " + "WHERE v.available=1 GROUP BY t.id,t.name,t.group_id,g.name,g.sort_order " + "HAVING count(DISTINCT vt.video_id)>=2 " + "ORDER BY video_count DESC,g.sort_order,g.name,t.name LIMIT ?", + (tag_limit,), + ).fetchall() + + recent = videos(app, current_user, sort="added", limit=item_limit) + collection_items = [item for item in list_collections(app, current_user) if item["video_count"] > 0] + categories = [ + { + "id": f"tag:{row['id']}", + "tag_id": row["id"], + "title": row["name"], + "group_name": row["group_name"], + "total": int(row["video_count"]), + "items": videos(app, current_user, tag_ids=row["id"], limit=item_limit), + } + for row in tag_rows + ] + unorganized_items = ( + videos(app, current_user, organized=False, sort="added", limit=item_limit) + if unorganized_count + else [] + ) + hide_unorganized = unorganized_count <= item_limit and { + item["id"] for item in unorganized_items + } == {item["id"] for item in recent} + actor_items = sorted( + (item for item in list_actors(app, current_user) if int(item["video_count"] or 0) > 0), + key=lambda item: (-int(item["video_count"] or 0), str(item["name"]).casefold()), + )[:item_limit] + return { + "video_count": int(summary["video_count"] or 0), + "updated_at": summary["updated_at"], + "recent": {"id": "recent", "title": "最近新增", "total": int(summary["video_count"] or 0), "items": recent}, + "collections": {"total": len(collection_items), "items": collection_items[:item_limit]}, + "categories": categories, + "people": actor_items, + "unorganized": None + if not unorganized_count or hide_unorganized + else {"id": "unorganized", "title": "待整理", "total": unorganized_count, "items": unorganized_items}, + } + + +@router.post("/v1/collections", status_code=201) +def add_collection( + body: CollectionCreateBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + name = body.name.strip() + if not name: + raise HTTPException(400, "合集名称不能为空") + try: + with app.db.transaction() as conn: + if collection_by_name(conn, name): + raise HTTPException(409, "已存在同名合集") + collection_id = create_collection(conn, name, body.description) + if body.video_ids: + assign_videos(conn, collection_id, body.video_ids) + row = _read_collection(conn, collection_id) + except KeyError as exc: + raise HTTPException(404, {"message": "部分视频不存在", "video_ids": exc.args[0]}) from exc + assert row is not None + return _collection_payload(row) + + +@router.get("/v1/collections/{collection_id}") +def get_collection( + collection_id: str, + app: Annotated[Services, Depends(services)], + auth: Annotated[dict, Depends(require_auth)], +): + with app.db.read() as conn: + row = _read_collection(conn, collection_id) + items = collection_tree(conn, collection_id) if row else [] + item_metadata = video_item_metadata(conn, collection_id) if row else {} + if not row: + raise HTTPException(404, "合集不存在") + result = _collection_payload(row) + result["videos"] = videos( + app, + auth, + collection_id=collection_id, + sort="collection", + limit=500, + ) + for video in result["videos"]: + video.update(item_metadata.get(str(video["id"]), {})) + + by_video = {str(video["id"]): video for video in result["videos"]} + + def attach(nodes: list[dict]) -> None: + for node in nodes: + if node["kind"] == "group": + attach(node["children"]) + else: + node["video"] = by_video.get(str(node["video_id"])) + + attach(items) + result["items"] = items + return result + + +@router.patch("/v1/collections/{collection_id}") +def update_collection( + collection_id: str, + body: CollectionUpdateBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + existing = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() + if not existing: + raise HTTPException(404, "合集不存在") + updates: list[str] = [] + args: list[object] = [] + if "name" in body.model_fields_set: + name = (body.name or "").strip() + if not name: + raise HTTPException(400, "合集名称不能为空") + duplicate = collection_by_name(conn, name) + if duplicate and duplicate["id"] != collection_id: + raise HTTPException(409, "已存在同名合集,可使用合并功能") + updates.append("name=?") + args.append(name) + conn.execute( + "UPDATE video_metadata SET series=?,updated_at=? WHERE video_id IN " + "(SELECT video_id FROM collection_videos WHERE collection_id=?)", + (name, utcnow(), collection_id), + ) + if "description" in body.model_fields_set: + updates.append("description=?") + args.append((body.description or "").strip()) + if "cover_video_id" in body.model_fields_set: + if ( + body.cover_video_id + and not conn.execute( + "SELECT 1 FROM collection_videos WHERE collection_id=? AND video_id=?", + (collection_id, body.cover_video_id), + ).fetchone() + ): + raise HTTPException(400, "代表视频必须属于当前合集") + updates.append("cover_video_id=?") + args.append(body.cover_video_id) + if updates: + updates.append("updated_at=?") + args.append(utcnow()) + args.append(collection_id) + conn.execute(f"UPDATE collections SET {','.join(updates)} WHERE id=?", args) + row = _read_collection(conn, collection_id) + except sqlite3.IntegrityError as exc: + raise HTTPException(409, "已存在同名合集") from exc + assert row is not None + return _collection_payload(row) + + +@router.delete("/v1/collections/{collection_id}") +def delete_collection( + collection_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + detached = dissolve_collection(conn, collection_id) + except LookupError as exc: + raise HTTPException(404, "合集不存在") from exc + return {"id": collection_id, "detached_videos": detached} + + +@router.post("/v1/collections/{collection_id}/videos") +def add_collection_videos( + collection_id: str, + body: CollectionVideosBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + updated = assign_videos(conn, collection_id, body.video_ids, body.position, body.parent_id) + except LookupError as exc: + raise HTTPException(404, "合集不存在") from exc + except KeyError as exc: + raise HTTPException(404, {"message": "部分视频不存在", "video_ids": exc.args[0]}) from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"collection_id": collection_id, "updated": updated} + + +@router.post("/v1/collections/{collection_id}/groups", status_code=201) +def add_collection_group( + collection_id: str, + body: CollectionGroupCreateBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + group_id = create_group(conn, collection_id, body.name, body.parent_id, body.position) + items = collection_tree(conn, collection_id) + except LookupError as exc: + raise HTTPException(404, "合集不存在") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except sqlite3.IntegrityError as exc: + raise HTTPException(409, "同一层级已存在同名分组") from exc + + def find(nodes: list[dict]) -> dict | None: + for node in nodes: + if node["id"] == group_id: + return node + if node["kind"] == "group" and (found := find(node["children"])): + return found + return None + + return find(items) + + +@router.patch("/v1/collections/{collection_id}/groups/{group_id}") +def update_collection_group( + collection_id: str, + group_id: str, + body: CollectionGroupUpdateBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + rename_group(conn, collection_id, group_id, body.name) + except LookupError as exc: + raise HTTPException(404, "分组不存在") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except sqlite3.IntegrityError as exc: + raise HTTPException(409, "同一层级已存在同名分组") from exc + return {"id": group_id, "name": body.name.strip()} + + +@router.delete("/v1/collections/{collection_id}/groups/{group_id}") +def remove_collection_group( + collection_id: str, + group_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + promoted = delete_group(conn, collection_id, group_id) + except LookupError as exc: + raise HTTPException(404, "分组不存在") from exc + return {"id": group_id, "promoted_items": promoted} + + +@router.patch("/v1/collections/{collection_id}/items/{item_id}/move") +def move_collection_item( + collection_id: str, + item_id: str, + body: CollectionItemMoveBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + move_item(conn, collection_id, item_id, body.parent_id, body.position) + except LookupError as exc: + raise HTTPException(404, "合集节点不存在") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except sqlite3.IntegrityError as exc: + raise HTTPException(409, "目标层级已存在同名分组") from exc + return {"id": item_id, "parent_id": body.parent_id, "position": body.position} + + +@router.delete("/v1/collections/{collection_id}/videos/{video_id}") +def remove_collection_video( + collection_id: str, + video_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + with app.db.transaction() as conn: + if not conn.execute( + "SELECT 1 FROM collection_videos WHERE collection_id=? AND video_id=?", + (collection_id, video_id), + ).fetchone(): + raise HTTPException(404, "视频不在当前合集中") + assign_videos(conn, None, [video_id]) + return {"collection_id": collection_id, "video_id": video_id} + + +@router.patch("/v1/collections/{collection_id}/videos/order") +def update_collection_order( + collection_id: str, + body: CollectionOrderBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + if not conn.execute("SELECT 1 FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "合集不存在") + updated = reorder_videos(conn, collection_id, body.video_ids) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"collection_id": collection_id, "updated": updated} + + +@router.get("/v1/collections/{collection_id}/tags") +def get_collection_tags( + collection_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + with app.db.read() as conn: + if not conn.execute("SELECT 1 FROM collections WHERE id=?", (collection_id,)).fetchone(): + raise HTTPException(404, "合集不存在") + rows = conn.execute( + "SELECT t.id,t.name,t.group_id,g.name AS group_name,g.selection_mode " + "FROM collection_tags ct JOIN tags t ON t.id=ct.tag_id " + "JOIN tag_groups g ON g.id=t.group_id WHERE ct.collection_id=? " + "ORDER BY g.sort_order,g.name,t.name", + (collection_id,), + ).fetchall() + return [dict(row) for row in rows] + + +@router.put("/v1/collections/{collection_id}/tags") +def update_collection_tags( + collection_id: str, + body: CollectionTagsBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + with app.db.transaction() as conn: + applied = set_collection_tags(conn, collection_id, body.tag_ids) + except KeyError as exc: + raise HTTPException(400, {"message": "包含不存在的标签", "tag_ids": exc.args[0]}) from exc + except LookupError as exc: + raise HTTPException(404, "合集不存在") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"collection_id": collection_id, "tag_ids": list(dict.fromkeys(body.tag_ids)), "applied": applied} + + +@router.get("/v1/series") +def list_series( + app: Annotated[Services, Depends(services)], + auth: Annotated[dict, Depends(require_auth)], +): + """Compatibility alias for clients released before collections.""" + with app.db.transaction() as conn: + app.db._migrate_collections_v8(conn) + return list_collections(app, auth) + + +@router.patch("/v1/series") +def update_series( + body: SeriesUpdateBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + name = body.name.strip() + new_name = body.new_name.strip() + if not name or not new_name: + raise HTTPException(400, "合集名称不能为空") + with app.db.transaction() as conn: + source = collection_by_name(conn, name) + if not source: + raise HTTPException(404, "合集不存在") + target = collection_by_name(conn, new_name) + if target and target["id"] != source["id"]: + updated = merge_collections(conn, source["id"], target["id"]) + else: + updated = conn.execute( + "SELECT count(*) FROM collection_videos WHERE collection_id=?", (source["id"],) + ).fetchone()[0] + conn.execute("UPDATE collections SET name=?,updated_at=? WHERE id=?", (new_name, utcnow(), source["id"])) + conn.execute( + "UPDATE video_metadata SET series=?,updated_at=? WHERE video_id IN " + "(SELECT video_id FROM collection_videos WHERE collection_id=?)", + (new_name, utcnow(), source["id"]), + ) + return {"name": name, "new_name": new_name, "updated": int(updated)} + + +@router.post("/v1/videos/series/bulk") +def bulk_video_series( + body: BulkSeriesBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + series = body.series.strip() if isinstance(body.series, str) else None + series = series or None + try: + with app.db.transaction() as conn: + collection_id = get_or_create_collection(conn, series) if series else None + updated = assign_videos(conn, collection_id, body.video_ids) + except KeyError as exc: + raise HTTPException(404, {"message": "部分视频不存在", "video_ids": exc.args[0]}) from exc + return {"updated": updated, "series": series, "collection_id": collection_id} + + +@router.get("/v1/jobs") +async def jobs( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], + limit: int = 100, + page: int | None = None, + page_size: int = 10, + lane: Literal["ai", "transfer", "download", "scan"] | None = None, + status: Literal["queued", "running", "completed", "failed", "cancelled"] | None = None, +): + if page is not None: + if page < 1 or page_size < 1 or page_size > 50: + raise HTTPException(422, "page 必须大于 0,page_size 必须在 1–50 之间") + return await _background_api( + app.jobs.paginate, page, page_size, lane=lane, status=status + ) + return await _background_api(app.jobs.list, limit, lane=lane, status=status) + + +@router.post("/v1/jobs/retry-failed", status_code=202) +async def retry_failed_jobs( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], + lane: Literal["ai", "transfer", "download", "scan"] | None = None, +): + return await _background_api(app.jobs.retry_failed, lane=lane) + + +@router.post("/v1/jobs/{job_id}/retry", status_code=202) +def retry_job( + job_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + retried_job_id = app.jobs.retry(job_id) + except KeyError as exc: + raise HTTPException(404, "后台任务不存在") from exc + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + return {"job_id": retried_job_id, "retried_from": job_id} + + +@router.post("/v1/jobs/{job_id}/cancel", status_code=202) +def cancel_job( + job_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + try: + app.jobs.request_cancel(job_id) + except KeyError as exc: + raise HTTPException(404, "后台任务不存在") from exc + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + return {"job_id": job_id, "cancel_requested": True} + + +@router.post("/v1/query-images", status_code=201) +async def upload_query_image( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], + file: Annotated[UploadFile, File()], +): + if file.content_type not in {"image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp"}: + raise HTTPException(415, "只支持 JPEG、PNG、WebP、GIF 或 BMP 图片") + image_id = str(uuid.uuid4()) + extension = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/bmp": ".bmp", + }[file.content_type] + path = app.settings.uploads_dir / f"{image_id}{extension}" + total = 0 + with path.open("wb") as output: + while chunk := await file.read(1024 * 1024): + total += len(chunk) + if total > app.settings.max_upload_mb * 1024 * 1024: + path.unlink(missing_ok=True) + raise HTTPException(413, "图片过大") + output.write(chunk) + try: + from PIL import Image + + with Image.open(path) as image: + image.verify() + except Exception as exc: + path.unlink(missing_ok=True) + raise HTTPException(400, "图片文件无效") from exc + expires = datetime.now(UTC) + timedelta(hours=1) + with app.db.transaction() as conn: + conn.execute( + "INSERT INTO query_images(id,path,content_type,expires_at,created_at) VALUES(?,?,?,?,?)", + (image_id, str(path), file.content_type, expires.isoformat(), utcnow()), + ) + return {"id": image_id, "expires_at": expires.isoformat()} + + +@router.get("/v1/events") +async def events( + request: Request, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + async def stream(): + async for event in app.events.subscribe(): + if await request.is_disconnected(): + break + if event is None: + yield ": keep-alive\n\n" + else: + yield f"data: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n" + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}, + ) + + +@router.post("/v1/search") +async def search( + body: SearchBody, app: Annotated[Services, Depends(services)], _: Annotated[dict, Depends(require_auth)] +): + image_path = None + if body.image_id: + if not app.embeddings.status()["visual_ready"]: + raise HTTPException(409, "以图搜图需要画面语义模型,请先在设置中安装模型包") + with app.db.read() as conn: + row = conn.execute( + "SELECT path FROM query_images WHERE id=? AND expires_at>?", (body.image_id, utcnow()) + ).fetchone() + if not row: + raise HTTPException(400, "查询图片不存在或已过期") + image_path = Path(row["path"]) + try: + kwargs = { + "text": body.text, + "image_path": image_path, + "source_ids": body.source_ids, + "min_duration_ms": body.min_duration_ms, + "max_duration_ms": body.max_duration_ms, + "min_width": body.min_width, + "has_people": body.has_people, + "tag_ids": body.tag_ids, + "sort": body.sort, + "limit": body.limit, + "cursor": body.cursor, + "recognition_types": body.recognition_types, + } + # The deterministic hash encoder is a lightweight development/test backend. + # Keeping its SQLite work on the request thread also supports sandboxes that + # prohibit SQLite file locking from worker threads. + if app.settings.embedding_backend == "hash": + result = app.search.search(**kwargs) + else: + result = await asyncio.to_thread( + app.search.search, + **kwargs, + ) + compact = "".join(character for character in str(body.text or "").strip() if character.isalnum()) + cjk = sum("\u3400" <= character <= "\u9fff" for character in compact) + if compact and ((cjk == len(compact) and cjk < 2) or (cjk == 0 and len(compact) < 3)): + result["query_notice"] = ( + "关键词过短,仅进行精确人物、标签和标题匹配;" + "搜索语音/OCR 至少输入 2 个汉字或 3 个拉丁字符。" + ) + return result + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except ModelUnavailable as exc: + raise HTTPException(409, "以图搜图需要画面语义模型,请先在设置中安装模型包") from exc + + +def _audio_job_for_video(conn, video_id: str): + return conn.execute( + "SELECT j.id,j.status,j.progress,j.message,j.error,j.created_at,j.started_at,j.finished_at " + "FROM jobs j JOIN job_resources r ON r.job_id=j.id " + "WHERE r.resource_type='video' AND r.resource_id=? AND j.kind='transcribe_audio' " + "ORDER BY CASE j.status WHEN 'running' THEN 0 WHEN 'queued' THEN 1 ELSE 2 END,j.created_at DESC LIMIT 1", + (video_id,), + ).fetchone() + + +@router.get("/v1/search/coverage") +def search_coverage( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + version = app.models.runnable_component_versions().get("audio") + with app.db.read() as conn: + total = int(conn.execute("SELECT count(*) FROM videos WHERE available=1").fetchone()[0]) + indexed = int( + conn.execute( + "SELECT count(*) FROM videos WHERE available=1 AND audio_model_version=?", + (version or "",), + ).fetchone()[0] + ) + searchable = int( + conn.execute( + "SELECT count(*) FROM videos v WHERE v.available=1 " + "AND EXISTS(SELECT 1 FROM text_entries e WHERE e.video_id=v.id AND e.kind='audio' " + "AND coalesce(e.quality_score,1)>=0.55)" + ).fetchone()[0] + ) + empty = int( + conn.execute( + "SELECT count(*) FROM videos v WHERE v.available=1 AND v.audio_model_version=? " + "AND NOT EXISTS(SELECT 1 FROM text_entries e WHERE e.video_id=v.id AND e.kind='audio')", + (version or "",), + ).fetchone()[0] + ) + states = { + row["status"]: int(row["amount"]) + for row in conn.execute( + "SELECT j.status,count(DISTINCT r.resource_id) AS amount FROM jobs j " + "JOIN job_resources r ON r.job_id=j.id AND r.resource_type='video' " + "JOIN videos v ON v.id=r.resource_id AND v.available=1 " + "WHERE j.kind='transcribe_audio' AND j.status IN ('queued','running') GROUP BY j.status" + ).fetchall() + } + failed = int( + conn.execute( + "SELECT count(DISTINCT r.resource_id) FROM jobs j " + "JOIN job_resources r ON r.job_id=j.id AND r.resource_type='video' " + "JOIN videos v ON v.id=r.resource_id AND v.available=1 " + "WHERE j.kind='transcribe_audio' AND j.status='failed' " + "AND NOT EXISTS(SELECT 1 FROM jobs active JOIN job_resources ar ON ar.job_id=active.id " + "WHERE ar.resource_type='video' AND ar.resource_id=r.resource_id " + "AND active.kind='transcribe_audio' AND active.status IN ('queued','running')) " + "AND coalesce(v.audio_model_version,'')<>?", + (version or "",), + ).fetchone()[0] + ) + low_quality = int( + conn.execute( + "SELECT count(*) FROM videos WHERE available=1 AND audio_index_revision0 OR audio_quality_score<0.55)", + (SPEECH_INDEX_REVISION,), + ).fetchone()[0] + ) + return { + "available": bool(version), + "model_version": version, + "total": total, + "indexed": indexed, + "searchable": searchable, + "empty": empty, + "queued": states.get("queued", 0), + "running": states.get("running", 0), + "failed": failed, + "low_quality": low_quality, + "pending": max(0, total - indexed - states.get("queued", 0) - states.get("running", 0) - failed), + "percent": round(searchable / total * 100, 1) if total else 100.0, + "current_model_percent": round(indexed / total * 100, 1) if total else 100.0, + } + + +@router.get("/v1/speech/config") +def speech_config( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + return { + "language_policy": app.settings.audio_language_policy, + "quality_profile": app.settings.audio_quality_profile, + "model_variant": app.settings.audio_model_variant, + "index_revision": SPEECH_INDEX_REVISION, + } + + +@router.patch("/v1/speech/config") +def update_speech_config( + body: SpeechConfigBody, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + if body.language_policy is not None: + app.settings.audio_language_policy = body.language_policy + if body.quality_profile is not None: + app.settings.audio_quality_profile = body.quality_profile + variant_changed = body.model_variant is not None and body.model_variant != app.settings.audio_model_variant + if variant_changed: + try: + app.models.set_audio_variant(body.model_variant) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + except ModelUnavailable as exc: + raise HTTPException(409, str(exc)) from exc + except SpeechStageError as exc: + detail = " ".join(str(exc).split())[:500] + stage = f"{exc.stage} · " if exc.stage else "" + raise HTTPException(409, f"音频模型 GPU 验证失败:{stage}{detail}") from exc + except TimeoutError as exc: + raise HTTPException(409, f"音频模型 GPU 验证超时:{' '.join(str(exc).split())[:500]}") from exc + except Exception as exc: + raise HTTPException(409, f"音频模型 GPU 验证失败:{type(exc).__name__}") from exc + value = { + "language_policy": app.settings.audio_language_policy, + "quality_profile": app.settings.audio_quality_profile, + "model_variant": app.settings.audio_model_variant, + "index_revision": SPEECH_INDEX_REVISION, + } + app.db.set_setting("speech_config", value) + if variant_changed: + app.reconcile_ai() + return value + + +@router.post("/v1/speech/reconcile", status_code=202) +def reconcile_speech_quality( + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], +): + result = app.reconcile_audio_quality() + queued = app.reconcile_ai() + return {**result, "queued": len(queued)} + + +@router.get("/v1/videos/{video_id}/transcript") +def video_transcript( + video_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], + page: int = 1, + page_size: int = 50, +): + page = max(1, page) + page_size = min(100, max(1, page_size)) + version = app.models.runnable_component_versions().get("audio") + with app.db.read() as conn: + video = conn.execute( + "SELECT id,audio_model_version,audio_detected_language,audio_quality_score," + "audio_rejected_segments,audio_quality_flags_json,audio_index_revision " + "FROM videos WHERE id=? AND available=1", + (video_id,), + ).fetchone() + if not video: + raise HTTPException(404, "视频不存在") + total = int( + conn.execute( + "SELECT count(*) FROM text_entries WHERE video_id=? AND kind='audio' " + "AND coalesce(quality_score,1)>=0.55", + (video_id,), + ).fetchone()[0] + ) + rows = conn.execute( + "SELECT id,start_ms,end_ms,raw_text,language,quality_score FROM text_entries " + "WHERE video_id=? AND kind='audio' AND coalesce(quality_score,1)>=0.55 " + "ORDER BY start_ms,id LIMIT ? OFFSET ?", + (video_id, page_size, (page - 1) * page_size), + ).fetchall() + job = _audio_job_for_video(conn, video_id) + current = bool(version and video["audio_model_version"] == version) + try: + quality_flags = json.loads(video["audio_quality_flags_json"] or "[]") + if not isinstance(quality_flags, list): + quality_flags = [] + except (TypeError, json.JSONDecodeError): + quality_flags = [] + quality_state = transcript_quality_state( + video["audio_quality_score"], + total, + int(video["audio_rejected_segments"] or 0), + quality_flags, + ) + if job and job["status"] in {"queued", "running"}: + status = job["status"] + elif current: + status = quality_state + elif job and job["status"] == "failed": + status = "failed" + elif not version: + status = "unavailable" + else: + status = "pending" + return { + "status": status, + "model_version": version, + "indexed_version": video["audio_model_version"], + "detected_language": video["audio_detected_language"], + "quality_score": video["audio_quality_score"], + "quality_state": quality_state + if int(video["audio_index_revision"] or 0) >= SPEECH_INDEX_REVISION + else "legacy", + "rejected_segments": int(video["audio_rejected_segments"] or 0), + "quality_flags": quality_flags, + "items": [dict(row) for row in rows], + "page": page, + "page_size": page_size, + "total": total, + "pages": max(1, (total + page_size - 1) // page_size), + "job": dict(job) if job else None, + } + + +@router.post("/v1/videos/{video_id}/transcript/reindex", status_code=202) +def reindex_video_transcript( + video_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_auth)], + body: TranscriptReindexBody | None = None, +): + if not app.models.runnable_component_versions().get("audio"): + raise HTTPException(409, "音频识别模型尚未安装") + with app.db.read() as conn: + if not conn.execute("SELECT 1 FROM videos WHERE id=? AND available=1", (video_id,)).fetchone(): + raise HTTPException(404, "视频不存在") + payload = {"video_id": video_id} + if body and body.language and body.language != "auto": + payload["language"] = body.language + job_id = app.jobs.enqueue("transcribe_audio", payload, dedupe_key=f"audio:{video_id}", priority=0) + with app.db.read() as conn: + row = conn.execute("SELECT status FROM jobs WHERE id=?", (job_id,)).fetchone() + return {"job_id": job_id, "status": row["status"] if row else "queued"} + + +@router.get("/v1/frames/{frame_id}/thumbnail") +def thumbnail( + frame_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_media_auth)], +): + with app.db.read() as conn: + row = conn.execute("SELECT thumbnail_path FROM frames WHERE id=?", (frame_id,)).fetchone() + if not row or not Path(row["thumbnail_path"]).is_file(): + raise HTTPException(404, "缩略图不存在") + content = Path(row["thumbnail_path"]).read_bytes() + return Response(content=content, media_type="image/webp") + + +@router.get("/v1/faces/{face_id}/thumbnail") +def face_thumbnail( + face_id: str, + app: Annotated[Services, Depends(services)], + _: Annotated[dict, Depends(require_media_auth)], +): + with app.db.read() as conn: + row = conn.execute( + "SELECT f.bbox_json,fr.thumbnail_path,v.width,v.height FROM faces f " + "JOIN frames fr ON fr.id=f.frame_id JOIN videos v ON v.id=f.video_id WHERE f.id=?", + (face_id,), + ).fetchone() + if not row or not Path(row["thumbnail_path"]).is_file(): + raise HTTPException(404, "人脸缩略图不存在") + from io import BytesIO + + from PIL import Image + + with Image.open(row["thumbnail_path"]) as image: + width, height = image.size + source_width = max(1, row["width"] or width) + source_height = max(1, row["height"] or height) + x1, y1, x2, y2 = json.loads(row["bbox_json"]) + scale_x, scale_y = width / source_width, height / source_height + box = [x1 * scale_x, y1 * scale_y, x2 * scale_x, y2 * scale_y] + padding = max(box[2] - box[0], box[3] - box[1]) * 0.2 + crop = image.convert("RGB").crop( + ( + max(0, int(box[0] - padding)), + max(0, int(box[1] - padding)), + min(width, int(box[2] + padding)), + min(height, int(box[3] + padding)), + ) + ) + crop.thumbnail((240, 240)) + output = BytesIO() + crop.save(output, "WEBP", quality=82) + return Response(content=output.getvalue(), media_type="image/webp") + + +async def _remote_response( + request: Request, + app: Services, + video_id: str, + *, + download_name: str | None = None, +) -> Response: + def remote_record(): + with app.db.read() as conn: + record = conn.execute( + "SELECT v.source_id,v.source_key,v.size_bytes,v.available FROM videos v " + "JOIN sources s ON s.id=v.source_id WHERE v.id=? AND s.kind='webdav'", + (video_id,), + ).fetchone() + if not record or not record["available"]: + return None + access = app.sources.open_remote_access(record["source_id"], record["source_key"]) + return record, access + + try: + resolved = await asyncio.to_thread(remote_record) + except Exception as exc: + raise HTTPException(502, f"无法打开远程媒体库:{exc}") from exc + if not resolved: + raise HTTPException(404, "远程视频不存在或当前离线") + row, access = resolved + url, username, password, verify_tls = access.as_tuple() + await app.remote_semaphore.acquire() + remote_http = app.remote_http if verify_tls else app.remote_http_insecure + forwarded = {} + for name in ("range", "if-range", "if-none-match", "if-modified-since"): + if value := request.headers.get(name): + forwarded[name] = value + head_via_range_get = False + try: + upstream_method = "HEAD" if request.method == "HEAD" else "GET" + upstream_request = remote_http.build_request(upstream_method, url, headers=forwarded) + upstream = await remote_http.send( + upstream_request, + stream=True, + auth=httpx.BasicAuth(username, password), + ) + if request.method == "HEAD" and upstream.status_code >= 400: + await upstream.aclose() + fallback_headers = dict(forwarded) + fallback_headers.setdefault("range", "bytes=0-0") + upstream = await remote_http.send( + remote_http.build_request("GET", url, headers=fallback_headers), + stream=True, + auth=httpx.BasicAuth(username, password), + ) + head_via_range_get = True + except Exception: + access.close() + app.remote_semaphore.release() + raise + if upstream.status_code >= 400: + await upstream.aclose() + access.close() + app.remote_semaphore.release() + raise HTTPException(502, f"WebDAV 返回 HTTP {upstream.status_code}") + headers = { + name: value + for name, value in upstream.headers.items() + if name.lower() in {"content-length", "content-range", "accept-ranges", "content-type", "etag", "last-modified"} + } + upstream_type = headers.get("content-type", "").split(";", 1)[0].strip().lower() + inferred_type = mimetypes.guess_type(str(row["source_key"]))[0] + if inferred_type and upstream_type in {"", "application/octet-stream", "binary/octet-stream"}: + # OpenList and several generic WebDAV servers expose every object as a + # binary stream. Chromium then keeps