feat: add ImageFind application and release pipelines

This commit is contained in:
2026-08-11 18:02:40 +08:00
commit 16239d7525
270 changed files with 59163 additions and 0 deletions
+20
View File
@@ -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
+32
View File
@@ -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/
@@ -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.510px 关键文字与低对比 `--subtle` token 对低视力用户不友好。
- 平板导航只显示图标,人物/演员图片存在替代文本缺口。
### Casey(分心的单手手机用户)
- 中心上传和 44px 控件表现好,但进入二级页后熟悉的底栏消失。
- 搜索模式、设置标签、播放器动作和合集条目依赖无提示横滑。
- “新建合集”等主操作位于顶部,远离拇指热区。
- 上传页固定“继续上传”比处理失败或打开刚上传内容更抢眼。
## Minor Observations
- “上传中心”“传输中心”“上传”三个概念需要统一命名边界。
- 黑色选中 chip 与全局蓝色品牌状态冲突。
- `AI SEARCH``SYSTEM``ADMIN PROFILE` 等英文 eyebrow 使中文界面显得模板化。
- 未知时长显示 `0:00` 容易被理解为真正的零长度视频。
- “暂无封面”应区分缺失、排队、失败和隐私遮罩。
- 401 控制台信息在五个尺寸都出现,但发生于登录前认证探测,当前没有用户可见影响。
- 手机搜索容器比客户区宽 1px,但 document/body 未溢出,属于非阻塞几何误差。
- 320px 时间点面板的报告值是时序误判;截图明确显示面板已经打开。
## Questions to Consider
- ImageFind 的第一承诺究竟是“管理媒体文件”,还是“找到私有媒体中的任意时刻”?首页为什么更像前者?
- 如果隐私是核心差异,为什么移动端最需要的封面遮罩快捷操作反而不可见?
- 一次大文件上传完成后,用户最自然的下一步是播放、整理到合集,还是继续上传?
- 移动端二级页是否真的需要沉浸到移除所有导航?
- 如果原始文件名和缺省封面是边缘状态,首页的信息结构应怎样改变?
+50
View File
@@ -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"]
Vendored
+239
View File
@@ -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)
}
}
}
+60
View File
@@ -0,0 +1,60 @@
# Product
<!-- impeccable:product-schema 1 -->
## 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% 缩放以及不依赖颜色单独表达状态。
+284
View File
@@ -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.113.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 <token>`
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 图片字幕。
+3
View File
@@ -0,0 +1,3 @@
"""ImageFind application package."""
__version__ = "0.5.45"
+606
View File
@@ -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,
}
+714
View File
@@ -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
File diff suppressed because it is too large Load Diff
+443
View File
@@ -0,0 +1,443 @@
from __future__ import annotations
import argparse
import ctypes
import json
import math
import os
import signal
import sys
import time
import wave
from array import array
from pathlib import Path
from .accelerator import AcceleratorService
from .config import Settings
from .speech import SpeechService, SpeechStageError
from .speech_quality import normalize_language, pcm16_voiced_regions, select_language, transcript_quality
def _emit(payload: dict) -> None:
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), flush=True)
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="ImageFind isolated Whisper worker")
parser.add_argument("--data-dir", required=True)
parser.add_argument("--wav", required=True)
parser.add_argument("--device", choices=("CPU", "GPU"), required=True)
parser.add_argument("--gpu-mode", choices=("low", "balanced", "throughput"), default="balanced")
parser.add_argument("--chunk-seconds", type=int, default=30)
parser.add_argument("--overlap-seconds", type=int, default=2)
parser.add_argument("--cpu-threads", type=int, default=1)
parser.add_argument("--max-new-tokens", type=int, default=0, help=argparse.SUPPRESS)
parser.add_argument("--start-frame", type=int, default=0, help=argparse.SUPPRESS)
parser.add_argument("--max-chunks", type=int, default=0, help=argparse.SUPPRESS)
parser.add_argument("--language", default="")
parser.add_argument("--stream-language", default="")
parser.add_argument("--language-policy", choices=("zh_priority", "auto", "zh"), default="zh_priority")
parser.add_argument("--quality-profile", choices=("speed", "balanced", "accuracy"), default="accuracy")
parser.add_argument("--model-variant", choices=("small", "medium"), default="small")
return parser.parse_args()
def _pcm16_to_float32(raw: bytes):
try:
import numpy as np
return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
except ModuleNotFoundError:
values = array("h")
values.frombytes(raw)
if sys.byteorder != "little":
values.byteswap()
return [sample / 32768.0 for sample in values]
def _prepare_process(cpu_threads: int) -> None:
thread_value = str(max(1, int(cpu_threads)))
for name in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS", "NUMEXPR_NUM_THREADS"):
os.environ[name] = thread_value
try:
os.nice(15)
except OSError:
pass
# The worker has its own session so the API can pause it independently.
# Make that isolation safe during fnOS upgrades: a parent exit must also
# reclaim the resident Whisper/OpenVINO model, even while the worker is
# stopped by SIGSTOP.
try:
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGKILL, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
return
if os.getppid() == 1:
os.kill(os.getpid(), signal.SIGKILL)
except (AttributeError, OSError):
pass
def _sample_energy(samples) -> float:
try:
return sum(float(value) ** 2 for value in samples) / max(1, len(samples))
except (TypeError, ValueError):
return 0.0
def _score_segments(
segments: list[dict],
*,
speech_ratio: float,
language: str | None,
) -> tuple[list[dict], int, list[str], float]:
accepted: list[dict] = []
rejected = 0
flags: list[str] = []
scores: list[float] = []
for item in segments:
duration_ms = max(1, int(item.get("end_ms") or 1) - int(item.get("start_ms") or 0))
quality = transcript_quality(
str(item.get("text") or ""),
duration_ms,
speech_ratio=speech_ratio,
expected_language=language or item.get("language"),
)
flags.extend(quality.flags)
scores.append(quality.score)
if not quality.accepted:
rejected += 1
continue
value = dict(item)
value["quality_score"] = quality.score
if language or item.get("language"):
value["language"] = normalize_language(language or item.get("language"))
accepted.append(value)
score = sum(scores) / len(scores) if scores else (1.0 if speech_ratio < 0.02 else 0.0)
return accepted, rejected, list(dict.fromkeys(flags)), round(score, 3)
def run(args: argparse.Namespace) -> int:
quality_profile = str(getattr(args, "quality_profile", "accuracy"))
language_policy = str(getattr(args, "language_policy", "zh_priority"))
model_variant = str(getattr(args, "model_variant", "small"))
if model_variant == "medium" and args.device != "GPU":
raise SpeechStageError("device_policy", "Whisper Medium 仅支持 Intel GPU,不允许使用 CPU")
settings_kwargs = {
"data_dir": Path(args.data_dir),
"ai_cpu_threads": args.cpu_threads,
"audio_quality_profile": quality_profile,
"audio_language_policy": language_policy,
"audio_model_variant": model_variant,
"ai_gpu_mode": str(getattr(args, "gpu_mode", "balanced")),
}
max_new_tokens = max(0, int(getattr(args, "max_new_tokens", 0)))
if max_new_tokens:
settings_kwargs["audio_max_new_tokens"] = max_new_tokens
settings = Settings(**settings_kwargs)
accelerator = AcceleratorService(settings)
if args.device == "CPU":
accelerator.gpu_device = None
speech = SpeechService(settings, accelerator)
engine = speech._load()
del engine
wav_path = Path(args.wav)
detected: list[str] = []
with wave.open(str(wav_path), "rb") as scan:
sample_rate = scan.getframerate()
total_frames = scan.getnframes()
chunk_frames = max(sample_rate, sample_rate * args.chunk_seconds)
overlap_frames = min(chunk_frames // 2, sample_rate * args.overlap_seconds)
step_frames = max(sample_rate, chunk_frames - overlap_frames)
candidates: list[tuple[float, float, bytes]] = []
first_frame = min(total_frames, max(0, int(getattr(args, "start_frame", 0))))
max_chunks = max(0, int(getattr(args, "max_chunks", 0)))
scan_end = total_frames
if max_chunks:
scan_end = min(total_frames, first_frame + chunk_frames + step_frames * (max_chunks - 1))
position = first_frame
while position < scan_end:
scan.setpos(position)
raw = scan.readframes(min(chunk_frames, scan_end - position))
if not raw:
break
regions = pcm16_voiced_regions(raw, sample_rate, mode=2)
if not regions and _sample_energy(_pcm16_to_float32(raw)) > 1e-6:
regions = pcm16_voiced_regions(raw, sample_rate, mode=1)
for region_start, region_end, speech_ratio in regions:
candidate = raw[region_start * 2 : region_end * 2]
candidates.append((speech_ratio, _sample_energy(_pcm16_to_float32(candidate)), candidate))
candidates.sort(key=lambda item: (item[0], item[1]), reverse=True)
del candidates[3:]
position += step_frames
explicit_language = normalize_language(getattr(args, "language", ""))
stream_language = normalize_language(getattr(args, "stream_language", ""))
if not explicit_language and language_policy != "zh":
sample_limit = {"speed": 1, "balanced": 2, "accuracy": 3}.get(quality_profile, 3)
for speech_ratio, _energy, raw in candidates[:sample_limit]:
if speech_ratio < 0.015:
continue
try:
language = speech._detect_language_samples(_pcm16_to_float32(raw), sample_rate)
except Exception as exc:
print(f"Whisper language detection skipped: {type(exc).__name__}", file=sys.stderr, flush=True)
break
if language:
detected.append(language)
resolved_language = explicit_language or select_language(
detected,
policy=language_policy,
stream_language=stream_language,
)
candidate_language = detected[0] if len(detected) >= 3 and len(set(detected[:3])) == 1 else None
if (
not explicit_language
and language_policy == "zh_priority"
and candidate_language not in {None, "zh"}
and candidates
):
# Strong non-Chinese detection still gets one bounded content check.
# The candidate must materially outperform Chinese before it can
# override the user's preference.
probe_samples = _pcm16_to_float32(candidates[0][2])
duration_ms = max(1, round(len(probe_samples) / max(1, sample_rate) * 1000))
token_budget = max(24, min(64, math.ceil(duration_ms / 1000 * 4) + 12))
zh_segments = speech._direct_transcribe_samples(
probe_samples,
sample_rate,
language="zh",
quality_profile="speed",
max_new_tokens=token_budget,
)
candidate_segments = speech._direct_transcribe_samples(
probe_samples,
sample_rate,
language=candidate_language,
quality_profile="speed",
max_new_tokens=token_budget,
)
_zh, _zr, _zf, zh_score = _score_segments(zh_segments, speech_ratio=candidates[0][0], language="zh")
_candidate, _cr, _cf, candidate_score = _score_segments(
candidate_segments,
speech_ratio=candidates[0][0],
language=candidate_language,
)
if candidate_score >= zh_score + 0.15:
resolved_language = candidate_language
component = accelerator.status()["components"]["audio"]
_emit(
{
"event": "ready",
"component": component,
"detected_language": resolved_language,
"language_candidates": detected,
}
)
rejected_total = 0
quality_scores: list[float] = []
quality_flags: list[str] = []
strongest_voiced_ratio = 0.0
strongest_voiced_frame = 0
rejected_candidate_units = 0
verification_duration_ms = 0
with wave.open(str(wav_path), "rb") as handle:
sample_rate = handle.getframerate()
total_frames = handle.getnframes()
chunk_frames = max(sample_rate, sample_rate * args.chunk_seconds)
overlap_frames = min(chunk_frames // 2, sample_rate * args.overlap_seconds)
step_frames = max(sample_rate, chunk_frames - overlap_frames)
first_frame = min(total_frames, max(0, int(getattr(args, "start_frame", 0))))
start_frame = first_frame
index = 0
available_frames = max(0, total_frames - first_frame)
total_chunks = max(1, (max(available_frames - overlap_frames, 1) + step_frames - 1) // step_frames)
max_chunks = max(0, int(getattr(args, "max_chunks", 0)))
if max_chunks:
total_chunks = min(total_chunks, max_chunks)
strongest_empty_energy = 0.0
strongest_empty_frame = first_frame
recognized_segments = 0
while start_frame < total_frames:
if max_chunks and index >= max_chunks:
break
handle.setpos(start_frame)
frame_count = min(chunk_frames, total_frames - start_frame)
raw = handle.readframes(frame_count)
if not raw:
break
samples = _pcm16_to_float32(raw)
base_ms = round(start_frame / sample_rate * 1000)
end_frame = start_frame + len(samples)
end_ms = round(end_frame / sample_rate * 1000)
lower_ms = base_ms + (args.overlap_seconds * 500 if start_frame else 0)
upper_ms = end_ms - (args.overlap_seconds * 500 if end_frame < total_frames else 0)
energy = _sample_energy(samples)
segments = []
regions = pcm16_voiced_regions(raw, sample_rate, mode=2)
if not regions and energy > 1e-6:
regions = pcm16_voiced_regions(raw, sample_rate, mode=1)
windows: list[tuple[list[float], float, int, int]] = []
for region_start, region_end, speech_ratio in regions:
if speech_ratio < 0.08:
continue
if speech_ratio > strongest_voiced_ratio:
strongest_voiced_ratio = speech_ratio
strongest_voiced_frame = start_frame + region_start
window_raw = raw[region_start * 2 : region_end * 2]
window_samples = _pcm16_to_float32(window_raw)
window_base_ms = round((start_frame + region_start) / sample_rate * 1000)
window_duration_ms = max(1, round((region_end - region_start) / sample_rate * 1000))
windows.append((window_samples, speech_ratio, window_base_ms, window_duration_ms))
# Stateful Whisper IRs on Intel GPU share decoder variables across
# requests. Concurrent/batched generation can leave the first
# sequence waiting on an unresolved state update, so keep GPU
# decoding strictly one window at a time. The encoder still runs
# on GPU; this trades theoretical throughput for deterministic
# first-segment latency and avoids any CPU fallback.
batch_size = 1
chunk_inference_seconds = 0.0
for offset in range(0, len(windows), batch_size):
window_batch = windows[offset:offset + batch_size]
window_seconds = len(window_batch[0][0]) / max(1, sample_rate)
token_budget = max(24, min(96, math.ceil(window_seconds * 4) + 12))
_emit(
{
"event": "decode_start",
"chunk_index": index + 1,
"window_index": offset + 1,
"window_seconds": round(window_seconds, 2),
"max_new_tokens": token_budget,
"device": args.device,
"num_beams": 1 if args.device == "GPU" else speech.generation_beams(quality_profile),
"batch_size": len(window_batch),
"cache": speech.has_decoder_cache(),
}
)
inference_started = time.monotonic()
decoded_batch = speech._direct_transcribe_batch_samples(
[item[0] for item in window_batch],
sample_rate,
language=resolved_language,
quality_profile=quality_profile,
max_new_tokens=token_budget,
)
chunk_inference_seconds += time.monotonic() - inference_started
for (_window_samples, speech_ratio, window_base_ms, window_duration_ms), raw_segments in zip(
window_batch, decoded_batch, strict=True
):
scored, rejected, flags, candidate_score = _score_segments(
raw_segments,
speech_ratio=speech_ratio,
language=resolved_language,
)
raw_units = sum(
character.isalnum()
for segment in raw_segments
for character in str(segment.get("text") or "")
)
if rejected and raw_units > rejected_candidate_units:
rejected_candidate_units = raw_units
verification_duration_ms = window_duration_ms
rejected_total += rejected
quality_flags.extend(flags)
quality_scores.append(candidate_score)
for segment in scored:
segment["start_ms"] += window_base_ms
segment["end_ms"] += window_base_ms
if segment["end_ms"] <= segment["start_ms"]:
segment["end_ms"] = segment["start_ms"] + 1
midpoint = (segment["start_ms"] + segment["end_ms"]) // 2
if lower_ms <= midpoint <= upper_ms:
segments.append(segment)
recognized_segments += len(segments)
if not segments:
if energy > strongest_empty_energy:
strongest_empty_energy = energy
strongest_empty_frame = start_frame
index += 1
_emit(
{
"event": "chunk",
"index": index,
"total": total_chunks,
"fraction": min(
1.0,
index / total_chunks if max_chunks else end_frame / total_frames if total_frames else 1.0,
),
"chunk_index": index,
"window_count": len(windows),
"media_seconds": round(frame_count / max(1, sample_rate), 3),
"inference_seconds": round(chunk_inference_seconds, 3),
"rtf": round(chunk_inference_seconds / max(0.001, frame_count / max(1, sample_rate)), 3),
"segments": segments,
}
)
start_frame += step_frames
if args.device == "GPU" and recognized_segments == 0 and strongest_voiced_ratio >= 0.05:
stage = "low_quality_result" if rejected_total else "empty_result"
raise SpeechStageError(
stage,
"GPU 音频结果未通过质量校验" if rejected_total else "GPU 完整识别未返回文字或时间片段",
{
"verification_start_frame": strongest_voiced_frame or strongest_empty_frame,
"sample_rate": sample_rate,
"gpu_text_units": rejected_candidate_units,
"verification_duration_ms": verification_duration_ms,
"gpu_quality_score": (
round(sum(quality_scores) / len(quality_scores), 3) if quality_scores else None
),
"quality_flags": list(dict.fromkeys(quality_flags)),
"detected_language": resolved_language,
"language_candidates": detected,
"requested_device": args.device,
"actual_device": component.get("actual_device") or component.get("device") or args.device,
"primary_backend": "direct_generate",
"model_variant": model_variant,
"num_beams": 1 if args.device == "GPU" else speech.generation_beams(quality_profile),
"segmentation": "webrtcvad",
},
)
_emit(
{
"event": "complete",
"detected_language": resolved_language,
"quality_score": round(sum(quality_scores) / len(quality_scores), 3) if quality_scores else None,
"rejected_segments": rejected_total,
"quality_flags": list(dict.fromkeys(quality_flags)),
"inference_diagnostics": {
"requested_device": args.device,
"actual_device": component.get("actual_device") or component.get("device") or args.device,
"primary_backend": "direct_generate",
"model_variant": model_variant,
"num_beams": 1 if args.device == "GPU" else speech.generation_beams(quality_profile),
"segmentation": "webrtcvad",
"language_candidates": detected,
"selected_language": resolved_language,
"fallback_scope": None,
"fallback_reason": None,
},
}
)
return 0
def main() -> int:
args = _arguments()
_prepare_process(args.cpu_threads)
try:
return run(args)
except Exception as exc:
_emit(
{
"event": "error",
"type": type(exc).__name__,
"stage": exc.stage if isinstance(exc, SpeechStageError) else None,
"message": str(exc)[:1600],
"details": exc.details if isinstance(exc, SpeechStageError) else {},
}
)
return 1
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+667
View File
@@ -0,0 +1,667 @@
from __future__ import annotations
import sqlite3
import uuid
from collections.abc import Iterable
from typing import Any
from .database import utcnow
def validate_tag_ids(conn: sqlite3.Connection, tag_ids: Iterable[str]) -> list[str]:
selected = list(dict.fromkeys(str(value) for value in tag_ids))
if not selected:
return []
placeholders = ",".join("?" for _ in selected)
rows = conn.execute(
f"SELECT t.id,t.group_id,g.selection_mode FROM tags t "
f"JOIN tag_groups g ON g.id=t.group_id WHERE t.id IN ({placeholders})",
selected,
).fetchall()
if len(rows) != len(selected):
raise KeyError([value for value in selected if value not in {str(row['id']) for row in rows}])
single_groups: set[str] = set()
for row in rows:
if row["selection_mode"] == "single":
if row["group_id"] in single_groups:
raise ValueError("单选分类组只能选择一个标签")
single_groups.add(str(row["group_id"]))
return selected
def apply_video_tags(
conn: sqlite3.Connection,
video_ids: Iterable[str],
tag_ids: Iterable[str],
) -> int:
videos = list(dict.fromkeys(str(value) for value in video_ids))
selected = validate_tag_ids(conn, tag_ids)
if not videos or not selected:
return 0
placeholders = ",".join("?" for _ in selected)
rows = conn.execute(
f"SELECT t.id,t.group_id,g.selection_mode FROM tags t "
f"JOIN tag_groups g ON g.id=t.group_id WHERE t.id IN ({placeholders})",
selected,
).fetchall()
single_groups = [str(row["group_id"]) for row in rows if row["selection_mode"] == "single"]
for video_id in videos:
if single_groups:
group_placeholders = ",".join("?" for _ in single_groups)
conn.execute(
"DELETE FROM video_tags WHERE video_id=? AND tag_id IN ("
f"SELECT id FROM tags WHERE group_id IN ({group_placeholders}))",
(video_id, *single_groups),
)
conn.executemany(
"INSERT OR IGNORE INTO video_tags(video_id,tag_id) VALUES(?,?)",
((video_id, tag_id) for tag_id in selected),
)
return len(videos)
def collection_tag_ids(conn: sqlite3.Connection, collection_id: str) -> list[str]:
return [
str(row["tag_id"])
for row in conn.execute(
"SELECT tag_id FROM collection_tags WHERE collection_id=? ORDER BY created_at,tag_id",
(collection_id,),
).fetchall()
]
def set_collection_tags(conn: sqlite3.Connection, collection_id: str, tag_ids: Iterable[str]) -> int:
if not conn.execute("SELECT 1 FROM collections WHERE id=?", (collection_id,)).fetchone():
raise LookupError(collection_id)
selected = validate_tag_ids(conn, tag_ids)
conn.execute("DELETE FROM collection_tags WHERE collection_id=?", (collection_id,))
now = utcnow()
conn.executemany(
"INSERT INTO collection_tags(collection_id,tag_id,created_at) VALUES(?,?,?)",
((collection_id, tag_id, now) for tag_id in selected),
)
videos = _ordered_video_ids(conn, collection_id)
apply_video_tags(conn, videos, selected)
return len(videos)
def collection_by_name(conn: sqlite3.Connection, name: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM collections WHERE lower(name)=lower(?)", (name.strip(),)
).fetchone()
def create_collection(
conn: sqlite3.Connection,
name: str,
description: str = "",
) -> str:
now = utcnow()
collection_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO collections(id,name,description,created_at,updated_at) VALUES(?,?,?,?,?)",
(collection_id, name.strip(), description.strip(), now, now),
)
return collection_id
def get_or_create_collection(conn: sqlite3.Connection, name: str) -> str:
row = collection_by_name(conn, name)
return str(row["id"]) if row else create_collection(conn, name)
def _ensure_collection_items(conn: sqlite3.Connection, collection_id: str) -> None:
"""Import membership rows written by pre-v14 code or an old backup.
``collection_items`` is the canonical hierarchy. ``collection_videos`` is
deliberately kept as a flattened projection because older clients and the
main video query use it efficiently.
"""
rows = conn.execute(
"SELECT cv.video_id,cv.position,cv.added_at FROM collection_videos cv "
"LEFT JOIN collection_items ci ON ci.video_id=cv.video_id "
"WHERE cv.collection_id=? AND ci.id IS NULL ORDER BY cv.position,cv.added_at,cv.video_id",
(collection_id,),
).fetchall()
for row in rows:
item_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"imagefind:collection-item:{row['video_id']}"))
conn.execute(
"INSERT OR IGNORE INTO collection_items(id,collection_id,parent_id,kind,name,video_id,position,"
"created_at,updated_at) VALUES(?,?,NULL,'video',NULL,?,?,?,?)",
(item_id, collection_id, row["video_id"], int(row["position"] or 0), row["added_at"], row["added_at"]),
)
def _item_rows(conn: sqlite3.Connection, collection_id: str) -> list[sqlite3.Row]:
return conn.execute(
"SELECT ci.*,coalesce(v.duration_ms,0) AS duration_ms FROM collection_items ci "
"LEFT JOIN videos v ON v.id=ci.video_id WHERE ci.collection_id=? "
"ORDER BY ci.position,ci.created_at,ci.id",
(collection_id,),
).fetchall()
def _flatten_item_rows(conn: sqlite3.Connection, collection_id: str) -> list[sqlite3.Row]:
rows = _item_rows(conn, collection_id)
children: dict[str | None, list[sqlite3.Row]] = {}
for row in rows:
children.setdefault(str(row["parent_id"]) if row["parent_id"] else None, []).append(row)
flattened: list[sqlite3.Row] = []
visited: set[str] = set()
def walk(parent_id: str | None) -> None:
for row in children.get(parent_id, []):
item_id = str(row["id"])
if item_id in visited:
continue
visited.add(item_id)
flattened.append(row)
if row["kind"] == "group":
walk(item_id)
walk(None)
# Corrupt/orphaned nodes must not make videos disappear. Validation in the
# write APIs prevents this path in normal operation.
for row in rows:
if str(row["id"]) not in visited:
flattened.append(row)
return flattened
def _sync_flat_projection(conn: sqlite3.Connection, collection_id: str) -> list[str]:
videos = [row for row in _flatten_item_rows(conn, collection_id) if row["kind"] == "video"]
conn.execute("DELETE FROM collection_videos WHERE collection_id=?", (collection_id,))
conn.executemany(
"INSERT INTO collection_videos(collection_id,video_id,position,added_at) VALUES(?,?,?,?)",
(
(collection_id, row["video_id"], position, row["created_at"])
for position, row in enumerate(videos)
),
)
return [str(row["video_id"]) for row in videos]
def _ordered_video_ids(conn: sqlite3.Connection, collection_id: str) -> list[str]:
return [
str(row["video_id"])
for row in _flatten_item_rows(conn, collection_id)
if row["kind"] == "video"
]
def _validate_parent(
conn: sqlite3.Connection, collection_id: str, parent_id: str | None
) -> sqlite3.Row | None:
if parent_id is None:
return None
row = conn.execute(
"SELECT * FROM collection_items WHERE id=? AND collection_id=? AND kind='group'",
(parent_id, collection_id),
).fetchone()
if not row:
raise ValueError("目标分组不存在或不属于当前合集")
return row
def _normalize_siblings(
conn: sqlite3.Connection, collection_id: str, parent_id: str | None
) -> None:
condition = "parent_id IS NULL" if parent_id is None else "parent_id=?"
args: tuple[object, ...] = (collection_id,) if parent_id is None else (collection_id, parent_id)
rows = conn.execute(
f"SELECT id FROM collection_items WHERE collection_id=? AND {condition} "
"ORDER BY position,created_at,id",
args,
).fetchall()
conn.executemany(
"UPDATE collection_items SET position=? WHERE id=?",
((position, row["id"]) for position, row in enumerate(rows)),
)
def collection_tree(conn: sqlite3.Connection, collection_id: str) -> list[dict[str, Any]]:
rows = _item_rows(conn, collection_id)
paths: dict[str, list[str]] = {}
nodes: dict[str, dict[str, Any]] = {}
child_rows: dict[str | None, list[sqlite3.Row]] = {}
for row in rows:
child_rows.setdefault(str(row["parent_id"]) if row["parent_id"] else None, []).append(row)
def build(parent_id: str | None, prefix: list[str], ancestry: set[str]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for row in child_rows.get(parent_id, []):
item_id = str(row["id"])
if item_id in ancestry:
continue
if row["kind"] == "group":
path = [*prefix, str(row["name"])]
children = build(item_id, path, ancestry | {item_id})
node: dict[str, Any] = {
"id": item_id,
"collection_id": collection_id,
"parent_id": row["parent_id"],
"kind": "group",
"name": row["name"],
"position": int(row["position"]),
"path": path,
"children": children,
"video_count": sum(
child["video_count"] if child["kind"] == "group" else 1 for child in children
),
"duration_ms": sum(
child["duration_ms"] if child["kind"] == "group" else int(child.get("duration_ms") or 0)
for child in children
),
}
else:
path = list(prefix)
paths[str(row["video_id"])] = path
node = {
"id": item_id,
"collection_id": collection_id,
"parent_id": row["parent_id"],
"kind": "video",
"video_id": row["video_id"],
"position": int(row["position"]),
"path": path,
"duration_ms": int(row["duration_ms"] or 0),
}
nodes[item_id] = node
result.append(node)
return result
return build(None, [], set())
def video_item_metadata(conn: sqlite3.Connection, collection_id: str) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
def visit(nodes: list[dict[str, Any]]) -> None:
for node in nodes:
if node["kind"] == "group":
visit(node["children"])
else:
result[str(node["video_id"])] = {
"collection_item_id": node["id"],
"collection_parent_id": node["parent_id"],
"collection_path": node["path"],
}
visit(collection_tree(conn, collection_id))
return result
def _refresh_collection(conn: sqlite3.Connection, collection_id: str) -> None:
_ensure_collection_items(conn, collection_id)
rows = _sync_flat_projection(conn, collection_id)
cover = conn.execute(
"SELECT cover_video_id FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not cover:
return
cover_video_id = cover["cover_video_id"]
if not cover_video_id or cover_video_id not in rows:
cover_video_id = rows[0] if rows else None
conn.execute(
"UPDATE collections SET cover_video_id=?,updated_at=? WHERE id=?",
(cover_video_id, utcnow(), collection_id),
)
def assign_videos(
conn: sqlite3.Connection,
collection_id: str | None,
video_ids: Iterable[str],
position: int | None = None,
parent_id: str | None = None,
) -> int:
selected = list(dict.fromkeys(str(value) for value in video_ids))
if not selected:
return 0
placeholders = ",".join("?" for _ in selected)
existing = {
str(row["id"])
for row in conn.execute(f"SELECT id FROM videos WHERE id IN ({placeholders})", selected).fetchall()
}
missing = [video_id for video_id in selected if video_id not in existing]
if missing:
raise KeyError(missing)
collection_name: str | None = None
if collection_id is not None:
target = conn.execute("SELECT name FROM collections WHERE id=?", (collection_id,)).fetchone()
if not target:
raise LookupError(collection_id)
collection_name = str(target["name"])
_validate_parent(conn, collection_id, parent_id)
legacy_memberships = conn.execute(
f"SELECT DISTINCT collection_id FROM collection_videos WHERE video_id IN ({placeholders})",
selected,
).fetchall()
for row in legacy_memberships:
_ensure_collection_items(conn, str(row["collection_id"]))
memberships = {
str(row["video_id"]): row
for row in conn.execute(
f"SELECT id,video_id,collection_id,parent_id FROM collection_items "
f"WHERE kind='video' AND video_id IN ({placeholders})",
selected,
).fetchall()
}
previous_ids = {str(row["collection_id"]) for row in memberships.values()}
moving = [
video_id
for video_id in selected
if collection_id is None
or memberships.get(video_id) is None
or str(memberships[video_id]["collection_id"]) != collection_id
or memberships[video_id]["parent_id"] != parent_id
or position is not None
]
previous_parents: list[tuple[str, str | None]] = []
for video_id in moving:
membership = memberships.get(video_id)
if membership:
previous_parents.append(
(str(membership["collection_id"]), str(membership["parent_id"]) if membership["parent_id"] else None)
)
conn.execute("DELETE FROM collection_items WHERE id=?", (membership["id"],))
if moving:
moving_placeholders = ",".join("?" for _ in moving)
conn.execute(
f"DELETE FROM collection_videos WHERE video_id IN ({moving_placeholders})", moving
)
for previous_collection, previous_parent in previous_parents:
_normalize_siblings(conn, previous_collection, previous_parent)
if collection_id is not None:
condition = "parent_id IS NULL" if parent_id is None else "parent_id=?"
sibling_args: tuple[object, ...] = (
(collection_id,) if parent_id is None else (collection_id, parent_id)
)
siblings = conn.execute(
f"SELECT id FROM collection_items WHERE collection_id=? AND {condition} "
"ORDER BY position,created_at,id",
sibling_args,
).fetchall()
insertion = len(siblings) if position is None else min(max(position, 0), len(siblings))
for offset, row in enumerate(siblings[insertion:], start=insertion + len(moving)):
conn.execute("UPDATE collection_items SET position=? WHERE id=?", (offset, row["id"]))
now = utcnow()
for offset, video_id in enumerate(moving):
conn.execute(
"INSERT INTO collection_items(id,collection_id,parent_id,kind,name,video_id,position,"
"created_at,updated_at) "
"VALUES(?,?,?,'video',NULL,?,?,?,?)",
(str(uuid.uuid4()), collection_id, parent_id, video_id, insertion + offset, now, now),
)
_normalize_siblings(conn, collection_id, parent_id)
apply_video_tags(conn, selected, collection_tag_ids(conn, collection_id))
now = utcnow()
conn.executemany(
"INSERT INTO video_metadata(video_id,series,updated_at) VALUES(?,?,?) "
"ON CONFLICT(video_id) DO UPDATE SET series=excluded.series,updated_at=excluded.updated_at",
((video_id, collection_name, now) for video_id in selected),
)
for affected in previous_ids | ({collection_id} if collection_id else set()):
_refresh_collection(conn, affected)
return len(selected)
def create_group(
conn: sqlite3.Connection,
collection_id: str,
name: str,
parent_id: str | None = None,
position: int | None = None,
) -> str:
if not conn.execute("SELECT 1 FROM collections WHERE id=?", (collection_id,)).fetchone():
raise LookupError(collection_id)
_validate_parent(conn, collection_id, parent_id)
cleaned = name.strip()
if not cleaned:
raise ValueError("分组名称不能为空")
condition = "parent_id IS NULL" if parent_id is None else "parent_id=?"
args: tuple[object, ...] = (collection_id,) if parent_id is None else (collection_id, parent_id)
siblings = conn.execute(
f"SELECT id FROM collection_items WHERE collection_id=? AND {condition} ORDER BY position,created_at,id",
args,
).fetchall()
insertion = len(siblings) if position is None else min(max(position, 0), len(siblings))
for new_position, row in enumerate(siblings[insertion:], start=insertion + 1):
conn.execute("UPDATE collection_items SET position=? WHERE id=?", (new_position, row["id"]))
item_id = str(uuid.uuid4())
now = utcnow()
conn.execute(
"INSERT INTO collection_items(id,collection_id,parent_id,kind,name,video_id,position,created_at,updated_at) "
"VALUES(?,?,?,'group',?,NULL,?,?,?)",
(item_id, collection_id, parent_id, cleaned, insertion, now, now),
)
_refresh_collection(conn, collection_id)
return item_id
def rename_group(conn: sqlite3.Connection, collection_id: str, group_id: str, name: str) -> None:
cleaned = name.strip()
if not cleaned:
raise ValueError("分组名称不能为空")
if not conn.execute(
"UPDATE collection_items SET name=?,updated_at=? WHERE id=? AND collection_id=? AND kind='group'",
(cleaned, utcnow(), group_id, collection_id),
).rowcount:
raise LookupError(group_id)
_refresh_collection(conn, collection_id)
def _available_group_name(
conn: sqlite3.Connection,
collection_id: str,
parent_id: str | None,
name: str,
exclude_id: str | None = None,
) -> str:
condition = "parent_id IS NULL" if parent_id is None else "parent_id=?"
def occupied(candidate: str) -> bool:
args: tuple[object, ...] = (
(collection_id, candidate, exclude_id or "")
if parent_id is None
else (collection_id, parent_id, candidate, exclude_id or "")
)
return bool(
conn.execute(
f"SELECT 1 FROM collection_items WHERE collection_id=? AND {condition} "
"AND kind='group' AND lower(name)=lower(?) AND id<>?",
args,
).fetchone()
)
if not occupied(name):
return name
suffix = 2
candidate = f"{name} ({suffix})"
while occupied(candidate):
suffix += 1
candidate = f"{name} ({suffix})"
return candidate
def delete_group(conn: sqlite3.Connection, collection_id: str, group_id: str) -> int:
group = conn.execute(
"SELECT * FROM collection_items WHERE id=? AND collection_id=? AND kind='group'",
(group_id, collection_id),
).fetchone()
if not group:
raise LookupError(group_id)
parent_id = str(group["parent_id"]) if group["parent_id"] else None
children = conn.execute(
"SELECT id,kind,name FROM collection_items WHERE parent_id=? ORDER BY position,created_at,id",
(group_id,),
).fetchall()
if children:
sibling_condition = "parent_id IS NULL" if parent_id is None else "parent_id=?"
sibling_args: tuple[object, ...] = (
(len(children) - 1, collection_id, int(group["position"]), group_id)
if parent_id is None
else (len(children) - 1, collection_id, parent_id, int(group["position"]), group_id)
)
conn.execute(
f"UPDATE collection_items SET position=position+? WHERE collection_id=? AND {sibling_condition} "
"AND position>? AND id<>?",
sibling_args,
)
for offset, child in enumerate(children):
promoted_name = child["name"]
if child["kind"] == "group":
promoted_name = _available_group_name(
conn, collection_id, parent_id, str(child["name"]), str(child["id"])
)
conn.execute(
"UPDATE collection_items SET parent_id=?,name=?,position=?,updated_at=? WHERE id=?",
(
parent_id,
promoted_name,
int(group["position"]) + offset,
utcnow(),
child["id"],
),
)
conn.execute("DELETE FROM collection_items WHERE id=?", (group_id,))
_normalize_siblings(conn, collection_id, parent_id)
_refresh_collection(conn, collection_id)
return len(children)
def move_item(
conn: sqlite3.Connection,
collection_id: str,
item_id: str,
parent_id: str | None,
position: int,
) -> None:
item = conn.execute(
"SELECT * FROM collection_items WHERE id=? AND collection_id=?", (item_id, collection_id)
).fetchone()
if not item:
raise LookupError(item_id)
_validate_parent(conn, collection_id, parent_id)
if item_id == parent_id:
raise ValueError("分组不能移动到自身")
if item["kind"] == "group" and parent_id:
descendants = conn.execute(
"WITH RECURSIVE descendants(id) AS (SELECT id FROM collection_items WHERE parent_id=? "
"UNION ALL SELECT ci.id FROM collection_items ci JOIN descendants d ON ci.parent_id=d.id) "
"SELECT 1 FROM descendants WHERE id=? LIMIT 1",
(item_id, parent_id),
).fetchone()
if descendants:
raise ValueError("分组不能移动到自己的下级")
old_parent = str(item["parent_id"]) if item["parent_id"] else None
conn.execute("UPDATE collection_items SET position=2147483647 WHERE id=?", (item_id,))
_normalize_siblings(conn, collection_id, old_parent)
condition = "parent_id IS NULL" if parent_id is None else "parent_id=?"
args: tuple[object, ...] = (collection_id,) if parent_id is None else (collection_id, parent_id)
siblings = conn.execute(
f"SELECT id FROM collection_items WHERE collection_id=? AND {condition} AND id<>? "
"ORDER BY position,created_at,id",
(*args, item_id),
).fetchall()
insertion = min(max(position, 0), len(siblings))
conn.execute(
"UPDATE collection_items SET parent_id=?,position=?,updated_at=? WHERE id=?",
(parent_id, insertion, utcnow(), item_id),
)
_normalize_siblings(conn, collection_id, parent_id)
_refresh_collection(conn, collection_id)
def reorder_videos(conn: sqlite3.Connection, collection_id: str, video_ids: Iterable[str]) -> int:
ordered = list(str(value) for value in video_ids)
if len(ordered) != len(set(ordered)):
raise ValueError("合集排序不能包含重复视频")
rows = _item_rows(conn, collection_id)
if any(row["kind"] == "group" or row["parent_id"] is not None for row in rows):
raise ValueError("分层合集请使用节点移动接口调整顺序")
current = _ordered_video_ids(conn, collection_id)
if set(ordered) != set(current) or len(ordered) != len(current):
raise ValueError("排序列表必须完整包含合集中的全部视频")
for position, video_id in enumerate(ordered):
conn.execute("UPDATE collection_items SET position=? WHERE video_id=?", (position, video_id))
_refresh_collection(conn, collection_id)
return len(ordered)
def merge_collections(conn: sqlite3.Connection, source_id: str, target_id: str) -> int:
if source_id == target_id:
return 0
source = conn.execute("SELECT id FROM collections WHERE id=?", (source_id,)).fetchone()
target = conn.execute("SELECT name FROM collections WHERE id=?", (target_id,)).fetchone()
if not source or not target:
raise LookupError(source_id if not source else target_id)
source_rules = conn.execute(
"SELECT ct.tag_id,t.group_id,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 ct.created_at,ct.tag_id",
(source_id,),
).fetchall()
target_rules = conn.execute(
"SELECT ct.tag_id,t.group_id,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 ct.created_at,ct.tag_id",
(target_id,),
).fetchall()
target_single_groups = {
str(row["group_id"]) for row in target_rules if row["selection_mode"] == "single"
}
merged_rules = [str(row["tag_id"]) for row in target_rules]
merged_rules.extend(
str(row["tag_id"])
for row in source_rules
if row["selection_mode"] != "single" or str(row["group_id"]) not in target_single_groups
)
set_collection_tags(conn, target_id, merged_rules)
source_videos = _ordered_video_ids(conn, source_id)
_ensure_collection_items(conn, target_id)
_ensure_collection_items(conn, source_id)
target_roots = conn.execute(
"SELECT id,name FROM collection_items WHERE collection_id=? AND parent_id IS NULL "
"ORDER BY position,created_at,id",
(target_id,),
).fetchall()
used_names = {str(row["name"]).casefold() for row in target_roots if row["name"]}
source_roots = conn.execute(
"SELECT * FROM collection_items WHERE collection_id=? AND parent_id IS NULL "
"ORDER BY position,created_at,id",
(source_id,),
).fetchall()
for offset, row in enumerate(source_roots, start=len(target_roots)):
if row["kind"] == "group" and str(row["name"]).casefold() in used_names:
base = str(row["name"])
suffix = 2
candidate = f"{base} ({suffix})"
while candidate.casefold() in used_names:
suffix += 1
candidate = f"{base} ({suffix})"
conn.execute("UPDATE collection_items SET name=? WHERE id=?", (candidate, row["id"]))
used_names.add(candidate.casefold())
conn.execute("UPDATE collection_items SET position=? WHERE id=?", (offset, row["id"]))
conn.execute("UPDATE collection_items SET collection_id=? WHERE collection_id=?", (target_id, source_id))
if source_videos:
now = utcnow()
conn.executemany(
"INSERT INTO video_metadata(video_id,series,updated_at) VALUES(?,?,?) "
"ON CONFLICT(video_id) DO UPDATE SET series=excluded.series,updated_at=excluded.updated_at",
((video_id, str(target["name"]), now) for video_id in source_videos),
)
apply_video_tags(conn, source_videos, collection_tag_ids(conn, target_id))
conn.execute("DELETE FROM collections WHERE id=?", (source_id,))
_refresh_collection(conn, target_id)
return len(source_videos)
def dissolve_collection(conn: sqlite3.Connection, collection_id: str) -> int:
video_ids = _ordered_video_ids(conn, collection_id)
if video_ids:
assign_videos(conn, None, video_ids)
result = conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
if not result.rowcount:
raise LookupError(collection_id)
return len(video_ids)
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
import os
import secrets
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="IMAGEFIND_", extra="ignore")
data_dir: Path = Path("./data")
frontend_dir: Path | None = None
host: str = "127.0.0.1"
port: int = 8765
# Runtime-only loopback listener used by ffmpeg/ffprobe for remote media.
# Zero asks the kernel for a free port during normal ImageFind startup.
internal_media_port: int = Field(default=0, ge=0, le=65535)
direct_access: bool = True
gateway_socket: Path | None = None
gateway_prefix: str = "/app/imagefind"
gateway_socket_mode: int = 0o600
public_base_url: str = ""
cookie_secure: bool = False
session_days: int = 90
postgres_conf: Path | None = None
postgres_enrollment_token_seed: Path | None = None
ffmpeg_path: str = "ffmpeg"
ffprobe_path: str = "ffprobe"
scan_interval_seconds: int = 86400
job_poll_seconds: float = 1.0
max_upload_mb: int = 20
upload_chunk_mb: int = 16
upload_staging_gb: float = 100.0
upload_reserve_gb: float = 5.0
upload_incomplete_days: int = 7
upload_failed_hours: int = 72
trash_retention_days: int = 30
remote_timeout_seconds: float = 30.0
remote_cache_gb: float = 10.0
remote_cache_idle_seconds: int = Field(default=600, ge=60, le=86400)
remote_cache_partial_hours: int = Field(default=24, ge=1, le=168)
remote_max_connections: int = 3
# OpenList owns the actual remote copy. Keep its task polling bounded so
# a dead task cannot occupy an ImageFind worker forever.
openlist_copy_timeout_seconds: int = Field(default=86400, ge=300, le=604800)
openlist_copy_stall_seconds: int = Field(default=1800, ge=60, le=86400)
preview_cache_gb: float = 5.0
preview_window_seconds: int = 180
visual_image_model: str = "clip-ViT-B-32"
visual_text_model: str = "clip-ViT-B-32-multilingual-v1"
# Prefer OpenVINO's tested, pre-exported multilingual Whisper model. This
# avoids device-specific IR differences from exporting PyTorch weights on
# the NAS while retaining compatibility with Optimum Intel.
audio_model: str = "OpenVINO/whisper-small-fp16-ov"
# INT8 is materially smaller than the FP16 Medium export and avoids
# exhausting shared system memory on Intel integrated GPUs such as N100.
audio_medium_model: str = "OpenVINO/whisper-medium-int8-ov"
audio_model_variant: str = Field(default="small", pattern=r"^(small|medium)$")
audio_chunk_seconds: int = Field(default=30, ge=15, le=300)
audio_overlap_seconds: int = Field(default=2, ge=0, le=15)
audio_stall_seconds: int = Field(default=600, ge=30, le=7200)
audio_gpu_stall_seconds: int = Field(default=180, ge=30, le=1800)
audio_max_new_tokens: int = Field(default=192, ge=64, le=448)
# Kept for environment compatibility with 0.5.19. Runtime decoding uses
# the explicit speed/balanced/accuracy profile in SpeechService instead.
audio_num_beams: int = Field(default=1, ge=1, le=5)
audio_language_policy: str = Field(default="zh_priority", pattern=r"^(zh_priority|auto|zh)$")
audio_quality_profile: str = Field(default="accuracy", pattern=r"^(speed|balanced|accuracy)$")
face_match_threshold: float = 0.72
embedding_backend: str = "auto"
ai_cpu_threads: int = Field(default=1, ge=1, le=8)
# GPU inference is deliberately bounded on integrated Intel graphics. The
# resource API persists the selected mode in PostgreSQL and workers pass
# it explicitly when they are isolated in a child process.
ai_gpu_mode: str = Field(default="balanced", pattern=r"^(low|balanced|throughput)$")
resource_cpu_pause_percent: float = Field(default=70.0, ge=20, le=100)
resource_cpu_resume_percent: float = Field(default=55.0, ge=10, le=95)
resource_memory_reserve_mb: int = Field(default=768, ge=128, le=65536)
resource_memory_reserve_percent: float = Field(default=20.0, ge=5, le=80)
resource_sample_seconds: float = Field(default=2.0, ge=0.25, le=30)
resource_disk_reserve_gb: float = Field(default=5.0, ge=0.5, le=1024)
model_bundle_url: str = ""
model_bundle_sha256: str = ""
# Model weights are large and should use the domestic mirror by default.
# Custom endpoints remain supported from Settings, but a fresh install does
# not require an overseas proxy merely to obtain Hugging Face snapshots.
model_hf_endpoint: str = "https://hf-mirror.com"
pip_index_url: str = "https://pypi.tuna.tsinghua.edu.cn/simple"
pytorch_index_url: str = "https://download.pytorch.org/whl/cpu"
model_zoo_url: str = (
"https://storage.openvinotoolkit.org/repositories/open_model_zoo/2022.3/models_bin/1"
)
model_upload_gb: float = 10.0
model_extract_gb: float = 30.0
runtime_requirements_dir: Path | None = None
bundled_ai_runtime: bool = False
bundled_tools_dir: Path | None = None
backup_upload_gb: float = 2.0
backup_extract_gb: float = 4.0
backup_reserve_mb: int = 64
app_name: str = "ImageFind"
rclone_path: str = "rclone"
aria2_path: str = "aria2c"
aria2_max_connections: int = Field(default=4, ge=1, le=16)
aria2_download_limit_mb: int = Field(default=20, ge=1, le=1024)
@property
def database_path(self) -> Path:
# The Database constructor still receives a stable path so existing
# code can derive the application data root. Runtime state lives in
# PostgreSQL credentials under TRIM_PKGVAR, not in this file.
return self.data_dir / "postgresql"
@property
def postgres_conf_path(self) -> Path:
return self.postgres_conf or self.data_dir.parent / "postgres-client.conf"
@property
def postgres_enrollment_seed_path(self) -> Path:
return self.postgres_enrollment_token_seed or self.data_dir.parent / "postgres-enrollment-token.seed"
@property
def thumbnails_dir(self) -> Path:
return self.data_dir / "thumbnails"
@property
def uploads_dir(self) -> Path:
return self.data_dir / "query-images"
@property
def upload_staging_dir(self) -> Path:
return self.data_dir / "upload-staging"
@property
def download_staging_dir(self) -> Path:
return self.data_dir / "download-staging"
@property
def rclone_dir(self) -> Path:
return self.data_dir / "rclone"
@property
def remote_media_cache_dir(self) -> Path:
return self.data_dir / "remote-media-cache"
@property
def preview_dir(self) -> Path:
return self.data_dir / "preview-cache"
@property
def models_dir(self) -> Path:
return self.data_dir / "models"
@property
def runtime_dir(self) -> Path:
return self.data_dir.parent / "runtime"
@property
def ai_site_path(self) -> Path:
return self.runtime_dir / "ai-current"
@property
def internal_token_path(self) -> Path:
return self.data_dir / ".internal-token"
def prepare(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True)
self.thumbnails_dir.mkdir(parents=True, exist_ok=True)
self.uploads_dir.mkdir(parents=True, exist_ok=True)
self.upload_staging_dir.mkdir(parents=True, exist_ok=True)
self.download_staging_dir.mkdir(parents=True, exist_ok=True)
self.rclone_dir.mkdir(parents=True, exist_ok=True)
self.remote_media_cache_dir.mkdir(parents=True, exist_ok=True)
os.chmod(self.upload_staging_dir, 0o700)
os.chmod(self.download_staging_dir, 0o700)
os.chmod(self.rclone_dir, 0o700)
os.chmod(self.remote_media_cache_dir, 0o700)
self.preview_dir.mkdir(parents=True, exist_ok=True)
self.models_dir.mkdir(parents=True, exist_ok=True)
self.runtime_dir.mkdir(parents=True, exist_ok=True)
if not self.internal_token_path.exists():
self.internal_token_path.write_text(secrets.token_urlsafe(48), encoding="utf-8")
os.chmod(self.internal_token_path, 0o600)
def internal_token(self) -> str:
self.prepare()
return self.internal_token_path.read_text(encoding="utf-8").strip()
@lru_cache
def get_settings() -> Settings:
settings = Settings()
settings.prepare()
return settings
+692
View File
@@ -0,0 +1,692 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import time
from pathlib import Path
import httpx
from watchfiles import awatch
from .accelerator import AcceleratorService
from .backups import BackupService
from .config import Settings
from .database import Database, utcnow
from .deletion import VideoDeletionService
from .diagnostics import Diagnostics
from .downloads import DownloadService
from .events import EventBroker
from .indexer import Indexer
from .inference import (
InferenceSupervisor,
IsolatedEmbeddingService,
IsolatedFaceService,
IsolatedOcrService,
)
from .jobs import JobQueue
from .media import MediaService
from .models import ModelManager
from .openlist_native import OpenListNativeService
from .preview import PreviewService
from .remote_cache import RemoteMediaCache
from .resources import ResourceGovernor
from .runtime import RuntimeToolManager
from .scanner import Scanner
from .search import SearchService
from .security import AuthService, SecretStore
from .sources import SourceService
from .speech import SPEECH_INDEX_REVISION, AudioIndexer, SpeechService
from .speech_quality import normalize_language, transcript_quality
from .storage import StorageService
from .tags import TagSuggestionService
from .uploads import UploadService
from .usage import StorageUsageService
from .vectors import make_vector_store
logger = logging.getLogger(__name__)
class Services:
def __init__(self, settings: Settings):
self.settings = settings
self.diagnostics = Diagnostics()
self.db = Database(settings.database_path)
self.db.initialize()
configured_hf_endpoint = self.db.setting("model_hf_endpoint", settings.model_hf_endpoint)
if isinstance(configured_hf_endpoint, str) and configured_hf_endpoint:
# Move the former overseas default to the domestic mirror. Explicit
# custom mirrors are preserved.
if configured_hf_endpoint.rstrip("/") == "https://huggingface.co":
configured_hf_endpoint = "https://hf-mirror.com"
self.db.set_setting("model_hf_endpoint", configured_hf_endpoint)
self.settings.model_hf_endpoint = configured_hf_endpoint
configured_pip_index = self.db.setting("pip_index_url", settings.pip_index_url)
if isinstance(configured_pip_index, str) and configured_pip_index:
self.settings.pip_index_url = configured_pip_index
configured_pytorch_index = self.db.setting("pytorch_index_url", settings.pytorch_index_url)
if isinstance(configured_pytorch_index, str):
self.settings.pytorch_index_url = configured_pytorch_index
speech_config = self.db.setting("speech_config", {})
if isinstance(speech_config, dict):
language_policy = speech_config.get("language_policy")
quality_profile = speech_config.get("quality_profile")
model_variant = speech_config.get("model_variant")
if language_policy in {"zh_priority", "auto", "zh"}:
self.settings.audio_language_policy = language_policy
if quality_profile in {"speed", "balanced", "accuracy"}:
self.settings.audio_quality_profile = quality_profile
if model_variant in {"small", "medium"}:
self.settings.audio_model_variant = model_variant
self.secrets = SecretStore(settings.data_dir / ".secret-key")
self.auth = AuthService(self.db, settings.session_days)
self.runtime_tools = RuntimeToolManager(settings)
self.sources = SourceService(self.db, settings, self.secrets, self.runtime_tools)
self.resources = ResourceGovernor(self.db, settings)
self.remote_cache = RemoteMediaCache(settings, self.sources, self.resources)
self.storage = StorageService(self.db, settings, self.sources, self.resources)
self.openlist_native = OpenListNativeService(self.db, settings, self.sources, self.secrets)
self.storage.openlist_native = self.openlist_native
self.vectors = make_vector_store(self.db)
self.events = EventBroker()
self.jobs = JobQueue(self.db, settings.job_poll_seconds, self.resources, self.events)
self.backups = BackupService(self.db, settings, self.secrets, self.jobs)
self.uploads = UploadService(self.db, settings, self.jobs, self.storage, self.openlist_native)
self.downloads = DownloadService(self.db, settings, self.jobs, self.storage)
self.storage_usage = StorageUsageService(
self.db,
settings,
self.sources.rclone,
self.remote_cache,
self.sources,
)
self.uploads.storage_usage = self.storage_usage
self.accelerator = AcceleratorService(settings)
self.inference = InferenceSupervisor(settings, self.accelerator)
self.embeddings = IsolatedEmbeddingService(settings, self.accelerator, self.inference)
self.ocr = IsolatedOcrService(settings, self.accelerator, self.inference)
self.faces = IsolatedFaceService(settings, self.accelerator, self.inference)
self.speech = SpeechService(settings, self.accelerator, self.runtime_tools)
self.models = ModelManager(
settings,
self.embeddings,
self.ocr,
self.faces,
self.db,
self.secrets,
speech=self.speech,
)
variants = self.models.audio_variants()
active_medium = variants.get("medium") or {}
available_small = variants.get("small") or {}
if (
self.settings.audio_model_variant == "medium"
and active_medium.get("needs_update")
and available_small.get("installed")
):
# 0.5.22 used a much larger FP16 Medium export. Keep the old files
# available for atomic replacement, but never start new jobs with
# that stale variant after the INT8 upgrade.
self.settings.audio_model_variant = "small"
value = dict(speech_config) if isinstance(speech_config, dict) else {}
value.update(
{
"language_policy": self.settings.audio_language_policy,
"quality_profile": self.settings.audio_quality_profile,
"model_variant": "small",
}
)
self.db.set_setting("speech_config", value)
logger.warning("Whisper Medium FP16 需要更新为 INT8,已暂时切回 Small")
self.runtime_tools.set_client_factory(self.models._client)
self.media = MediaService(settings, self.runtime_tools, self.remote_cache)
self.preview = PreviewService(self.db, settings, self.media)
self.deletions = VideoDeletionService(
self.db, self.jobs, self.storage, self.vectors, self.preview, settings, self.remote_cache
)
self.scanner = Scanner(self.db, self.sources, self.jobs, self.vectors, self.openlist_native)
self.indexer = Indexer(
self.db,
self.jobs,
self.media,
self.embeddings,
self.ocr,
self.faces,
self.vectors,
settings.thumbnails_dir,
settings.face_match_threshold,
self.models,
)
self.audio_indexer = AudioIndexer(self.db, self.jobs, self.media, self.speech, self.models)
self.search = SearchService(self.db, self.embeddings, self.vectors)
self.tag_suggestions = TagSuggestionService(self.db, self.embeddings, self.vectors, self.jobs)
self.remote_semaphore = asyncio.Semaphore(max(1, settings.remote_max_connections))
remote_timeout = httpx.Timeout(
connect=max(10.0, settings.remote_timeout_seconds),
read=None,
write=30.0,
pool=30.0,
)
remote_limits = httpx.Limits(
max_connections=max(1, settings.remote_max_connections),
max_keepalive_connections=max(1, settings.remote_max_connections),
keepalive_expiry=30.0,
)
self.remote_http = httpx.AsyncClient(
verify=True,
timeout=remote_timeout,
follow_redirects=True,
trust_env=False,
limits=remote_limits,
)
self.remote_http_insecure = httpx.AsyncClient(
verify=False,
timeout=remote_timeout,
follow_redirects=True,
trust_env=False,
limits=remote_limits,
)
self.jobs.register("scan_source", self.scanner.handle)
self.jobs.register("refresh_path", self.scanner.handle_refresh)
self.jobs.register("index_video", self.indexer.handle)
self.jobs.register("transcribe_audio", self.audio_indexer.handle)
self.jobs.register("transfer_upload", self.uploads.handle)
self.jobs.register("install_models", self._install_models)
self.jobs.register("prepare_ai_runtime", self._prepare_ai_runtime)
self.jobs.register("suggest_tags", self.tag_suggestions.handle)
self.jobs.register("aria2_download", self.downloads.handle)
self.jobs.register("backup_export", self._backup_export)
self._background: list[asyncio.Task] = []
self._stop = asyncio.Event()
async def _backup_export(self, job_id: str, payload: dict) -> None:
await asyncio.to_thread(self.backups.run_export, job_id, str(payload["backup_id"]))
async def _install_models(self, job_id: str, payload: dict) -> None:
archive_path = Path(payload["archive_path"]) if payload.get("archive_path") else None
component = str(payload.get("component") or "all")
labels = {"visual": "画面语义", "ocr": "OCR 文字", "faces": "人物识别", "audio": "音频识别", "all": "完整"}
def progress(value: float, message: str) -> None:
self.jobs.update(job_id, value, message)
self.jobs.update(job_id, 0.02, f"准备安装{labels.get(component, component)}模型")
try:
result = await asyncio.to_thread(
self.models.install,
payload.get("url"),
payload.get("sha256"),
archive_path=archive_path,
progress=progress,
component=component,
variant=payload.get("variant"),
)
queued = await asyncio.to_thread(self.reconcile_ai)
warnings = result["manifest"].get("warnings") or []
suffix = f"{warnings[0]}" if warnings else ""
self.jobs.update(
job_id,
1,
f"{labels.get(component, component)}模型安装完成,已排队补齐 {len(queued)} 部影片{suffix}",
)
finally:
if archive_path is not None:
archive_path.unlink(missing_ok=True)
async def _prepare_ai_runtime(self, job_id: str, payload: dict) -> None:
requested = payload.get("components") or []
components = [component for component in ("visual", "ocr", "faces", "audio") if component in requested]
if not components:
return
labels = {"visual": "画面语义", "ocr": "OCR 文字", "faces": "人物识别", "audio": "音频识别"}
for index, component in enumerate(components):
def runtime_progress(value: float, message: str, *, offset: int = index) -> None:
self.jobs.update(job_id, (offset + value * 0.65) / len(components), message)
def repair_progress(value: float, message: str, *, offset: int = index) -> None:
self.jobs.update(job_id, (offset + 0.65 + value * 0.35) / len(components), message)
self.jobs.update(job_id, index / len(components), f"修复{labels[component]}运行环境")
health = await asyncio.to_thread(self.models.component_health)
if health.get(component, {}).get("state") in {"outdated", "damaged"}:
self.jobs.update(job_id, index / len(components), f"重新安装{labels[component]}模型")
await asyncio.to_thread(
self.models.install,
progress=repair_progress,
component=component,
)
else:
await asyncio.to_thread(self.models.ensure_runtime, component, runtime_progress)
await asyncio.to_thread(self.models.repair_component, component, repair_progress)
status = await asyncio.to_thread(self.models.status, summary=True)
if not status.get("operational_components", {}).get(component):
component_health = status.get("component_health", {}).get(component, {})
detail = component_health.get("error") or "运行环境验证未通过"
raise RuntimeError(f"{labels[component]}修复后仍不可用:{detail}")
queued = await asyncio.to_thread(self.reconcile_ai)
self.jobs.update(job_id, 1, f"AI 运行环境已就绪,已排队补齐 {len(queued)} 部影片")
def _migrate_legacy_deletes(self) -> None:
with self.db.read() as conn:
rows = conn.execute(
"SELECT id,payload_json FROM jobs WHERE kind='delete_video' "
"AND status IN ('queued','running') ORDER BY created_at"
).fetchall()
for row in rows:
try:
payload = json.loads(row["payload_json"])
video_id = str(payload["video_id"])
result = self.deletions.delete(
video_id,
allow_deleting=True,
exclude_job_ids={row["id"]},
)
except Exception as exc:
logger.exception("legacy delete job %s failed during migration", row["id"])
with self.db.transaction() as conn:
conn.execute(
"UPDATE jobs SET status='failed',error=?,finished_at=?,lease_owner=NULL,"
"heartbeat_at=?,pause_reason=NULL WHERE id=?",
(str(exc)[:1000] or "旧版删除任务迁移失败", utcnow(), utcnow(), row["id"]),
)
else:
with self.db.transaction() as conn:
conn.execute(
"UPDATE jobs SET status='completed',progress=1,message=?,finished_at=?,"
"lease_owner=NULL,heartbeat_at=?,pause_reason=NULL WHERE id=?",
(
f"视频已移入回收站,已取消 {result['cancelled_jobs']} 个关联任务",
utcnow(),
utcnow(),
row["id"],
),
)
def _cancel_audio_for_pending_model_install(self) -> int:
with self.db.read() as conn:
rows = conn.execute(
"SELECT payload_json FROM jobs WHERE kind='install_models' AND status IN ('queued','running')"
).fetchall()
prepare_rows = conn.execute(
"SELECT payload_json FROM jobs WHERE kind='prepare_ai_runtime' AND status IN ('queued','running')"
).fetchall()
pending_audio = False
for row in rows:
try:
payload = json.loads(row["payload_json"])
except (TypeError, json.JSONDecodeError):
payload = {}
component = str(payload.get("component") or "all")
if component in {"audio", "all"} or payload.get("archive_path"):
pending_audio = True
break
if not pending_audio:
for row in prepare_rows:
try:
payload = json.loads(row["payload_json"])
except (TypeError, json.JSONDecodeError):
payload = {}
components = payload.get("components")
if isinstance(components, list) and "audio" in components:
pending_audio = True
break
if not pending_audio:
return 0
cancelled = self.jobs.cancel_by_kind("transcribe_audio")
if cancelled:
logger.warning("cancelled %s audio transcription jobs blocked by model installation", len(cancelled))
return len(cancelled)
def queue_missing_ai_runtime(self) -> str | None:
components = self.models.missing_runtime_components()
health = self.models.component_health()
outdated_audio = health.get("audio", {}).get("state") == "outdated"
if not components and not outdated_audio:
return None
job_ids = []
if outdated_audio:
job_ids.append(
self.jobs.enqueue(
"install_models",
{"component": "audio"},
dedupe_key="install-model:audio",
)
)
job_ids.extend(
self.jobs.enqueue(
"prepare_ai_runtime",
{"components": [component]},
dedupe_key=f"prepare-ai-runtime:{component}",
)
for component in components
if not (component == "audio" and outdated_audio)
)
return job_ids[0]
def reconcile_ai(self) -> list[str]:
versions = self.models.runnable_component_versions()
if not any(versions.values()):
return []
with self.db.read() as conn:
transient_index_video_ids: set[str] = set()
for failed in conn.execute(
"SELECT payload_json FROM jobs WHERE kind='index_video' AND status='failed' "
"AND (lower(coalesce(error,'')) LIKE '%serialization failure%' "
"OR lower(coalesce(error,'')) LIKE '%deadlock detected%' "
"OR lower(coalesce(error,'')) LIKE '%could not obtain lock%' "
"OR lower(coalesce(error,'')) LIKE '%数据库事务冲突%')"
).fetchall():
try:
payload = json.loads(failed["payload_json"])
except (TypeError, json.JSONDecodeError):
continue
if isinstance(payload, dict) and payload.get("video_id"):
transient_index_video_ids.add(str(payload["video_id"]))
rows = conn.execute(
"SELECT id,status,fingerprint,indexed_fingerprint,basic_fingerprint,visual_model_version,"
"ocr_model_version,faces_model_version,audio_model_version "
"FROM videos WHERE available=1 ORDER BY created_at"
).fetchall()
queued: list[str] = []
for row in rows:
basic_ready = row["basic_fingerprint"] == row["fingerprint"]
if row["status"] == "pending" or not row["basic_fingerprint"]:
queued.append(
self.jobs.enqueue(
"index_video",
{"video_id": row["id"], "reconcile": True},
dedupe_key=f"index:{row['id']}",
)
)
continue
recovering_transient_index = (
row["id"] in transient_index_video_ids and row["indexed_fingerprint"] != row["fingerprint"]
)
if not basic_ready and not recovering_transient_index:
continue
frame_missing = recovering_transient_index or any(
versions[name] and row[f"{name}_model_version"] != versions[name] for name in ("visual", "ocr", "faces")
)
if frame_missing:
queued.append(
self.jobs.enqueue(
"index_video",
{"video_id": row["id"], "reconcile": True},
dedupe_key=f"index:{row['id']}",
)
)
elif basic_ready and versions.get("audio") and row["audio_model_version"] != versions["audio"]:
queued.append(
self.jobs.enqueue(
"transcribe_audio",
{"video_id": row["id"]},
dedupe_key=f"audio:{row['id']}",
)
)
return queued
def reconcile_audio_quality(self) -> dict[str, int]:
"""Keep legacy text searchable while scheduling the revised pipeline."""
with self.db.read() as conn:
rows = conn.execute(
"SELECT v.id,v.audio_detected_language,v.audio_quality_score,v.audio_rejected_segments,"
"e.id AS entry_id,e.start_ms,e.end_ms,e.raw_text "
"FROM videos v LEFT JOIN text_entries e ON e.video_id=v.id AND e.kind='audio' "
"WHERE v.available=1 AND v.audio_model_version IS NOT NULL AND v.audio_index_revision<? "
"ORDER BY v.id,e.start_ms,e.id",
(SPEECH_INDEX_REVISION,),
).fetchall()
grouped: dict[str, list] = {}
languages: dict[str, str | None] = {}
prior_quality: dict[str, float | None] = {}
prior_rejected: dict[str, int] = {}
for row in rows:
grouped.setdefault(row["id"], []).append(row)
languages[row["id"]] = row["audio_detected_language"]
prior_quality[row["id"]] = row["audio_quality_score"]
prior_rejected[row["id"]] = int(row["audio_rejected_segments"] or 0)
flagged = current = 0
for video_id, entries in grouped.items():
scored = []
bad_ids: list[str] = []
flags: list[str] = []
language = normalize_language(languages.get(video_id))
suspicious_language = language in {"nn", "jw"} or (
self.settings.audio_language_policy == "zh_priority" and language not in {None, "zh"}
)
legacy_low_quality = (
prior_quality.get(video_id) is not None and float(prior_quality[video_id]) < 0.55
) or prior_rejected.get(video_id, 0) > 0
if suspicious_language:
flags.append("untrusted_language")
if legacy_low_quality:
flags.append("legacy_low_quality")
for entry in entries:
if not entry["entry_id"]:
continue
quality = transcript_quality(
entry["raw_text"],
max(1, int(entry["end_ms"] or 1) - int(entry["start_ms"] or 0)),
expected_language=languages.get(video_id),
)
scored.append(quality.score)
flags.extend(quality.flags)
if not quality.accepted:
bad_ids.append(entry["entry_id"])
# Revision 4 changes language selection, segmentation and decoding.
# Structural checks cannot prove old words were heard correctly,
# therefore every prior revision is stale. Existing entries remain
# searchable until the replacement transaction succeeds.
flags.append("legacy_speech_pipeline")
with self.db.transaction() as conn:
conn.execute(
"UPDATE videos SET audio_model_version=NULL,audio_quality_score=?,"
"audio_rejected_segments=?,audio_quality_flags_json=?,updated_at=? WHERE id=?",
(
min(scored) if scored else float(prior_quality.get(video_id) or 0),
max(len(bad_ids), prior_rejected.get(video_id, 0)),
json.dumps(list(dict.fromkeys(flags)), ensure_ascii=False, separators=(",", ":")),
utcnow(),
video_id,
),
)
flagged += 1
return {"scanned": len(grouped), "flagged": flagged, "current": current}
async def start(self) -> None:
self.events.bind()
recovered_jobs = await asyncio.to_thread(self.jobs.recover_stale)
if recovered_jobs:
logger.warning("recovered %s jobs left by a previous process", recovered_jobs)
recovered_uploads = await asyncio.to_thread(self.uploads.recover_pending)
if recovered_uploads:
logger.warning("reconnected %s unfinished uploads", recovered_uploads)
cleaned_audio_workers = await asyncio.to_thread(self.speech.cleanup_orphaned_workers)
if cleaned_audio_workers:
logger.warning("cleaned %s orphaned audio workers during startup", cleaned_audio_workers)
cleaned_audio_workdirs = await asyncio.to_thread(self.speech.cleanup_stale_workdirs)
if cleaned_audio_workdirs:
logger.warning("cleaned %s stale audio work directories during startup", cleaned_audio_workdirs)
recovered_deleting = await asyncio.to_thread(self.deletions.recover_stale_deleting)
if recovered_deleting:
logger.warning("recovered %s stale visible deleting videos", recovered_deleting)
await asyncio.to_thread(self._migrate_legacy_deletes)
self.queue_missing_ai_runtime()
await asyncio.to_thread(self._cancel_audio_for_pending_model_install)
# Reconcile already-operational components after upgrades as well as
# immediately after an install. Active dedupe keys make this idempotent.
reconciled_ai = await asyncio.to_thread(self.reconcile_ai)
if reconciled_ai:
logger.warning(
"queued %s safe AI reconciliation jobs, including recoverable lock failures",
len(reconciled_ai),
)
self._background = [
# Each resource class gets one worker. Compute remains strictly
# serial while user-visible transfers and downloads can progress
# independently; per-source write locking protects destinations.
asyncio.create_task(self.jobs.run(lane="ai"), name="imagefind-ai-worker"),
asyncio.create_task(self.jobs.run(lane="transfer"), name="imagefind-transfer-worker"),
asyncio.create_task(self.jobs.run(lane="download"), name="imagefind-download-worker"),
asyncio.create_task(self.jobs.run(lane="scan"), name="imagefind-scan-worker"),
asyncio.create_task(self._recover_uploads(), name="imagefind-upload-recovery"),
asyncio.create_task(self._scheduled_scans(), name="imagefind-periodic-scanner"),
asyncio.create_task(self._watch_manager(), name="imagefind-watch-manager"),
asyncio.create_task(self._maintenance(), name="imagefind-maintenance"),
asyncio.create_task(self._repair_legacy_audio_quality(), name="imagefind-speech-quality-repair"),
asyncio.create_task(self.diagnostics.monitor(self._stop), name="imagefind-loop-monitor"),
]
async def _repair_legacy_audio_quality(self) -> None:
"""Audit legacy transcripts after the API and workers are available."""
try:
result = await asyncio.to_thread(self.reconcile_audio_quality)
if result["flagged"]:
queued = await asyncio.to_thread(self.reconcile_ai)
logger.warning(
"flagged %s legacy audio transcripts and queued %s repair jobs",
result["flagged"],
len(queued),
)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("legacy audio quality reconciliation failed")
async def stop(self) -> None:
self._stop.set()
self.jobs.stop()
for task in self._background:
task.cancel()
await asyncio.to_thread(self.speech.close)
await asyncio.to_thread(self.inference.close)
for task in self._background:
with contextlib.suppress(asyncio.CancelledError):
await task
try:
await asyncio.to_thread(self.auth.flush_api_token_usage)
except Exception:
logger.exception("final API token usage flush failed during shutdown")
await asyncio.to_thread(self.preview.close)
await asyncio.to_thread(self.downloads.close)
await asyncio.to_thread(self.openlist_native.close)
await asyncio.to_thread(self.sources.close)
await self.remote_http.aclose()
await self.remote_http_insecure.aclose()
await asyncio.to_thread(self.db.close)
async def _recover_uploads(self) -> None:
"""Continuously reconnect upload rows whose worker job terminated.
Startup recovery alone cannot repair a database-lock race that occurs
after the service is already running. ``recover_pending`` ignores
healthy queued/running jobs, so this lightweight sweep is safe while
browser and WebDAV transfers are active.
"""
while not self._stop.is_set():
try:
await asyncio.wait_for(self._stop.wait(), timeout=15)
continue
except TimeoutError:
pass
try:
recovered = await asyncio.to_thread(self.uploads.recover_pending)
if recovered:
logger.warning("reconnected %s orphaned uploads while running", recovered)
except Exception:
logger.exception("periodic upload recovery failed; the next sweep will retry")
try:
await asyncio.to_thread(self.auth.flush_api_token_usage)
except Exception:
logger.exception("periodic API token usage flush failed; the next sweep will retry")
async def _scheduled_scans(self) -> None:
while not self._stop.is_set():
await asyncio.to_thread(self.uploads.cleanup)
await asyncio.to_thread(self.storage.purge_expired)
for source in self.sources.list_sources():
if source["enabled"]:
self.jobs.enqueue(
"scan_source",
{"source_id": source["id"]},
dedupe_key=f"scan:{source['id']}",
)
try:
await asyncio.wait_for(self._stop.wait(), timeout=max(300, self.settings.scan_interval_seconds))
except TimeoutError:
pass
async def _maintenance(self) -> None:
last_history_cleanup = 0.0
while not self._stop.is_set():
now = time.monotonic()
try:
await asyncio.to_thread(self._cleanup_expired_state)
await asyncio.to_thread(self.backups.cleanup_exports)
if now - last_history_cleanup >= 3600:
await asyncio.to_thread(self.jobs.cleanup_history)
last_history_cleanup = now
except Exception:
logger.exception("periodic lifecycle cleanup failed")
try:
await asyncio.to_thread(self.sources.rclone.reconcile_orphans)
except Exception:
logger.exception("periodic rclone reconciliation failed")
try:
await asyncio.wait_for(self._stop.wait(), timeout=60)
except TimeoutError:
pass
def _cleanup_expired_state(self) -> None:
now = utcnow()
with self.db.transaction() as conn:
rows = conn.execute("DELETE FROM query_images WHERE expires_at<? RETURNING path", (now,)).fetchall()
conn.execute("DELETE FROM gateway_media_tokens WHERE expires_at<?", (now,))
conn.execute("DELETE FROM sessions WHERE expires_at<?", (now,))
for row in rows:
try:
Path(row["path"]).unlink(missing_ok=True)
except OSError:
logger.warning("unable to remove expired query image", exc_info=True)
async def _watch_one(self, source_id: str, path: str) -> None:
try:
async for changes in awatch(path, recursive=True, debounce=2000, stop_event=self._stop):
if changes:
self.jobs.enqueue(
"scan_source",
{"source_id": source_id},
dedupe_key=f"scan:{source_id}",
delay_seconds=2,
)
except (FileNotFoundError, PermissionError, OSError):
logger.warning("Unable to watch source %s at %s", source_id, path, exc_info=True)
async def _watch_manager(self) -> None:
watchers: dict[str, tuple[str, asyncio.Task]] = {}
while not self._stop.is_set():
current: dict[str, str] = {}
for source in self.sources.list_sources():
if source["enabled"] and source["kind"] == "local":
current[source["id"]] = source["config"]["path"]
for source_id, (path, task) in list(watchers.items()):
if source_id not in current or current[source_id] != path:
task.cancel()
watchers.pop(source_id)
for source_id, path in current.items():
if source_id not in watchers:
watchers[source_id] = (
path,
asyncio.create_task(self._watch_one(source_id, path), name=f"watch-{source_id}"),
)
try:
await asyncio.wait_for(self._stop.wait(), timeout=30)
except TimeoutError:
pass
for _, task in watchers.values():
task.cancel()
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
from __future__ import annotations
import contextlib
import logging
import threading
from pathlib import Path
from .database import Database, utcnow
from .vectors import normalize, pack_vector, unpack_vector
logger = logging.getLogger(__name__)
class VideoDeletionService:
"""Synchronously remove a video and all ImageFind-derived data.
Moving a source object and updating SQLite cannot be one real transaction.
The service therefore hides the video first and restores the source when a
required index/database stage fails. Cache files are derived and are
removed idempotently after the durable records have been committed.
"""
def __init__(self, db: Database, jobs, storage, vectors, preview, settings, remote_cache=None):
self.db = db
self.jobs = jobs
self.storage = storage
self.vectors = vectors
self.preview = preview
self.settings = settings
self.remote_cache = remote_cache
self._lock = threading.RLock()
@staticmethod
def _centroid(blobs: list[bytes]) -> bytes | None:
vectors = [unpack_vector(blob) for blob in blobs if blob]
vectors = [vector for vector in vectors if vector]
if not vectors:
return None
dimensions = len(vectors[0])
compatible = [vector for vector in vectors if len(vector) == dimensions]
if not compatible:
return None
return pack_vector(normalize([sum(vector[index] for vector in compatible) for index in range(dimensions)]))
def _restore_after_failure(
self,
video_id: str,
previous_status: str,
trash_id: str | None,
indexes_removed: bool,
cancelled_job_ids: list[str] | None = None,
) -> None:
if trash_id:
try:
self.storage.restore(trash_id)
except Exception:
logger.exception("unable to restore source after failed deletion for video %s", video_id)
with self.db.transaction() as conn:
conn.execute(
"DELETE FROM video_tombstones WHERE source_id=(SELECT source_id FROM videos WHERE id=?) "
"AND source_key=(SELECT source_key FROM videos WHERE id=?)",
(video_id, video_id),
)
if indexes_removed:
conn.execute(
"UPDATE videos SET available=1,status='pending',error='删除回滚后需要重新解析',"
"indexed_fingerprint=NULL,basic_fingerprint=NULL,visual_model_version=NULL,"
"ocr_model_version=NULL,faces_model_version=NULL,audio_model_version=NULL,updated_at=? "
"WHERE id=?",
(utcnow(), video_id),
)
else:
restored_status = "pending" if previous_status == "deleting" else previous_status
conn.execute(
"UPDATE videos SET available=1,status=?,error=NULL,updated_at=? WHERE id=?",
(restored_status, utcnow(), video_id),
)
if indexes_removed:
with contextlib.suppress(Exception):
self.jobs.enqueue("index_video", {"video_id": video_id}, dedupe_key=f"index:{video_id}")
if cancelled_job_ids:
try:
self.jobs.restore_cancelled(cancelled_job_ids)
except Exception:
logger.exception("unable to restore cancelled jobs after failed deletion for video %s", video_id)
def recover_stale_deleting(self) -> int:
with self.db.transaction() as conn:
rows = conn.execute(
"SELECT id,source_id,source_key,display_name FROM videos "
"WHERE available=1 AND status='deleting'"
).fetchall()
for row in rows:
conn.execute(
"INSERT INTO video_tombstones("
"source_id,source_key,video_id,display_name,source_deleted,deleted_at) "
"VALUES(?,?,?,?,0,?) ON CONFLICT(source_id,source_key) DO UPDATE SET "
"video_id=excluded.video_id,display_name=excluded.display_name,deleted_at=excluded.deleted_at",
(row["source_id"], row["source_key"], row["id"], row["display_name"], utcnow()),
)
conn.execute(
"UPDATE videos SET available=0,status='offline',"
"error='删除任务未完成,请重新扫描媒体源确认源文件状态',updated_at=? "
"WHERE available=1 AND status='deleting'",
(utcnow(),),
)
return len(rows)
def delete(
self,
video_id: str,
*,
delete_source: bool = True,
allow_deleting: bool = False,
exclude_job_ids: set[str] | None = None,
) -> dict:
with self._lock:
with self.db.read() as conn:
row = conn.execute(
"SELECT id,source_id,source_key,status,display_name,available FROM videos WHERE id=?",
(video_id,),
).fetchone()
if row is None or (not row["available"] and not (allow_deleting and row["status"] == "deleting")):
raise KeyError(video_id)
item = dict(row)
if delete_source:
self.storage.require_writable(item["source_id"])
previous_status = str(item["status"] or "indexed")
with self.db.transaction() as conn:
changed = conn.execute(
"UPDATE videos SET available=0,status='deleting',error=NULL,updated_at=? "
"WHERE id=? AND (available=1 OR status='deleting')",
(utcnow(), video_id),
).rowcount
conn.execute(
"INSERT INTO video_tombstones("
"source_id,source_key,video_id,display_name,source_deleted,deleted_at) "
"VALUES(?,?,?,?,0,?) ON CONFLICT(source_id,source_key) DO UPDATE SET "
"video_id=excluded.video_id,display_name=excluded.display_name,"
"source_deleted=0,deleted_at=excluded.deleted_at",
(
item["source_id"],
item["source_key"],
video_id,
item["display_name"],
utcnow(),
),
)
if not changed:
raise RuntimeError("视频正在删除")
trash_id: str | None = None
indexes_removed = False
cancelled_jobs: list[str] = []
try:
if delete_source:
trash_id = self.storage.trash(item["source_id"], item["source_key"])
# Do not cancel work until the source object is durably in the
# recoverable trash. A provider MOVE failure must leave the
# original indexing job untouched.
cancelled_jobs = self.jobs.cancel_for_video(
video_id,
exclude_job_ids=exclude_job_ids,
)
with self.db.read() as conn:
thumbnails = {
Path(path)
for record in conn.execute(
"SELECT thumbnail_path FROM frames WHERE video_id=? AND thumbnail_path IS NOT NULL "
"UNION SELECT thumbnail_path FROM faces WHERE video_id=? AND thumbnail_path IS NOT NULL",
(video_id, video_id),
).fetchall()
if (path := record["thumbnail_path"])
}
person_ids = [
record["person_id"]
for record in conn.execute(
"SELECT DISTINCT person_id FROM faces WHERE video_id=?", (video_id,)
).fetchall()
]
people_updates: list[tuple[str, int, bytes | None, bool]] = []
for person_id in person_ids:
remaining_faces = conn.execute(
"SELECT vector_blob FROM faces WHERE person_id=? AND video_id<>?", (person_id, video_id)
).fetchall()
person = conn.execute(
"SELECT is_named FROM people WHERE id=?", (person_id,)
).fetchone()
blobs = [record["vector_blob"] for record in remaining_faces if record["vector_blob"]]
people_updates.append(
(
person_id,
len(remaining_faces),
self._centroid(blobs),
bool(person and person["is_named"]),
)
)
self.vectors.delete_video(video_id)
indexes_removed = True
with self.db.transaction() as conn:
conn.execute(
"UPDATE video_tombstones SET source_deleted=?,deleted_at=? "
"WHERE source_id=? AND source_key=?",
(
int(delete_source),
utcnow(),
item["source_id"],
item["source_key"],
),
)
conn.execute(
"DELETE FROM text_fts WHERE entry_id IN "
"(SELECT id FROM text_entries WHERE video_id=?)",
(video_id,),
)
conn.execute("DELETE FROM videos WHERE id=?", (video_id,))
for person_id, count, centroid, is_named in people_updates:
if not count:
if not is_named:
conn.execute("DELETE FROM people WHERE id=?", (person_id,))
else:
conn.execute(
"UPDATE people SET centroid_blob=NULL,face_count=0,updated_at=? WHERE id=?",
(utcnow(), person_id),
)
else:
conn.execute(
"UPDATE people SET centroid_blob=?,face_count=?,updated_at=? WHERE id=?",
(centroid, count, utcnow(), person_id),
)
except Exception:
self._restore_after_failure(
video_id,
previous_status,
trash_id,
indexes_removed,
cancelled_jobs,
)
raise
for thumbnail in thumbnails:
with contextlib.suppress(OSError):
thumbnail.unlink(missing_ok=True)
with contextlib.suppress(Exception):
self.preview.remove_video(video_id)
if self.remote_cache is not None:
with contextlib.suppress(Exception):
self.remote_cache.invalidate_video(video_id)
expires_at = None
if trash_id:
with self.db.read() as conn:
trash = conn.execute("SELECT expires_at FROM trash WHERE id=?", (trash_id,)).fetchone()
if trash:
expires_at = trash["expires_at"]
logger.info(
"video %s synchronously removed source_deleted=%s trash_item=%s",
video_id,
delete_source,
trash_id,
)
return {
"video_id": video_id,
"display_name": item["display_name"],
"source_deleted": delete_source,
"trash_id": trash_id,
"trash_expires_at": expires_at,
"cancelled_jobs": len(cancelled_jobs),
"cancelled_job_ids": cancelled_jobs,
}
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import asyncio
import math
import threading
import time
from collections import deque
from pathlib import Path
from typing import Any
from starlette.types import ASGIApp, Message, Receive, Scope, Send
class Diagnostics:
def __init__(self) -> None:
self._guard = threading.Lock()
self._requests: deque[dict[str, Any]] = deque(maxlen=200)
self._loop_lag_ms = 0.0
self._loop_max_lag_ms = 0.0
def record_request(self, method: str, path: str, status: int, duration_ms: float) -> None:
with self._guard:
self._requests.append(
{
"method": method,
"path": path,
"status": status,
"duration_ms": round(duration_ms, 2),
"at": time.time(),
}
)
async def monitor(self, stopping: asyncio.Event) -> None:
interval = 1.0
expected = time.monotonic() + interval
while not stopping.is_set():
try:
await asyncio.wait_for(stopping.wait(), timeout=interval)
break
except TimeoutError:
pass
now = time.monotonic()
lag = max(0.0, (now - expected) * 1000)
expected = now + interval
with self._guard:
self._loop_lag_ms = lag
self._loop_max_lag_ms = max(self._loop_max_lag_ms, lag)
@staticmethod
def _rss_bytes() -> int:
try:
for line in Path("/proc/self/status").read_text(encoding="utf-8").splitlines():
if line.startswith("VmRSS:"):
return int(line.split()[1]) * 1024
except (OSError, ValueError, IndexError):
pass
return 0
def status(self) -> dict[str, Any]:
with self._guard:
requests = list(self._requests)
lag = self._loop_lag_ms
max_lag = self._loop_max_lag_ms
durations = sorted(float(item["duration_ms"]) for item in requests)
p95_index = max(0, math.ceil(len(durations) * 0.95) - 1)
return {
"process_rss_bytes": self._rss_bytes(),
"event_loop_lag_ms": round(lag, 2),
"event_loop_max_lag_ms": round(max_lag, 2),
"request_count_window": len(requests),
"request_p95_ms": durations[p95_index] if durations else 0,
"slow_requests": [item for item in requests if item["duration_ms"] >= 1000][-30:],
"recent_errors": [item for item in requests if item["status"] >= 500][-20:],
}
class ObservabilityMiddleware:
SECURITY_HEADERS = (
(b"x-content-type-options", b"nosniff"),
(b"referrer-policy", b"same-origin"),
(b"permissions-policy", b"camera=(), microphone=(), geolocation=()"),
(
b"content-security-policy",
b"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
b"img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; "
b"font-src 'self'; frame-ancestors 'self'",
),
)
def __init__(self, app: ASGIApp, diagnostics: Diagnostics):
self.app = app
self.diagnostics = diagnostics
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
started = time.monotonic()
status = 500
async def observed(message: Message) -> None:
nonlocal status
if message["type"] == "http.response.start":
status = int(message["status"])
headers = list(message.get("headers", []))
existing = {name.lower() for name, _ in headers}
headers.extend(header for header in self.SECURITY_HEADERS if header[0] not in existing)
message = {**message, "headers": headers}
await send(message)
try:
await self.app(scope, receive, observed)
finally:
duration_ms = (time.monotonic() - started) * 1000
self.diagnostics.record_request(
str(scope.get("method") or ""),
str(scope.get("path") or "")[:500],
status,
duration_ms,
)
+567
View File
@@ -0,0 +1,567 @@
from __future__ import annotations
import base64
import json
import logging
import os
import posixpath
import secrets
import shutil
import signal
import socket
import subprocess
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import unquote, urlparse
import httpx
from .database import Database, utcnow
from .jobs import JobCancelled, JobQueue
from .remote import safe_relative_path
from .sources import VIDEO_EXTENSIONS
from .storage import StorageService
logger = logging.getLogger(__name__)
class Aria2Unavailable(RuntimeError):
pass
class DownloadService:
"""Private loopback aria2 controller and persistent transfer queue."""
def __init__(self, db: Database, settings, jobs: JobQueue, storage: StorageService):
self.db = db
self.settings = settings
self.jobs = jobs
self.storage = storage
self._lock = threading.RLock()
self._process: subprocess.Popen | None = None
self._rpc_url = ""
self._secret = ""
self._effective_limit = 0
self._session = settings.download_staging_dir / "aria2.session"
self._orphaned_cleaned = self.cleanup_orphaned()
def cleanup_orphaned(self) -> int:
cleaned = 0
root = str(self.settings.download_staging_dir.resolve())
try:
candidates = [path for path in Path("/proc").iterdir() if path.name.isdigit()]
except OSError:
return 0
for path in candidates:
try:
status = (path / "status").read_text(encoding="utf-8")
ppid = int(next(line.split()[1] for line in status.splitlines() if line.startswith("PPid:")))
arguments = (path / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
except (OSError, ValueError, StopIteration):
continue
if ppid != 1 or root not in arguments or "aria2c" not in arguments:
continue
pid = int(path.name)
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
continue
cleaned += 1
return cleaned
def executable(self) -> str | None:
configured = str(self.settings.aria2_path or "aria2c")
if os.path.isabs(configured):
return configured if os.access(configured, os.X_OK) else None
return shutil.which(configured)
def runtime(self) -> dict:
executable = self.executable()
running = bool(self._process and self._process.poll() is None)
return {
"available": executable is not None,
"running": running,
"executable": executable,
"max_concurrent_downloads": 1,
"max_connections_per_server": self.settings.aria2_max_connections,
"global_download_limit_bytes": self.settings.aria2_download_limit_mb * 1024**2,
"staging_quota_bytes": int(self.settings.upload_staging_gb * 1024**3),
"reserve_bytes": int(self.settings.upload_reserve_gb * 1024**3),
"orphaned_cleaned": self._orphaned_cleaned,
}
@staticmethod
def _free_port() -> int:
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
listener.bind(("127.0.0.1", 0))
return int(listener.getsockname()[1])
finally:
listener.close()
@staticmethod
def _demote() -> None:
try:
os.nice(15)
except OSError:
pass
def _start(self) -> None:
with self._lock:
if self._process and self._process.poll() is None:
return
executable = self.executable()
if not executable:
raise Aria2Unavailable("未找到系统 aria2c,请先在飞牛中安装 aria2")
port = self._free_port()
self._secret = secrets.token_urlsafe(32)
self._rpc_url = f"http://127.0.0.1:{port}/jsonrpc"
self._session.parent.mkdir(parents=True, exist_ok=True)
self._session.touch(exist_ok=True)
command = [
executable,
"--enable-rpc=true",
"--rpc-listen-all=false",
f"--rpc-listen-port={port}",
f"--rpc-secret={self._secret}",
"--max-concurrent-downloads=1",
f"--max-connection-per-server={self.settings.aria2_max_connections}",
f"--max-overall-download-limit={self.settings.aria2_download_limit_mb}M",
"--continue=true",
"--auto-file-renaming=true",
"--allow-overwrite=false",
"--follow-torrent=mem",
"--seed-time=0",
"--file-allocation=none",
f"--input-file={self._session}",
f"--save-session={self._session}",
"--save-session-interval=30",
f"--dir={self.settings.download_staging_dir}",
]
self._process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
preexec_fn=self._demote,
)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if self._process.poll() is not None:
raise Aria2Unavailable("系统 aria2c 启动失败")
try:
self._rpc("aria2.getVersion", [])
return
except (httpx.HTTPError, RuntimeError):
time.sleep(0.1)
self._process.terminate()
raise Aria2Unavailable("系统 aria2c 启动超时")
def _rpc(self, method: str, parameters: list | None = None):
payload = {
"jsonrpc": "2.0",
"id": uuid.uuid4().hex,
"method": method,
"params": [f"token:{self._secret}", *(parameters or [])],
}
response = httpx.post(self._rpc_url, json=payload, timeout=10)
response.raise_for_status()
result = response.json()
if result.get("error"):
message = str(result["error"].get("message") or "aria2 请求失败")
raise RuntimeError(message[:500])
return result.get("result")
def _discard_gid(self, gid: str, *, active: bool) -> None:
"""Best-effort aria2 cleanup that never blocks a local state change."""
methods = ["aria2.forceRemove", "aria2.removeDownloadResult"] if active else ["aria2.removeDownloadResult"]
succeeded = False
last_error: Exception | None = None
try:
self._start()
except Exception as exc: # aria2 cleanup must not turn delete/retry into HTTP 500
last_error = exc
else:
for method in methods:
try:
self._rpc(method, [gid])
succeeded = True
except Exception as exc: # stale GIDs and a disappearing daemon are expected here
last_error = exc
if not succeeded and last_error is not None:
logger.warning(
"aria2 result cleanup was skipped for gid %s (%s)",
gid,
type(last_error).__name__,
)
@staticmethod
def _kind(uri: str) -> str:
if uri.startswith("magnet:?"):
return "magnet"
scheme = urlparse(uri).scheme.lower()
if scheme not in {"http", "https", "ftp"}:
raise ValueError("只支持 HTTP、HTTPS、FTP、magnet 和 .torrent")
return "url"
def _check_capacity(self) -> None:
usage = shutil.disk_usage(self.settings.download_staging_dir)
reserve = int(self.settings.upload_reserve_gb * 1024**3)
if usage.free < reserve:
raise OSError("下载暂存盘剩余空间不足")
total = sum(
path.stat().st_size
for path in self.settings.download_staging_dir.rglob("*")
if path.is_file() and not path.is_symlink()
)
if total >= int(self.settings.upload_staging_gb * 1024**3):
raise OSError("后台下载暂存空间已达到 100 GiB 上限")
def create(self, uri: str, source_id: str, relative_path: str = "") -> dict:
uri = uri.strip()
if not uri or len(uri) > 8192:
raise ValueError("下载地址无效")
kind = self._kind(uri)
self.storage.require_writable(source_id)
relative = safe_relative_path(relative_path)
self._check_capacity()
task_id = str(uuid.uuid4())
task_dir = self.settings.download_staging_dir / task_id
task_dir.mkdir(mode=0o700)
name = "磁力下载" if kind == "magnet" else Path(unquote(urlparse(uri).path)).name or "后台下载"
now = utcnow()
with self.db.transaction() as conn:
conn.execute(
"INSERT INTO downloads(id,kind,source_uri,source_id,relative_path,staging_path,display_name,"
"created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
(task_id, kind, uri, source_id, relative, str(task_dir), name[:500], now, now),
)
job_id = self.jobs.enqueue(
"aria2_download",
{"download_id": task_id},
dedupe_key=f"download:{task_id}",
connection=conn,
)
conn.execute("UPDATE downloads SET job_id=? WHERE id=?", (job_id, task_id))
return self.get(task_id)
def create_torrent(self, content: bytes, filename: str, source_id: str, relative_path: str = "") -> dict:
if not content or len(content) > 100 * 1024**2:
raise ValueError("Torrent 文件必须小于 100 MiB")
self.storage.require_writable(source_id)
relative = safe_relative_path(relative_path)
self._check_capacity()
task_id = str(uuid.uuid4())
task_dir = self.settings.download_staging_dir / task_id
task_dir.mkdir(mode=0o700)
torrent_path = task_dir / "source.torrent"
torrent_path.write_bytes(content)
os.chmod(torrent_path, 0o600)
now = utcnow()
with self.db.transaction() as conn:
conn.execute(
"INSERT INTO downloads(id,kind,source_uri,source_id,relative_path,staging_path,display_name,"
"created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
(task_id, "torrent", str(torrent_path), source_id, relative, str(task_dir), filename[:500], now, now),
)
job_id = self.jobs.enqueue(
"aria2_download",
{"download_id": task_id},
dedupe_key=f"download:{task_id}",
connection=conn,
)
conn.execute("UPDATE downloads SET job_id=? WHERE id=?", (job_id, task_id))
return self.get(task_id)
def get(self, download_id: str) -> dict:
with self.db.read() as conn:
row = conn.execute("SELECT * FROM downloads WHERE id=?", (download_id,)).fetchone()
if not row:
raise KeyError(download_id)
value = dict(row)
value["files"] = json.loads(value.pop("files_json") or "[]")
# A local torrent staging path and full remote URL are operational
# details; the UI only needs the safe kind and display name.
value.pop("source_uri", None)
value.pop("staging_path", None)
return value
def list(self) -> list[dict]:
with self.db.read() as conn:
ids = [row["id"] for row in conn.execute("SELECT id FROM downloads ORDER BY created_at DESC")]
return [self.get(task_id) for task_id in ids]
def _row(self, download_id: str) -> dict:
with self.db.read() as conn:
row = conn.execute("SELECT * FROM downloads WHERE id=?", (download_id,)).fetchone()
if not row:
raise KeyError(download_id)
return dict(row)
def _set_status(self, download_id: str, status: str, **values) -> None:
allowed = {
"gid", "progress", "total_bytes", "completed_bytes", "download_speed", "files_json",
"error", "finished_at", "job_id",
}
fields = {key: value for key, value in values.items() if key in allowed}
fields.update({"status": status, "updated_at": utcnow()})
assignments = ",".join(f"{key}=?" for key in fields)
with self.db.transaction() as conn:
conn.execute(
f"UPDATE downloads SET {assignments} WHERE id=?",
(*fields.values(), download_id),
)
@staticmethod
def _safe_files(task_dir: Path, aria_files: list[dict]) -> list[tuple[Path, str]]:
root = task_dir.resolve(strict=True)
result: list[tuple[Path, str]] = []
for item in aria_files:
if str(item.get("selected", "true")).lower() == "false":
continue
path = Path(str(item.get("path") or ""))
try:
resolved = path.resolve(strict=True)
relative = resolved.relative_to(root)
except (OSError, ValueError):
raise ValueError("aria2 返回了暂存目录之外的文件") from None
if path.is_symlink() or not resolved.is_file() or any(
part in {"", ".", ".."} for part in relative.parts
):
raise ValueError("下载内容包含不安全的文件路径")
if relative.name == "source.torrent" or relative.name.endswith(".aria2"):
continue
result.append((resolved, relative.as_posix()))
return result
def _transfer(self, job_id: str, row: dict, files: list[tuple[Path, str]]) -> list[str]:
transferred: list[str] = []
total = sum(path.stat().st_size for path, _ in files)
completed = 0
for index, (path, relative) in enumerate(files):
self.jobs.checkpoint(job_id, f"写入媒体库 {index + 1}/{len(files)}")
parent = posixpath.dirname(relative)
destination = posixpath.join(row["relative_path"], parent) if parent else row["relative_path"]
completed_before = completed
def progress(sent: int, size: int, *, offset: int = completed_before) -> None:
value = (offset + sent) / total if total else 1
self.jobs.update(job_id, 0.9 + value * 0.09, f"写入媒体库 {value:.0%}")
key = self.storage.write_file(
row["source_id"], destination, path.name, path, conflict="rename", progress=progress,
cancelled=lambda: self.jobs._cancel_requested(job_id),
)
completed += path.stat().st_size
if key:
transferred.append(key)
if Path(key).suffix.lower() in VIDEO_EXTENSIONS:
self.jobs.enqueue(
"refresh_path", {"source_id": row["source_id"], "key": key},
dedupe_key=f"refresh:{row['source_id']}:{key}",
)
return transferred
def run(self, job_id: str, download_id: str) -> None:
row = self._row(download_id)
task_dir = Path(row["staging_path"])
self._start()
gid = row.get("gid")
if not gid:
options = {"dir": str(task_dir), "seed-time": "0"}
if row["kind"] == "torrent":
content = Path(row["source_uri"]).read_bytes()
gid = self._rpc("aria2.addTorrent", [base64.b64encode(content).decode(), [], options])
else:
gid = self._rpc("aria2.addUri", [[row["source_uri"]], options])
self._set_status(download_id, "downloading", gid=gid, error=None)
try:
while True:
if self.jobs._cancel_requested(job_id):
raise JobCancelled("下载已取消")
reason = (
self.jobs.governor.pressure_reason(lane="download", running=True)
if self.jobs.governor is not None
else None
)
if self.jobs.governor is not None:
effective_limit = min(
self.settings.aria2_download_limit_mb * 1024**2,
self.jobs.governor.effective_io_limit_bytes(),
)
if effective_limit != self._effective_limit:
self._rpc(
"aria2.changeGlobalOption",
[{"max-overall-download-limit": str(effective_limit)}],
)
self._effective_limit = effective_limit
if reason:
try:
self._rpc("aria2.forcePause", [gid])
except RuntimeError:
pass
self.jobs.checkpoint(job_id, reason)
try:
self._rpc("aria2.unpause", [gid])
except RuntimeError:
pass
status = self._rpc(
"aria2.tellStatus",
[
gid,
[
"gid", "status", "totalLength", "completedLength", "downloadSpeed",
"files", "followedBy", "errorMessage",
],
],
)
followed = status.get("followedBy") or []
if status["status"] == "complete" and followed:
gid = followed[0]
self._set_status(download_id, "downloading", gid=gid)
continue
total = int(status.get("totalLength") or 0)
completed = int(status.get("completedLength") or 0)
progress = completed / total if total else 0
files_json = json.dumps(status.get("files") or [], ensure_ascii=False)
state = "paused" if status["status"] == "paused" else "downloading"
self._set_status(
download_id, state, progress=progress, total_bytes=total,
completed_bytes=completed, download_speed=int(status.get("downloadSpeed") or 0),
files_json=files_json,
)
self.jobs.update(job_id, progress * 0.9, f"后台下载 {progress:.0%}")
if status["status"] == "complete":
aria_files = status.get("files") or []
break
if status["status"] in {"error", "removed"}:
raise RuntimeError(str(status.get("errorMessage") or "aria2 下载失败")[:500])
time.sleep(1)
files = self._safe_files(task_dir, aria_files)
if not files:
raise RuntimeError("下载完成,但没有可传输的文件")
self._set_status(download_id, "transferring", download_speed=0)
transferred = self._transfer(job_id, row, files)
self._set_status(
download_id, "completed", progress=1, download_speed=0,
files_json=json.dumps(transferred, ensure_ascii=False), finished_at=utcnow(), error=None,
)
shutil.rmtree(task_dir, ignore_errors=True)
except JobCancelled:
self._discard_gid(gid, active=True)
self._set_status(download_id, "cancelled", download_speed=0, finished_at=utcnow())
raise
except Exception as exc:
self._set_status(download_id, "failed", download_speed=0, error=str(exc)[:1000], finished_at=utcnow())
raise
async def handle(self, job_id: str, payload: dict) -> None:
import asyncio
await asyncio.to_thread(self.run, job_id, payload["download_id"])
def pause(self, download_id: str) -> dict:
row = self._row(download_id)
if row["status"] not in {"queued", "downloading"}:
raise ValueError("该下载任务当前不能暂停")
if row["status"] == "queued" and row.get("job_id"):
try:
self.jobs.request_cancel(row["job_id"])
except (KeyError, ValueError):
pass
elif row.get("gid"):
self._start()
self._rpc("aria2.forcePause", [row["gid"]])
self._set_status(download_id, "paused", download_speed=0)
return self.get(download_id)
def resume(self, download_id: str) -> dict:
row = self._row(download_id)
if row["status"] != "paused":
raise ValueError("该下载任务没有暂停")
job_id = row.get("job_id")
if row.get("gid"):
self._start()
self._rpc("aria2.unpause", [row["gid"]])
else:
job_id = self.jobs.enqueue(
"aria2_download", {"download_id": download_id}, dedupe_key=f"download:{download_id}"
)
self._set_status(download_id, "downloading" if row.get("gid") else "queued", job_id=job_id)
return self.get(download_id)
def retry(self, download_id: str) -> dict:
dedupe_key = f"download:{download_id}"
with self.db.transaction() as conn:
row = conn.execute(
"SELECT * FROM downloads WHERE id=? FOR UPDATE",
(download_id,),
).fetchone()
if row is None:
raise KeyError(download_id)
row = dict(row)
if row["status"] != "failed":
raise ValueError("只有失败的下载任务可以重试")
# DownloadService records the failure just before the lane worker
# records its job failure. A fast retry can therefore observe the
# old job as running and collide with its active dedupe key even
# though the handler has already finished all download work.
old_job_id = row.get("job_id")
if old_job_id:
old_job = conn.execute(
"SELECT status,dedupe_key FROM jobs WHERE id=? FOR UPDATE",
(old_job_id,),
).fetchone()
if old_job and old_job["status"] in {"queued", "running"} and old_job["dedupe_key"] == dedupe_key:
conn.execute(
"UPDATE jobs SET dedupe_key=? WHERE id=?",
(f"{dedupe_key}:finished:{old_job_id}", old_job_id),
)
job_id = self.jobs.enqueue(
"aria2_download",
{"download_id": download_id},
dedupe_key=dedupe_key,
connection=conn,
)
conn.execute(
"UPDATE downloads SET gid=NULL,status='queued',error=NULL,finished_at=NULL,"
"download_speed=0,job_id=?,updated_at=? WHERE id=?",
(job_id, utcnow(), download_id),
)
if row.get("gid"):
self._discard_gid(row["gid"], active=False)
return self.get(download_id)
def delete(self, download_id: str) -> None:
row = self._row(download_id)
if row.get("job_id") and row["status"] in {"queued", "downloading", "paused", "transferring"}:
try:
self.jobs.request_cancel(row["job_id"])
except (KeyError, ValueError):
pass
if row.get("gid"):
self._discard_gid(
row["gid"],
active=row["status"] in {"queued", "downloading", "paused"},
)
shutil.rmtree(Path(row["staging_path"]), ignore_errors=True)
with self.db.transaction() as conn:
conn.execute("DELETE FROM downloads WHERE id=?", (download_id,))
def close(self) -> None:
with self._lock:
process = self._process
self._process = None
if process and process.poll() is None:
try:
self._rpc("aria2.shutdown", [])
process.wait(timeout=5)
except (RuntimeError, httpx.HTTPError, subprocess.TimeoutExpired):
process.terminate()
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import asyncio
import itertools
import time
from collections.abc import AsyncIterator
class EventBroker:
"""Small in-process fan-out for UI invalidation events."""
def __init__(self) -> None:
self._loop: asyncio.AbstractEventLoop | None = None
self._subscribers: dict[int, asyncio.Queue[dict]] = {}
self._ids = itertools.count(1)
def bind(self) -> None:
self._loop = asyncio.get_running_loop()
def _publish(self, event: dict) -> None:
for queue in list(self._subscribers.values()):
if queue.full():
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
queue.put_nowait(event)
def publish(self, topic: str, **payload) -> None:
loop = self._loop
if loop is None or loop.is_closed():
return
event = {"topic": topic, "at": time.time(), **payload}
loop.call_soon_threadsafe(self._publish, event)
async def subscribe(self) -> AsyncIterator[dict | None]:
subscriber_id = next(self._ids)
queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=32)
self._subscribers[subscriber_id] = queue
try:
yield {"topic": "connected", "at": time.time()}
while True:
try:
yield await asyncio.wait_for(queue.get(), timeout=20)
except TimeoutError:
yield None
finally:
self._subscribers.pop(subscriber_id, None)
def status(self) -> dict[str, int | bool]:
return {"ready": self._loop is not None, "subscribers": len(self._subscribers)}
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, MutableMapping
from pathlib import Path
from typing import Any
from starlette.types import Receive, Scope, Send
GATEWAY_SCOPE_KEY = "imagefind_gateway"
def _normalise_prefix(value: str) -> str:
prefix = "/" + value.strip("/")
return "" if prefix == "/" else prefix
def _same_socket(server: object, socket_path: Path | None) -> bool:
if socket_path is None or not isinstance(server, (tuple, list)) or len(server) != 2:
return False
host, port = server
if port is not None or not isinstance(host, str):
return False
try:
return Path(host).resolve() == socket_path.resolve()
except OSError:
return Path(host) == socket_path
def is_gateway_scope(scope: Scope) -> bool:
state = scope.get("state")
return bool(isinstance(state, MutableMapping) and state.get(GATEWAY_SCOPE_KEY))
class GatewayMountMiddleware:
"""Mount ImageFind below the fnOS gateway prefix only on its trusted UDS.
X-Trim-* headers are security-sensitive. Merely seeing the headers is not
sufficient: the request must have arrived on the configured Unix socket.
"""
def __init__(
self,
app: Callable[[Scope, Receive, Send], Awaitable[None]],
*,
socket_path: Path | None,
prefix: str,
):
self.app = app
self.socket_path = socket_path
self.prefix = _normalise_prefix(prefix)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] not in {"http", "websocket"} or not _same_socket(scope.get("server"), self.socket_path):
await self.app(scope, receive, send)
return
path = scope.get("path", "")
if self.prefix and path == self.prefix and scope["type"] == "http":
await send(
{
"type": "http.response.start",
"status": 307,
"headers": [(b"location", f"{self.prefix}/".encode("ascii")), (b"cache-control", b"no-store")],
}
)
await send({"type": "http.response.body", "body": b""})
return
if self.prefix and not path.startswith(f"{self.prefix}/"):
if scope["type"] == "http":
await send(
{
"type": "http.response.start",
"status": 404,
"headers": [(b"content-type", b"text/plain; charset=utf-8")],
}
)
await send({"type": "http.response.body", "body": b"Not Found"})
else:
await send({"type": "websocket.close", "code": 1008})
return
mounted: dict[str, Any] = dict(scope)
state = dict(scope.get("state") or {})
state[GATEWAY_SCOPE_KEY] = True
mounted["state"] = state
mounted["root_path"] = self.prefix
mounted["path"] = path[len(self.prefix) :] or "/"
raw_path = scope.get("raw_path")
if isinstance(raw_path, bytes) and self.prefix:
prefix_bytes = self.prefix.encode("utf-8")
if raw_path.startswith(prefix_bytes):
mounted["raw_path"] = raw_path[len(prefix_bytes) :] or b"/"
await self.app(mounted, receive, send)
+448
View File
@@ -0,0 +1,448 @@
from __future__ import annotations
import asyncio
import gc
import json
import logging
import shutil
import uuid
from pathlib import Path
from PIL import Image
from .ai import EmbeddingService, FaceService, OcrService, difference_hash
from .database import Database, utcnow
from .jobs import JobCancelled, JobQueue
from .media import MediaService
from .text import search_tokens
from .vectors import normalize, pack_vector, unpack_vector
logger = logging.getLogger(__name__)
class Indexer:
def __init__(
self,
db: Database,
jobs: JobQueue,
media: MediaService,
embeddings: EmbeddingService,
ocr: OcrService,
faces: FaceService,
vectors,
thumbnails_dir: Path,
face_threshold: float,
models=None,
):
self.db = db
self.jobs = jobs
self.media = media
self.embeddings = embeddings
self.ocr = ocr
self.faces = faces
self.vectors = vectors
self.thumbnails_dir = thumbnails_dir
self.face_threshold = face_threshold
self.models = models
async def handle(self, job_id: str, payload: dict) -> None:
await asyncio.to_thread(self.index, job_id, payload["video_id"])
def _video(self, video_id: str) -> dict:
with self.db.read() as conn:
row = conn.execute(
"SELECT v.*,s.kind AS source_kind,s.name AS source_name FROM videos v "
"JOIN sources s ON s.id=v.source_id WHERE v.id=?",
(video_id,),
).fetchone()
if not row:
raise KeyError(f"视频不存在:{video_id}")
return dict(row)
@staticmethod
def _text_entry(
video_id: str, kind: str, text: str, start: int = 0, end: int = 0, frame_id: str | None = None
) -> dict:
return {
"id": str(uuid.uuid4()),
"video_id": video_id,
"frame_id": frame_id,
"kind": kind,
"start_ms": start,
"end_ms": end,
"raw_text": text,
"tokens": " ".join(search_tokens(text)),
}
@staticmethod
def _replaceable_text_condition(audio_stale: bool) -> str:
"""Build a fixed predicate accepted by both PostgreSQL and SQLite."""
# PostgreSQL rejects the former ``(? OR kind<>'audio')`` expression
# when SQLite's integer 0/1 is bound to the placeholder. A fixed
# predicate is portable and gives the planner a simpler condition.
return "" if audio_stale else " AND kind<>'audio'"
def _assign_person(self, vector: list[float]) -> str:
hits = self.vectors.search("faces", vector, 3)
for hit in hits:
if hit.score < self.face_threshold:
continue
with self.db.read() as conn:
row = conn.execute("SELECT person_id FROM faces WHERE id=?", (hit.id,)).fetchone()
if row:
return row["person_id"]
person_id = str(uuid.uuid4())
now = utcnow()
with self.db.critical_transaction() as conn:
conn.execute(
"INSERT INTO people(id,name,normalized_name,centroid_blob,face_count,created_at,updated_at) "
"VALUES(?,?,?,?,0,?,?)",
(person_id, f"未命名人物 {person_id[:6]}", "", pack_vector(vector), now, now),
)
return person_id
def _update_person_centroid(self, person_id: str, vector: list[float]) -> None:
with self.db.critical_transaction() as conn:
row = conn.execute("SELECT centroid_blob,face_count FROM people WHERE id=?", (person_id,)).fetchone()
count = row["face_count"] if row else 0
old = unpack_vector(row["centroid_blob"]) if row else []
centroid = (
vector
if not old
else normalize([(old[i] * count + vector[i]) / (count + 1) for i in range(len(vector))])
)
conn.execute(
"UPDATE people SET centroid_blob=?,face_count=face_count+1,updated_at=? WHERE id=?",
(pack_vector(centroid), utcnow(), person_id),
)
def index(self, job_id: str, video_id: str) -> None:
video = self._video(video_id)
if not video["available"]:
return
with self.db.critical_transaction() as conn:
conn.execute("UPDATE videos SET status='indexing',error=NULL,updated_at=? WHERE id=?", (utcnow(), video_id))
temporary: Path | None = None
media_lease = None
partial_messages: list[str] = []
try:
def cache_progress(received: int, total: int) -> None:
value = received / total if total else 0
self.jobs.update(job_id, 0.01 + value * 0.02, f"缓存远程媒体 {value:.0%}")
media_lease = self.media.acquire_input(
video,
progress=cache_progress,
cancelled=lambda: self.jobs._cancel_requested(job_id),
)
media_input = media_lease.input
self.jobs.update(job_id, 0.03, "解析视频资料")
probe = self.media.probe(media_input)
subtitle_cues = self.media.extract_embedded_subtitles(media_input, probe)
subtitle_cues.extend(self.media.sidecar_subtitles(video))
self.jobs.update(job_id, 0.08, "提取关键帧")
temporary, extracted = self.media.extract_frames(media_input, probe["duration_ms"])
if not extracted:
raise RuntimeError("未能从视频中提取关键帧")
staged_frames: list[dict] = []
text_entries = [
self._text_entry(video_id, "filename", video["display_name"]),
self._text_entry(video_id, "path", video["source_key"]),
]
text_entries.extend(
self._text_entry(video_id, "subtitle", cue.text, cue.start_ms, cue.end_ms) for cue in subtitle_cues
)
destination = self.thumbnails_dir / video_id
destination.mkdir(parents=True, exist_ok=True)
versions = self.models.runnable_component_versions() if self.models is not None else {
"visual": "hash-v1" if self.embeddings.status()["visual_ready"] else None,
"ocr": "installed" if self.ocr.ready() else None,
"faces": "installed" if self.faces.ready() else None,
"audio": None,
}
audio_stale = video.get("basic_fingerprint") != video["fingerprint"]
# Run AI components in phases. Keeping CLIP, OCR and face models
# resident together can exhaust a small NAS even when CPU usage is
# moderate, so each phase is unloaded before the next begins.
visual_ready = bool(versions["visual"])
ocr_ready = bool(versions["ocr"])
faces_ready = bool(versions["faces"])
for index, frame in enumerate(extracted):
self.jobs.checkpoint(job_id, f"生成封面 {index + 1}/{len(extracted)}")
frame_id = str(uuid.uuid4())
target = destination / f"{frame_id}.webp"
with Image.open(frame.path) as opened:
rgb = opened.convert("RGB")
rgb.thumbnail((480, 270))
rgb.save(target, "WEBP", quality=72, method=4)
frame_hash = difference_hash(rgb)
staged_frames.append(
{
"id": frame_id,
"timestamp_ms": frame.timestamp_ms,
"segment_start_ms": frame.segment_start_ms,
"segment_end_ms": frame.segment_end_ms,
"thumbnail_path": str(target),
"perceptual_hash": frame_hash,
"source_path": frame.path,
"vector": None,
"faces": [],
}
)
self.jobs.update(
job_id,
0.1 + 0.1 * (index + 1) / len(extracted),
f"生成封面 {index + 1}/{len(extracted)}",
)
if visual_ready:
try:
profile = self.embeddings.accelerator.gpu_profile()
batch_size = (
int(profile["batch_size"])
if self.embeddings.accelerator.device_for("visual") != "CPU"
else 1
)
for offset in range(0, len(staged_frames), max(1, batch_size)):
batch = staged_frames[offset:offset + max(1, batch_size)]
self.jobs.checkpoint(job_id, f"画面语义 {offset + 1}/{len(staged_frames)}")
vectors = self.embeddings.encode_images([frame["source_path"] for frame in batch])
for frame, vector in zip(batch, vectors, strict=True):
frame["vector"] = vector
completed = min(len(staged_frames), offset + len(batch))
self.jobs.update(
job_id,
0.2 + 0.25 * completed / len(staged_frames),
f"画面语义 {completed}/{len(staged_frames)}",
)
except Exception as exc:
logger.warning("visual indexing failed for video %s", video_id, exc_info=True)
visual_ready = False
partial_messages.append(f"画面分析失败:{str(exc)[:160]}")
finally:
self.embeddings.reset()
gc.collect()
if ocr_ready:
try:
profile = self.ocr.accelerator.gpu_profile()
batch_size = int(profile["batch_size"]) if self.ocr.accelerator.device_for("ocr") != "CPU" else 1
for offset in range(0, len(staged_frames), max(1, batch_size)):
batch = staged_frames[offset:offset + max(1, batch_size)]
self.jobs.checkpoint(job_id, f"OCR 文字 {offset + 1}/{len(staged_frames)}")
results = self.ocr.recognize_batch([frame["source_path"] for frame in batch])
for frame, ocr_lines in zip(batch, results, strict=True):
for text, confidence in ocr_lines:
if confidence >= 0.45 and text.strip():
text_entries.append(
self._text_entry(
video_id,
"ocr",
text.strip(),
frame["segment_start_ms"],
frame["segment_end_ms"],
frame["id"],
)
)
completed = min(len(staged_frames), offset + len(batch))
self.jobs.update(
job_id,
0.45 + 0.2 * completed / len(staged_frames),
f"OCR 文字 {completed}/{len(staged_frames)}",
)
except Exception as exc:
logger.warning("OCR indexing failed for video %s", video_id, exc_info=True)
ocr_ready = False
partial_messages.append(f"OCR 分析失败:{str(exc)[:160]}")
finally:
self.ocr.reset()
gc.collect()
if faces_ready:
try:
profile = self.faces.accelerator.gpu_profile()
batch_size = (
int(profile["batch_size"])
if self.faces.accelerator.device_for("faces") != "CPU"
else 1
)
for offset in range(0, len(staged_frames), max(1, batch_size)):
batch = staged_frames[offset:offset + max(1, batch_size)]
self.jobs.checkpoint(job_id, f"人物识别 {offset + 1}/{len(staged_frames)}")
results = self.faces.detect_and_embed_batch([frame["source_path"] for frame in batch])
for frame, faces in zip(batch, results, strict=True):
frame["faces"] = faces
completed = min(len(staged_frames), offset + len(batch))
self.jobs.update(
job_id,
0.65 + 0.15 * completed / len(staged_frames),
f"人物识别 {completed}/{len(staged_frames)}",
)
except Exception as exc:
logger.warning("face indexing failed for video %s", video_id, exc_info=True)
faces_ready = False
partial_messages.append(f"人物分析失败:{str(exc)[:160]}")
finally:
self.faces.reset()
gc.collect()
self.vectors.delete_video(video_id)
with self.db.critical_transaction() as conn:
replaceable_text = self._replaceable_text_condition(audio_stale)
old_entries = conn.execute(
f"SELECT id FROM text_entries WHERE video_id=?{replaceable_text}",
(video_id,),
).fetchall()
for row in old_entries:
conn.execute("DELETE FROM text_fts WHERE entry_id=?", (row["id"],))
conn.execute(
f"DELETE FROM text_entries WHERE video_id=?{replaceable_text}",
(video_id,),
)
conn.execute("DELETE FROM frames WHERE video_id=?", (video_id,))
conn.execute("UPDATE people SET face_count=(SELECT count(*) FROM faces WHERE person_id=people.id)")
conn.execute("DELETE FROM people WHERE face_count=0 AND is_named=0")
for frame in staged_frames:
conn.execute(
"INSERT INTO frames(id,video_id,timestamp_ms,segment_start_ms,segment_end_ms,"
"thumbnail_path,perceptual_hash,created_at) VALUES(?,?,?,?,?,?,?,?)",
(
frame["id"],
video_id,
frame["timestamp_ms"],
frame["segment_start_ms"],
frame["segment_end_ms"],
frame["thumbnail_path"],
frame["perceptual_hash"],
utcnow(),
),
)
for entry in text_entries:
conn.execute(
"INSERT INTO text_entries("
"id,video_id,frame_id,kind,start_ms,end_ms,raw_text,tokens,created_at"
") "
"VALUES(?,?,?,?,?,?,?,?,?)",
(
entry["id"],
video_id,
entry["frame_id"],
entry["kind"],
entry["start_ms"],
entry["end_ms"],
entry["raw_text"],
entry["tokens"],
utcnow(),
),
)
if entry["tokens"]:
conn.execute(
"INSERT INTO text_fts(entry_id,tokens) VALUES(?,?)", (entry["id"], entry["tokens"])
)
conn.execute(
"UPDATE videos SET duration_ms=?,width=?,height=?,codec=?,container=?,"
"status=?,indexed_fingerprint=fingerprint,basic_fingerprint=fingerprint,"
"visual_model_version=?,ocr_model_version=?,faces_model_version=?,audio_model_version=?,"
"error=?,updated_at=? WHERE id=?",
(
probe["duration_ms"],
probe["width"],
probe["height"],
probe["codec"],
probe["container"],
"partial" if partial_messages else "indexed",
versions["visual"] if visual_ready else None,
versions["ocr"] if ocr_ready else None,
versions["faces"] if faces_ready else None,
None if audio_stale else video.get("audio_model_version"),
"; ".join(partial_messages) or None,
utcnow(),
video_id,
),
)
if visual_ready:
self.vectors.ensure("frames", self.embeddings.dimensions)
for frame in staged_frames:
if frame["vector"]:
self.vectors.upsert(
"frames",
frame["id"],
frame["vector"],
{"video_id": video_id, "source_id": video["source_id"], "timestamp_ms": frame["timestamp_ms"]},
)
for detected in frame["faces"]:
vector = detected["vector"]
self.vectors.ensure("faces", len(vector))
person_id = self._assign_person(vector)
face_id = str(uuid.uuid4())
with self.db.critical_transaction() as conn:
conn.execute(
"INSERT INTO faces("
"id,frame_id,video_id,person_id,bbox_json,confidence,thumbnail_path,vector_blob,created_at"
") "
"VALUES(?,?,?,?,?,?,?,?,?)",
(
face_id,
frame["id"],
video_id,
person_id,
json.dumps(detected["bbox"]),
detected["confidence"],
frame["thumbnail_path"],
pack_vector(vector),
utcnow(),
),
)
self.vectors.upsert(
"faces",
face_id,
vector,
{"video_id": video_id, "source_id": video["source_id"], "person_id": person_id},
)
self._update_person_centroid(person_id, vector)
capabilities = [
name
for name, ready in (("画面", visual_ready), ("OCR", ocr_ready), ("人物", faces_ready))
if ready
]
suffix = f",已完成 {''.join(capabilities)}分析" if capabilities else "AI 模型未安装"
self.jobs.update(job_id, 0.98, f"已解析 {len(staged_frames)} 个关键帧{suffix}")
if versions.get("audio") and (
audio_stale or video.get("audio_model_version") != versions["audio"]
):
self.jobs.enqueue(
"transcribe_audio",
{"video_id": video_id},
dedupe_key=f"audio:{video_id}",
# Finish the current remote video while its decrypted
# materialization is warm, but still yield to model repair.
priority=0,
)
with self.db.read() as conn:
enabled_tag_methods = {
row["ai_method"]
for row in conn.execute("SELECT DISTINCT ai_method FROM tags WHERE ai_enabled=1").fetchall()
}
if "text" in enabled_tag_methods or ("visual" in enabled_tag_methods and visual_ready):
self.jobs.enqueue(
"suggest_tags",
{"video_ids": [video_id], "force": False},
dedupe_key=f"tag-suggestions:{video_id}",
)
except JobCancelled:
raise
except Exception as exc:
with self.db.critical_transaction() as conn:
conn.execute(
"UPDATE videos SET status='error',error=?,updated_at=? WHERE id=? AND available=1",
(str(exc)[:4000], utcnow(), video_id),
)
raise
finally:
if temporary:
shutil.rmtree(temporary, ignore_errors=True)
if media_lease is not None:
media_lease.close()
+269
View File
@@ -0,0 +1,269 @@
from __future__ import annotations
import gc
import multiprocessing
import threading
import time
import traceback
import uuid
from multiprocessing.connection import Connection
from pathlib import Path
from typing import Any
from .accelerator import AcceleratorService
from .ai import EmbeddingService, FaceService, ModelUnavailable, OcrService
from .config import Settings
def _worker(connection: Connection, settings: Settings, idle_seconds: float) -> None:
"""Own every heavy visual model and leave after an idle window."""
accelerator = AcceleratorService(settings)
embeddings = EmbeddingService(settings, accelerator)
ocr = OcrService(settings, accelerator)
faces = FaceService(settings, accelerator)
last_activity = time.monotonic()
try:
while True:
if not connection.poll(1.0):
if time.monotonic() - last_activity >= idle_seconds:
return
continue
request = connection.recv()
last_activity = time.monotonic()
request_id = request.get("id")
operation = request.get("operation")
try:
if operation == "close":
connection.send({"id": request_id, "ok": True, "result": None})
return
if operation == "encode_text":
result = embeddings.encode_text(str(request["value"]))
elif operation == "encode_image":
result = embeddings.encode_image(Path(request["path"]))
elif operation == "encode_images":
result = embeddings.encode_images([Path(path) for path in request["paths"]])
elif operation == "recognize":
result = ocr.recognize(Path(request["path"]))
elif operation == "recognize_batch":
result = ocr.recognize_batch([Path(path) for path in request["paths"]])
elif operation == "detect_faces":
result = faces.detect_and_embed(Path(request["path"]))
elif operation == "detect_faces_batch":
result = faces.detect_and_embed_batch([Path(path) for path in request["paths"]])
elif operation == "verify_visual":
result = embeddings.verify_acceleration()
elif operation == "reset":
component = str(request["component"])
{"visual": embeddings.reset, "ocr": ocr.reset, "faces": faces.reset}[component]()
gc.collect()
result = None
else:
raise ValueError(f"未知推理操作:{operation}")
connection.send(
{
"id": request_id,
"ok": True,
"result": result,
"accelerator": accelerator.status(),
}
)
except BaseException as exc:
connection.send(
{
"id": request_id,
"ok": False,
"error_type": type(exc).__name__,
"error": " ".join(str(exc).split())[:2000],
"traceback": traceback.format_exc(limit=8)[-8000:],
"accelerator": accelerator.status(),
}
)
except (EOFError, BrokenPipeError, OSError):
return
finally:
connection.close()
class InferenceSupervisor:
"""Serialize IPC to a lazily spawned, self-reaping inference process."""
def __init__(
self,
settings: Settings,
accelerator: AcceleratorService,
*,
idle_seconds: float = 120.0,
response_timeout: float = 900.0,
):
self.settings = settings
self.accelerator = accelerator
self.idle_seconds = max(5.0, idle_seconds)
self.response_timeout = max(30.0, response_timeout)
self._context = multiprocessing.get_context("spawn")
self._guard = threading.Lock()
self._process: multiprocessing.Process | None = None
self._connection: Connection | None = None
def _discard(self) -> None:
connection, process = self._connection, self._process
self._connection = None
self._process = None
if connection is not None:
try:
connection.close()
except OSError:
pass
if process is not None:
process.join(timeout=0.2)
def _ensure(self) -> Connection:
if self._process is not None and self._process.is_alive() and self._connection is not None:
return self._connection
self._discard()
parent, child = self._context.Pipe(duplex=True)
process = self._context.Process(
target=_worker,
args=(child, self.settings, self.idle_seconds),
name="imagefind-inference",
daemon=True,
)
process.start()
child.close()
self._process = process
self._connection = parent
return parent
def call(self, operation: str, **payload: Any):
with self._guard:
for attempt in range(2):
connection = self._ensure()
request_id = uuid.uuid4().hex
try:
connection.send({"id": request_id, "operation": operation, **payload})
if not connection.poll(self.response_timeout):
raise TimeoutError(f"AI 推理超时:{operation}")
response = connection.recv()
except (EOFError, BrokenPipeError, OSError):
self._discard()
if attempt == 0:
continue
raise RuntimeError("AI 推理进程意外退出") from None
if response.get("id") != request_id:
self._discard()
raise RuntimeError("AI 推理进程返回了无效响应")
status = response.get("accelerator")
if isinstance(status, dict):
self.accelerator.merge_worker_status(status)
if response.get("ok"):
return response.get("result")
detail = str(response.get("error") or "AI 推理失败")
if response.get("error_type") == "ModelUnavailable":
raise ModelUnavailable(detail)
raise RuntimeError(detail)
raise RuntimeError("AI 推理进程不可用")
def reset(self, component: str) -> None:
with self._guard:
if self._process is None or not self._process.is_alive() or self._connection is None:
self._discard()
self.accelerator.reset(component)
return
self.call("reset", component=component)
def close(self) -> None:
with self._guard:
connection, process = self._connection, self._process
if connection is not None and process is not None and process.is_alive():
request_id = uuid.uuid4().hex
try:
connection.send({"id": request_id, "operation": "close"})
if connection.poll(3):
connection.recv()
except (EOFError, BrokenPipeError, OSError):
pass
self._discard()
if process is not None and process.is_alive():
process.terminate()
process.join(timeout=3)
def status(self) -> dict[str, Any]:
with self._guard:
process = self._process
return {
"running": bool(process and process.is_alive()),
"pid": process.pid if process and process.is_alive() else None,
"idle_timeout_seconds": self.idle_seconds,
}
class IsolatedEmbeddingService(EmbeddingService):
def __init__(self, settings: Settings, accelerator: AcceleratorService, supervisor: InferenceSupervisor):
super().__init__(settings, accelerator)
self.supervisor = supervisor
def encode_text(self, text: str) -> list[float]:
if self.settings.embedding_backend == "hash":
return super().encode_text(text)
return self.supervisor.call("encode_text", value=text)
def encode_image(self, image) -> list[float]:
if self.settings.embedding_backend == "hash":
return super().encode_image(image)
if not isinstance(image, Path):
raise TypeError("隔离推理仅接受本地图像路径")
return self.supervisor.call("encode_image", path=str(image))
def encode_images(self, images: list[Path]) -> list[list[float]]:
if self.settings.embedding_backend == "hash":
return super().encode_images(images)
if not all(isinstance(image, Path) for image in images):
raise TypeError("隔离推理仅接受本地图像路径")
return self.supervisor.call("encode_images", paths=[str(image) for image in images])
def verify_acceleration(self) -> dict:
if self.settings.embedding_backend == "hash":
return super().verify_acceleration()
return self.supervisor.call("verify_visual")
def reset(self) -> None:
self.supervisor.reset("visual")
class IsolatedOcrService(OcrService):
def __init__(self, settings: Settings, accelerator: AcceleratorService, supervisor: InferenceSupervisor):
super().__init__(settings, accelerator)
self.supervisor = supervisor
def recognize(self, image: Path) -> list[tuple[str, float]]:
if not self.ready():
return []
return [tuple(value) for value in self.supervisor.call("recognize", path=str(image))]
def recognize_batch(self, images: list[Path]) -> list[list[tuple[str, float]]]:
if not self.ready():
return [[] for _ in images]
results = self.supervisor.call("recognize_batch", paths=[str(image) for image in images])
return [[tuple(value) for value in result] for result in results]
def reset(self) -> None:
self.supervisor.reset("ocr")
class IsolatedFaceService(FaceService):
def __init__(self, settings: Settings, accelerator: AcceleratorService, supervisor: InferenceSupervisor):
super().__init__(settings, accelerator)
self.supervisor = supervisor
def detect_and_embed(self, image_path: Path) -> list[dict]:
if not self.ready():
return []
return self.supervisor.call("detect_faces", path=str(image_path))
def detect_and_embed_batch(self, image_paths: list[Path]) -> list[list[dict]]:
if not self.ready():
return [[] for _ in image_paths]
return self.supervisor.call("detect_faces_batch", paths=[str(image) for image in image_paths])
def reset(self) -> None:
self.supervisor.reset("faces")
+873
View File
@@ -0,0 +1,873 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import sqlite3
import threading
import time
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from .database import Database, DatabaseTransientError, utcnow
logger = logging.getLogger(__name__)
JobHandler = Callable[[str, dict], Awaitable[None]]
JOB_LANES = {
"index_video": "ai",
"transcribe_audio": "ai",
"suggest_tags": "ai",
"install_models": "ai",
"prepare_ai_runtime": "ai",
"transfer_upload": "transfer",
"refresh_path": "transfer",
"aria2_download": "download",
"scan_source": "scan",
"backup_export": "transfer",
}
LANE_KINDS = {
lane: frozenset(kind for kind, value in JOB_LANES.items() if value == lane)
for lane in ("ai", "transfer", "download", "scan")
}
JOB_PRIORITIES = {
"prepare_ai_runtime": -10,
"install_models": -10,
"refresh_path": 10,
# A user-visible upload should start before passive source scans when a
# caller claims across lanes (for example during recovery or diagnostics).
"transfer_upload": 0,
"aria2_download": 10,
"scan_source": 10,
# Extract visual/OCR/face metadata before the much longer audio backlog so
# freshly uploaded videos become useful in the library promptly.
"index_video": 10,
"transcribe_audio": 20,
"suggest_tags": 40,
}
def job_lane(kind: str) -> str:
return JOB_LANES.get(kind, "ai")
class JobCancelled(RuntimeError):
pass
class JobRetry(RuntimeError):
def __init__(self, message: str, delay_seconds: float):
super().__init__(message)
self.delay_seconds = max(0.0, delay_seconds)
class JobQueue:
def __init__(self, db: Database, poll_seconds: float = 1.0, governor=None, events=None):
self.db = db
self.poll_seconds = poll_seconds
self.governor = governor
self.events = events
self.handlers: dict[str, JobHandler] = {}
self._stopping = asyncio.Event()
self.owner = f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
self._progress_guard = threading.Lock()
self._progress_state: dict[str, tuple[float, float]] = {}
self._pause_state: dict[str, tuple[str | None, float]] = {}
def register(self, kind: str, handler: JobHandler) -> None:
self.handlers[kind] = handler
@staticmethod
def _resources(payload: dict) -> list[tuple[str, str]]:
resources: list[tuple[str, str]] = []
for key, resource_type in (
("video_id", "video"),
("source_id", "source"),
("upload_id", "upload"),
("collection_id", "collection"),
("backup_id", "backup"),
):
value = payload.get(key)
if isinstance(value, str) and value:
resources.append((resource_type, value))
video_ids = payload.get("video_ids")
if isinstance(video_ids, list):
resources.extend(("video", value) for value in video_ids if isinstance(value, str) and value)
return list(dict.fromkeys(resources))
def enqueue(
self,
kind: str,
payload: dict,
*,
dedupe_key: str | None = None,
delay_seconds: float = 0,
priority: int | None = None,
connection=None,
) -> str:
job_id = str(uuid.uuid4())
run_after = datetime.now(UTC) + timedelta(seconds=delay_seconds)
resolved_priority = int(priority if priority is not None else JOB_PRIORITIES.get(kind, 50))
def insert(conn) -> str:
inserted = conn.execute(
"INSERT INTO jobs(id,kind,payload_json,dedupe_key,priority,run_after,created_at) "
"VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING",
(job_id, kind, json.dumps(payload), dedupe_key, resolved_priority, run_after.isoformat(), utcnow()),
).rowcount
if not inserted and dedupe_key:
row = conn.execute(
"SELECT id,status,priority FROM jobs WHERE dedupe_key=? AND status IN ('queued','running')",
(dedupe_key,),
).fetchone()
if row:
# A warm-cache follow-up can intentionally enqueue the same
# task at a higher priority. Preserve the running worker,
# but promote a queued duplicate instead of silently losing
# the caller's scheduling intent.
if row["status"] == "queued" and int(row["priority"]) > resolved_priority:
conn.execute(
"UPDATE jobs SET priority=? WHERE id=? AND status='queued' AND priority>?",
(resolved_priority, row["id"], resolved_priority),
)
return row["id"]
if not inserted:
raise sqlite3.IntegrityError("任务写入冲突,且未找到可复用的活动任务")
resources = self._resources(payload)
if resources:
conn.executemany(
"INSERT INTO job_resources(job_id,resource_type,resource_id) VALUES(?,?,?) "
"ON CONFLICT DO NOTHING",
((job_id, resource_type, resource_id) for resource_type, resource_id in resources),
)
return job_id
if connection is not None:
result = insert(connection)
else:
with self.db.transaction() as conn:
result = insert(conn)
if self.events is not None:
self.events.publish("jobs", job_id=result, action="queued", kind=kind)
return result
@staticmethod
def _public(row) -> dict:
item = dict(row)
item["lane"] = job_lane(item["kind"])
raw_diagnostics = item.pop("diagnostics_json", "{}")
try:
diagnostics = json.loads(raw_diagnostics or "{}")
except (TypeError, json.JSONDecodeError):
diagnostics = {}
item["inference_diagnostics"] = diagnostics if isinstance(diagnostics, dict) else {}
return item
def list(
self,
limit: int = 100,
*,
lane: str | None = None,
status: str | None = None,
) -> list[dict]:
filters: list[str] = []
parameters: list[object] = []
if lane:
kinds = LANE_KINDS[lane]
placeholders = ",".join("?" for _ in kinds)
filters.append(f"kind IN ({placeholders})")
parameters.extend(sorted(kinds))
if status:
filters.append("status=?")
parameters.append(status)
where = f" WHERE {' AND '.join(filters)}" if filters else ""
with self.db.read() as conn:
rows = conn.execute(
"SELECT id,kind,status,progress,message,attempts,created_at,started_at,finished_at,error,"
f"heartbeat_at,pause_reason,cancel_requested,diagnostics_json FROM jobs{where} "
"ORDER BY created_at DESC LIMIT ?",
(*parameters, min(max(limit, 1), 500)),
).fetchall()
return [self._public(row) for row in rows]
def paginate(
self,
page: int = 1,
page_size: int = 10,
*,
lane: str | None = None,
status: str | None = None,
) -> dict:
page = max(1, page)
page_size = min(max(page_size, 1), 50)
filters: list[str] = []
parameters: list[str] = []
if lane:
kinds = LANE_KINDS[lane]
placeholders = ",".join("?" for _ in kinds)
filters.append(f"kind IN ({placeholders})")
parameters.extend(sorted(kinds))
if status:
filters.append("status=?")
parameters.append(status)
where = f" WHERE {' AND '.join(filters)}" if filters else ""
with self.db.read() as conn:
total = int(conn.execute(f"SELECT count(*) FROM jobs{where}", parameters).fetchone()[0])
retryable_failed_count = len(self._retryable_failed_rows(conn, lane=lane))
pages = max(1, (total + page_size - 1) // page_size)
page = min(page, pages)
rows = conn.execute(
"SELECT id,kind,status,progress,message,attempts,created_at,started_at,finished_at,error,"
f"heartbeat_at,pause_reason,cancel_requested,diagnostics_json FROM jobs{where} "
"ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
(*parameters, page_size, (page - 1) * page_size),
).fetchall()
return {
"items": [self._public(row) for row in rows],
"page": page,
"page_size": page_size,
"total": total,
"pages": pages,
"retryable_failed_count": retryable_failed_count,
}
def _retryable_failed_rows(self, conn, *, lane: str | None = None, lock: bool = False):
filters = ["j.status='failed'"]
parameters: list[object] = []
if lane:
kinds = LANE_KINDS[lane]
placeholders = ",".join("?" for _ in kinds)
filters.append(f"j.kind IN ({placeholders})")
parameters.extend(sorted(kinds))
filters.append(
"NOT EXISTS(SELECT 1 FROM jobs newer WHERE newer.id<>j.id AND "
"((j.dedupe_key IS NOT NULL AND newer.dedupe_key=j.dedupe_key) OR "
"(j.dedupe_key IS NULL AND newer.dedupe_key IS NULL AND newer.kind=j.kind "
"AND newer.payload_json=j.payload_json)) AND "
"(newer.status IN ('queued','running') OR newer.created_at>j.created_at))"
)
suffix = " FOR UPDATE OF j SKIP LOCKED" if lock else ""
rows = conn.execute(
"SELECT j.id,j.kind,j.payload_json,j.dedupe_key,j.priority,j.created_at FROM jobs j WHERE "
f"{' AND '.join(filters)} ORDER BY j.created_at,j.id{suffix}",
parameters,
).fetchall()
result = []
for row in rows:
if row["kind"] not in self.handlers:
continue
try:
payload = json.loads(row["payload_json"])
except (TypeError, json.JSONDecodeError):
continue
if isinstance(payload, dict):
result.append((row, payload))
return result
def retry_failed(self, *, lane: str | None = None) -> dict:
"""Retry the latest unresolved failure for each logical task."""
if lane is not None and lane not in LANE_KINDS:
raise ValueError("未知的后台任务通道")
retried_ids: list[str] = []
with self.db.transaction() as conn:
rows = self._retryable_failed_rows(conn, lane=lane, lock=True)
for row, payload in rows:
retried_ids.append(
self.enqueue(
row["kind"],
payload,
dedupe_key=row["dedupe_key"] or f"bulk-retry:{row['id']}",
priority=int(row["priority"]),
connection=conn,
)
)
return {
"lane": lane or "all",
"retried": len(retried_ids),
"skipped": 0,
"job_ids": retried_ids,
}
def retry(self, job_id: str) -> str:
"""Create a new queued attempt while preserving the failed job record."""
with self.db.read() as conn:
row = conn.execute(
"SELECT kind,payload_json,dedupe_key,status,priority FROM jobs WHERE id=?",
(job_id,),
).fetchone()
if row is None:
raise KeyError(job_id)
if row["status"] != "failed":
raise ValueError("只有失败的后台任务可以重试")
if row["kind"] not in self.handlers:
raise ValueError("该任务类型当前不可重试")
try:
payload = json.loads(row["payload_json"])
except (TypeError, json.JSONDecodeError) as exc:
raise ValueError("原任务参数损坏,无法重试") from exc
if not isinstance(payload, dict):
raise ValueError("原任务参数损坏,无法重试")
return self.enqueue(
row["kind"], payload, dedupe_key=row["dedupe_key"], priority=int(row["priority"])
)
def update(self, job_id: str, progress: float, message: str = "") -> None:
value = min(max(progress, 0), 1)
now = time.monotonic()
with self._progress_guard:
previous_at, previous_value = self._progress_state.get(job_id, (0.0, -1.0))
if value < 1 and now - previous_at < 2 and abs(value - previous_value) < 0.01:
return
try:
self.db.write_with_retry(
lambda conn: conn.execute(
"UPDATE jobs SET progress=?,message=?,heartbeat_at=? WHERE id=?",
(value, message[:500], utcnow(), job_id),
),
timeout_seconds=5,
)
except DatabaseTransientError:
logger.warning("skipped progress update after a transient PostgreSQL conflict for job %s", job_id)
else:
with self._progress_guard:
self._progress_state[job_id] = (now, value)
if self.events is not None:
self.events.publish("jobs", job_id=job_id, action="progress", progress=value)
def set_diagnostics(self, job_id: str, diagnostics: dict) -> None:
"""Persist privacy-safe inference metadata for task and UI diagnostics."""
safe = {
str(key): value
for key, value in dict(diagnostics or {}).items()
if key
in {
"requested_device",
"actual_device",
"primary_backend",
"language_candidates",
"selected_language",
"fallback_scope",
"fallback_reason",
"gpu_quality_score",
"cpu_verification_quality",
}
}
payload = json.dumps(safe, ensure_ascii=False, separators=(",", ":"), default=str)
try:
self.db.write_with_retry(
lambda conn: conn.execute(
"UPDATE jobs SET diagnostics_json=?,heartbeat_at=? WHERE id=?",
(payload, utcnow(), job_id),
),
timeout_seconds=5,
)
except DatabaseTransientError:
# Diagnostics improve observability but are not part of the media
# result. A short PostgreSQL conflict must not turn an otherwise
# successful inference into a failed background job.
logger.warning("skipped diagnostics update after a transient PostgreSQL conflict for job %s", job_id)
def set_pause_reason(self, job_id: str, reason: str | None) -> None:
now = time.monotonic()
with self._progress_guard:
previous = self._pause_state.get(job_id)
if previous and previous[0] == reason and now - previous[1] < 15:
return
try:
self.db.write_with_retry(
lambda conn: conn.execute(
"UPDATE jobs SET pause_reason=?,heartbeat_at=?,message=coalesce(?,message) WHERE id=?",
(reason, utcnow(), reason, job_id),
),
timeout_seconds=5,
)
except DatabaseTransientError:
logger.warning("skipped pause heartbeat after a transient PostgreSQL conflict for job %s", job_id)
else:
with self._progress_guard:
self._pause_state[job_id] = (reason, now)
def _clear_runtime_state(self, job_id: str) -> None:
with self._progress_guard:
self._progress_state.pop(job_id, None)
self._pause_state.pop(job_id, None)
async def _write_critical(self, operation):
"""Persist a job state transition without blocking the ASGI loop.
PostgreSQL can abort a transaction on a deadlock or serialization
conflict. Keep retrying the complete state transition on a worker
thread instead of killing a lane worker or blocking the ASGI loop.
"""
while True:
try:
return await asyncio.to_thread(
self.db.write_with_retry,
operation,
timeout_seconds=5,
)
except DatabaseTransientError:
if self._stopping.is_set():
return None
logger.warning("retrying a job state after a transient PostgreSQL conflict")
try:
await asyncio.wait_for(self._stopping.wait(), timeout=self.poll_seconds)
except TimeoutError:
pass
@staticmethod
def _requeue_transient_job(conn, job_id: str) -> float:
row = conn.execute(
"SELECT attempts,cancel_requested FROM jobs WHERE id=?",
(job_id,),
).fetchone()
if row is None:
return 0
if row["cancel_requested"]:
conn.execute(
"UPDATE jobs SET status='cancelled',lease_owner=NULL,pause_reason=NULL,"
"finished_at=?,heartbeat_at=?,message='已取消' WHERE id=?",
(utcnow(), utcnow(), job_id),
)
return 0
attempts = max(1, int(row["attempts"] or 1))
delay = float(min(30, 5 * (2 ** min(attempts - 1, 3))))
run_after = datetime.now(UTC) + timedelta(seconds=delay)
conn.execute(
"UPDATE jobs SET status='queued',run_after=?,started_at=NULL,finished_at=NULL,error=NULL,"
"lease_owner=NULL,heartbeat_at=?,pause_reason=NULL,"
"message='数据库事务冲突,已自动恢复,稍后重试' WHERE id=?",
(run_after.isoformat(), utcnow(), job_id),
)
return delay
@staticmethod
def _finish_failed(conn, job_id: str, error: str) -> None:
"""Finish a worker failure without overriding a concurrent cancellation.
Video deletion deliberately removes the database row and remote object
immediately after requesting cancellation. A running media handler can
therefore observe ``video missing`` before reaching its next checkpoint.
Resolve the final state atomically so that the already-committed cancel
request wins over that expected teardown error.
"""
now = utcnow()
conn.execute(
"UPDATE jobs SET status=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'failed' END,"
"error=CASE WHEN cancel_requested=1 THEN NULL ELSE ? END,finished_at=?,pause_reason=NULL,"
"lease_owner=NULL,heartbeat_at=?,message=CASE WHEN cancel_requested=1 THEN '已取消' ELSE message END "
"WHERE id=?",
(error, now, now, job_id),
)
@staticmethod
def _finish_retry(conn, job_id: str, run_after: str, message: str) -> None:
"""Requeue a retry unless a concurrent caller has cancelled the job."""
now = utcnow()
conn.execute(
"UPDATE jobs SET status=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'queued' END,"
"run_after=?,started_at=NULL,"
"finished_at=CASE WHEN cancel_requested=1 THEN ? ELSE NULL END,error=NULL,lease_owner=NULL,"
"heartbeat_at=?,pause_reason=NULL,"
"message=CASE WHEN cancel_requested=1 THEN '已取消' ELSE ? END WHERE id=?",
(run_after, now, now, message, job_id),
)
@staticmethod
def _finish_completed(conn, job_id: str) -> None:
"""Commit success unless cancellation was requested before finalization."""
now = utcnow()
conn.execute(
"UPDATE jobs SET status=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'completed' END,"
"progress=CASE WHEN cancel_requested=1 THEN progress ELSE 1 END,finished_at=?,pause_reason=NULL,"
"lease_owner=NULL,heartbeat_at=?,error=NULL,"
"message=CASE WHEN cancel_requested=1 THEN '已取消' ELSE message END WHERE id=?",
(now, now, job_id),
)
@staticmethod
def _finish_cancelled(conn, job_id: str) -> None:
"""Finish a cancellation unless its request was concurrently rolled back.
Deletion compensation can clear ``cancel_requested`` after a running
worker has already left its checkpoint. In that race the payload is
still valid again, so return the job to the queue instead of committing
a stale cancelled state after ``restore_cancelled`` has completed.
"""
now = utcnow()
conn.execute(
"UPDATE jobs SET status=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'queued' END,"
"started_at=CASE WHEN cancel_requested=1 THEN started_at ELSE NULL END,"
"finished_at=CASE WHEN cancel_requested=1 THEN ? ELSE NULL END,lease_owner=NULL,"
"heartbeat_at=?,pause_reason=NULL,error=NULL,"
"message=CASE WHEN cancel_requested=1 THEN '已取消' ELSE '取消已回滚,任务重新排队' END "
"WHERE id=?",
(now, now, job_id),
)
def _job_state(self, job_id: str):
with self.db.read() as conn:
return conn.execute("SELECT kind,status,cancel_requested FROM jobs WHERE id=?", (job_id,)).fetchone()
def _cancel_requested(self, job_id: str) -> bool:
row = self._job_state(job_id)
# Direct service-level maintenance/tests may use an ephemeral progress
# id. A missing row is not a cancellation signal; real worker jobs are
# retained for their full lifecycle.
return bool(row and row["cancel_requested"])
def checkpoint(self, job_id: str, message: str | None = None, *, persist: bool = True) -> None:
state = self._job_state(job_id)
if state and state["cancel_requested"]:
raise JobCancelled("任务已取消")
def paused(reason: str) -> None:
self.set_pause_reason(job_id, reason)
def resumed() -> None:
self.set_pause_reason(job_id, None)
# Resource pressure must only park work that has actually been leased
# by the background worker. Direct maintenance calls and service-level
# tests can legitimately use a queued or ephemeral progress id and
# must not wait forever merely because their temporary filesystem is
# smaller than the NAS reserve policy.
if self.governor is not None and state is not None and state["status"] == "running":
if self._should_yield_to_model_maintenance(job_id):
raise JobCancelled("模型安装优先,音频识别已让路")
self.governor.wait_sync(
lane=job_lane(state["kind"]),
paused=paused,
resumed=resumed,
cancelled=lambda: (
self._cancel_requested(job_id)
or self._stopping.is_set()
or self._should_yield_to_model_maintenance(job_id)
),
)
if self._should_yield_to_model_maintenance(job_id):
raise JobCancelled("模型安装优先,音频识别已让路")
if self._cancel_requested(job_id):
raise JobCancelled("任务已取消")
if persist:
try:
self.db.write_with_retry(
lambda conn: conn.execute(
"UPDATE jobs SET pause_reason=NULL,heartbeat_at=?,message=coalesce(?,message) WHERE id=?",
(utcnow(), message, job_id),
),
timeout_seconds=5,
)
except DatabaseTransientError:
logger.warning("skipped checkpoint heartbeat after a transient conflict for job %s", job_id)
def request_cancel(self, job_id: str) -> None:
with self.db.transaction() as conn:
row = conn.execute("SELECT kind,status FROM jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
raise KeyError(job_id)
if row["kind"] == "backup_export" and row["status"] == "running":
raise ValueError("备份已经开始生成,不能中途取消")
if row["status"] == "queued":
now = utcnow()
conn.execute(
"UPDATE jobs SET status='cancelled',cancel_requested=1,finished_at=?,message='已取消' WHERE id=?",
(now, job_id),
)
if row["kind"] == "backup_export":
conn.execute(
"UPDATE backup_exports SET status='failed',secret_blob=NULL,error='备份任务已取消',"
"finished_at=? WHERE id IN (SELECT resource_id FROM job_resources "
"WHERE job_id=? AND resource_type='backup')",
(now, job_id),
)
elif row["status"] == "running":
conn.execute("UPDATE jobs SET cancel_requested=1,message='正在安全停止' WHERE id=?", (job_id,))
else:
raise ValueError("该任务已经结束")
if self.events is not None:
self.events.publish("jobs", job_id=job_id, action="cancel")
def cancel_by_kind(self, kind: str) -> list[str]:
with self.db.transaction() as conn:
rows = conn.execute(
"SELECT id,status FROM jobs WHERE kind=? AND status IN ('queued','running') ORDER BY created_at",
(kind,),
).fetchall()
cancelled: list[str] = []
for row in rows:
if row["status"] == "queued":
conn.execute(
"UPDATE jobs SET status='cancelled',cancel_requested=1,finished_at=?,"
"message='模型安装优先,已取消' WHERE id=?",
(utcnow(), row["id"]),
)
else:
conn.execute(
"UPDATE jobs SET cancel_requested=1,message='模型安装优先,正在停止' WHERE id=?",
(row["id"],),
)
cancelled.append(row["id"])
return cancelled
def has_queued_model_maintenance(self) -> bool:
with self.db.read() as conn:
return bool(
conn.execute(
"SELECT 1 FROM jobs WHERE kind IN ('install_models','prepare_ai_runtime') "
"AND status='queued' LIMIT 1"
).fetchone()
)
def _should_yield_to_model_maintenance(self, job_id: str) -> bool:
with self.db.read() as conn:
row = conn.execute("SELECT kind,status FROM jobs WHERE id=?", (job_id,)).fetchone()
return bool(row and row["kind"] == "transcribe_audio" and row["status"] == "running") and (
self.has_queued_model_maintenance()
)
def cancel_for_video(
self,
video_id: str,
*,
exclude_job_ids: set[str] | None = None,
connection=None,
) -> list[str]:
excluded = exclude_job_ids or set()
def cancel(conn) -> list[str]:
rows = conn.execute(
"SELECT DISTINCT j.id,j.status FROM jobs j JOIN job_resources r ON r.job_id=j.id "
"WHERE r.resource_type='video' AND r.resource_id=? AND j.status IN ('queued','running')",
(video_id,),
).fetchall()
cancelled: list[str] = []
now = utcnow()
for row in rows:
if row["id"] in excluded:
continue
if row["status"] == "queued":
changed = conn.execute(
"UPDATE jobs SET status='cancelled',cancel_requested=1,finished_at=?,message='已取消' "
"WHERE id=? AND status='queued'",
(now, row["id"]),
).rowcount
else:
changed = conn.execute(
"UPDATE jobs SET cancel_requested=1,message='正在安全停止' "
"WHERE id=? AND status='running'",
(row["id"],),
).rowcount
if changed:
cancelled.append(row["id"])
return cancelled
if connection is not None:
return cancel(connection)
with self.db.transaction() as conn:
return cancel(conn)
def restore_cancelled(self, job_ids: list[str]) -> int:
"""Undo a deletion-time cancellation after the delete is compensated.
Queued jobs are returned to the queue with their original payload and
priority. A running worker may already have observed the cancellation,
so both the still-running and just-cancelled states are handled.
Completed/failed jobs are intentionally left as historical truth.
"""
identifiers = list(dict.fromkeys(str(value) for value in job_ids if value))
if not identifiers:
return 0
placeholders = ",".join("?" for _ in identifiers)
with self.db.transaction() as conn:
restored = conn.execute(
f"UPDATE jobs SET status=CASE WHEN status='cancelled' THEN 'queued' ELSE status END,"
"cancel_requested=0,finished_at=CASE WHEN status='cancelled' THEN NULL ELSE finished_at END,"
"lease_owner=CASE WHEN status='cancelled' THEN NULL ELSE lease_owner END,"
"pause_reason=NULL,error=CASE WHEN status='cancelled' THEN NULL ELSE error END,"
"message=CASE WHEN status='cancelled' THEN '删除已回滚,任务重新排队' ELSE message END "
f"WHERE id IN ({placeholders}) AND status IN ('queued','running','cancelled')",
identifiers,
).rowcount
return int(restored)
def cleanup_history(self, *, completed_days: int = 30, failed_days: int = 90) -> dict[str, int]:
completed_cutoff = (datetime.now(UTC) - timedelta(days=max(1, completed_days))).isoformat()
failed_cutoff = (datetime.now(UTC) - timedelta(days=max(1, failed_days))).isoformat()
with self.db.transaction() as conn:
completed = conn.execute(
"DELETE FROM jobs WHERE status IN ('completed','cancelled') "
"AND coalesce(finished_at,created_at)<?",
(completed_cutoff,),
).rowcount
failed = conn.execute(
"DELETE FROM jobs WHERE status='failed' AND coalesce(finished_at,created_at)<?",
(failed_cutoff,),
).rowcount
return {"completed_cancelled": int(completed), "failed": int(failed)}
def recover_stale(self, stale_seconds: int = 300) -> int:
cutoff = (datetime.now(UTC) - timedelta(seconds=stale_seconds)).isoformat()
with self.db.transaction() as conn:
return conn.execute(
"UPDATE jobs SET status='queued',started_at=NULL,lease_owner=NULL,heartbeat_at=NULL,"
"pause_reason=NULL,cancel_requested=0,message='应用重启,等待继续执行' "
"WHERE status='running' AND (lease_owner IS NULL OR lease_owner NOT LIKE ? "
"OR heartbeat_at IS NULL OR heartbeat_at<?)",
(f"{self.owner}:%", cutoff),
).rowcount
def _claim(
self,
*,
lane: str | None = None,
allowed_kinds: frozenset[str] | None = None,
excluded_kinds: frozenset[str] = frozenset(),
) -> tuple[str, str, dict] | None:
lane_name = lane or "ai"
if lane and allowed_kinds is None:
allowed_kinds = LANE_KINDS[lane]
if self.governor is not None and self.governor.pressure_reason(lane=lane_name):
return None
filters = ["status='queued'", "cancel_requested=0", "run_after<=?"]
parameters: list[str] = [utcnow()]
if allowed_kinds:
placeholders = ",".join("?" for _ in allowed_kinds)
filters.append(f"kind IN ({placeholders})")
parameters.extend(sorted(allowed_kinds))
if excluded_kinds:
placeholders = ",".join("?" for _ in excluded_kinds)
filters.append(f"kind NOT IN ({placeholders})")
parameters.extend(sorted(excluded_kinds))
with self.db.transaction() as conn:
row = conn.execute(
f"SELECT id,kind,payload_json FROM jobs WHERE {' AND '.join(filters)} "
"ORDER BY priority,created_at LIMIT 1 FOR UPDATE SKIP LOCKED",
parameters,
).fetchone()
if not row:
return None
changed = conn.execute(
"UPDATE jobs SET status='running',started_at=?,attempts=attempts+1,lease_owner=?,"
"heartbeat_at=?,pause_reason=NULL WHERE id=? AND status='queued' AND cancel_requested=0",
(utcnow(), f"{self.owner}:{lane_name}", utcnow(), row["id"]),
).rowcount
if not changed:
return None
return row["id"], row["kind"], json.loads(row["payload_json"])
async def _execute_claimed(self, job_id: str, kind: str, payload: dict) -> None:
handler = self.handlers.get(kind)
if handler is None:
error = f"没有任务处理器:{kind}"
await self._write_critical(
lambda conn, error=error, job_id=job_id: self._finish_failed(conn, job_id, error)
)
self._clear_runtime_state(job_id)
return
try:
await asyncio.to_thread(self.checkpoint, job_id)
await handler(job_id, payload)
except JobCancelled:
await self._write_critical(
lambda conn, job_id=job_id: self._finish_cancelled(conn, job_id)
)
except JobRetry as exc:
run_after = datetime.now(UTC) + timedelta(seconds=exc.delay_seconds)
retry_message = str(exc)[:500]
await self._write_critical(
lambda conn, run_after=run_after, retry_message=retry_message, job_id=job_id: self._finish_retry(
conn, job_id, run_after.isoformat(), retry_message
)
)
except DatabaseTransientError:
delay = await self._write_critical(
lambda conn, job_id=job_id: self._requeue_transient_job(conn, job_id)
)
logger.warning(
"job %s hit a transient PostgreSQL conflict and was requeued for %.0fs",
job_id,
delay or 0,
)
except asyncio.CancelledError:
def release(conn, job_id=job_id):
state = conn.execute("SELECT cancel_requested FROM jobs WHERE id=?", (job_id,)).fetchone()
if state and state["cancel_requested"]:
conn.execute(
"UPDATE jobs SET status='cancelled',lease_owner=NULL,pause_reason=NULL,"
"finished_at=?,message='已取消' WHERE id=?",
(utcnow(), job_id),
)
else:
conn.execute(
"UPDATE jobs SET status='queued',started_at=NULL,lease_owner=NULL,heartbeat_at=NULL,"
"pause_reason=NULL WHERE id=?",
(job_id,),
)
await self._write_critical(release)
raise
except Exception as exc:
logger.exception("job %s failed", job_id)
# Full exception details belong in the service log. API clients
# receive a short, actionable message without leaking paths,
# credentials or an implementation traceback.
error = str(exc).strip()[:1000] or "任务执行失败,请查看服务日志"
await self._write_critical(
lambda conn, error=error, job_id=job_id: self._finish_failed(conn, job_id, error)
)
else:
await self._write_critical(
lambda conn, job_id=job_id: self._finish_completed(conn, job_id)
)
finally:
self._clear_runtime_state(job_id)
if self.events is not None:
self.events.publish("jobs", job_id=job_id, action="finished")
async def run(
self,
*,
lane: str | None = None,
allowed_kinds: frozenset[str] | None = None,
excluded_kinds: frozenset[str] = frozenset(),
) -> None:
if lane and allowed_kinds is None:
allowed_kinds = LANE_KINDS[lane]
while not self._stopping.is_set():
try:
claimed = await asyncio.to_thread(
self._claim,
lane=lane,
allowed_kinds=allowed_kinds,
excluded_kinds=excluded_kinds,
)
except DatabaseTransientError:
logger.warning("job lane %s is retrying after a PostgreSQL conflict", lane or "ai")
try:
await asyncio.wait_for(self._stopping.wait(), timeout=self.poll_seconds)
except TimeoutError:
pass
continue
if not claimed:
try:
await asyncio.wait_for(self._stopping.wait(), timeout=self.poll_seconds)
except TimeoutError:
pass
continue
await self._execute_claimed(*claimed)
def stop(self) -> None:
self._stopping.set()
+302
View File
@@ -0,0 +1,302 @@
from __future__ import annotations
import argparse
import importlib
import json
import logging
import mimetypes
import os
import socket
import sys
import tempfile
import time
import urllib.error
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
from starlette.requests import Request
from . import __version__
def create_app(settings=None):
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, Response
from .api import router
from .config import get_settings
from .container import Services
from .diagnostics import ObservabilityMiddleware
from .gateway import GatewayMountMiddleware
from .webdav import api_router as webdav_api_router
from .webdav import router as webdav_router
settings = settings or get_settings()
settings.prepare()
services = Services(settings)
@asynccontextmanager
async def lifespan(app: FastAPI):
await services.start()
try:
yield
finally:
await services.stop()
app = FastAPI(
title="ImageFind API",
version=__version__,
description="支持本地、WebDAV 与 AList 加密存储的私有 AI 媒体库",
lifespan=lifespan,
docs_url="/api/docs",
openapi_url="/api/openapi.json",
)
app.state.services = services
app.include_router(router)
app.include_router(webdav_api_router)
app.include_router(webdav_router)
app.add_middleware(
GatewayMountMiddleware,
socket_path=settings.gateway_socket,
prefix=settings.gateway_prefix,
)
app.add_middleware(ObservabilityMiddleware, diagnostics=services.diagnostics)
frontend = settings.frontend_dir or (Path(__file__).resolve().parents[2] / "frontend" / "dist")
if frontend.is_dir():
frontend = frontend.resolve()
@app.get("/{asset_path:path}", include_in_schema=False)
async def frontend_asset(asset_path: str, request: Request):
candidate = (frontend / asset_path).resolve()
if frontend not in candidate.parents or not candidate.is_file():
candidate = frontend / "index.html"
content = candidate.read_bytes()
media_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
headers: dict[str, str] = {}
if candidate.name == "index.html":
root_path = request.scope.get("root_path", "").rstrip("/")
app_base = f"{root_path}/" if root_path else "/"
access_mode = "gateway" if root_path else "direct"
runtime = (
"<script>window.__IMAGEFIND_BASE__="
f"{json.dumps(app_base, ensure_ascii=True)};"
"document.documentElement.dataset.imagefindAccess="
f"{json.dumps(access_mode)};</script>"
).encode()
content = content.replace(b"</head>", runtime + b"</head>", 1)
headers["Cache-Control"] = "no-store"
return Response(content=content, media_type=media_type, headers=headers)
else:
@app.get("/", include_in_schema=False)
async def development_root():
return HTMLResponse(
"<h1>ImageFind API 正在运行</h1>"
"<p>前端尚未构建。开发模式请运行 <code>npm run dev --prefix frontend</code>。</p>"
"<p><a href='/api/docs'>打开 API 文档</a></p>"
)
return app
def _unix_socket(path: Path, mode: int) -> socket.socket:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() or path.is_socket():
path.unlink()
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
listener.bind(str(path))
os.chmod(path, mode)
listener.set_inheritable(True)
return listener
except Exception:
listener.close()
if path.exists() or path.is_socket():
path.unlink()
raise
def _tcp_socket(host: str, port: int) -> socket.socket:
family = socket.AF_INET6 if ":" in host else socket.AF_INET
listener = socket.socket(family, socket.SOCK_STREAM)
try:
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((host, port))
listener.set_inheritable(True)
return listener
except Exception:
listener.close()
raise
def serve(settings) -> None:
import uvicorn
listeners: list[socket.socket] = []
socket_path = settings.gateway_socket
try:
# Remote media is deliberately fetched through the authenticated
# internal proxy. It must remain reachable when fnOS direct access is
# disabled, without reopening a user-facing TCP port.
internal_listener = _tcp_socket("127.0.0.1", settings.internal_media_port)
settings.internal_media_port = int(internal_listener.getsockname()[1])
listeners.append(internal_listener)
if socket_path is not None:
listeners.append(_unix_socket(socket_path, settings.gateway_socket_mode))
if settings.direct_access or socket_path is None:
listeners.append(_tcp_socket(settings.host, settings.port))
if not listeners:
raise RuntimeError("没有启用 ImageFind 监听入口")
config = uvicorn.Config(
create_app(settings),
log_level="info",
forwarded_allow_ips="127.0.0.1",
)
uvicorn.Server(config).run(sockets=listeners)
finally:
for listener in listeners:
listener.close()
if socket_path is not None and (socket_path.exists() or socket_path.is_socket()):
socket_path.unlink()
def provision_admin_password(password: str, *, replace: bool, settings=None) -> None:
from .config import get_settings
from .database import Database
from .security import AuthService
settings = settings or get_settings()
settings.prepare()
db = Database(settings.database_path)
db.initialize()
AuthService(db, settings.session_days).set_password(password, replace=replace)
def enroll_postgres(token: str | None = None, *, settings=None, force: bool = False) -> None:
from .config import get_settings
settings = settings or get_settings()
settings.prepare()
conf_path = settings.postgres_conf_path
seed_path = settings.postgres_enrollment_seed_path
if conf_path.exists() and not force:
return
if token is None and seed_path.exists():
token = seed_path.read_text(encoding="utf-8").strip()
token = (token or "").strip()
if "\n" in token or len(token) < 20 or len(token) > 256:
raise ValueError("PostgreSQL 应用接入令牌长度需为 20-256 个字符且不能包含换行")
payload = json.dumps(
{"appId": "imagefind", "displayName": "ImageFind", "requestedExtensions": ["vector"]}
).encode("utf-8")
request = urllib.request.Request(
"http://127.0.0.1:15433/internal/v1/enroll",
data=payload,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
method="POST",
)
deadline = time.monotonic() + 120
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(request, timeout=10) as response:
body = json.loads(response.read().decode("utf-8"))
break
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
last_error = exc
time.sleep(2)
else:
raise RuntimeError(f"PostgreSQL 共享服务注册失败:{last_error}") from last_error
required = {"host", "port", "database", "username", "password"}
if not required.issubset(body):
raise RuntimeError("PostgreSQL 共享服务返回的凭据不完整")
conf_path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".postgres-client.", dir=conf_path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as output:
output.write(f"host={body['host']}\n")
output.write(f"port={body['port']}\n")
output.write(f"database={body['database']}\n")
output.write(f"username={body['username']}\n")
output.write(f"password={body['password']}\n")
output.write(f"sslmode={body.get('sslMode', 'Disable')}\n")
os.chmod(temporary, 0o600)
os.replace(temporary, conf_path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
if seed_path.exists():
seed_path.unlink()
def check_ai_runtime() -> None:
from .runtime import AI_IMPORTS
components: dict[str, dict[str, object]] = {}
failed = False
for component, modules in AI_IMPORTS.items():
error = None
for module in modules:
try:
importlib.import_module(module)
except Exception as exc:
error = f"{module}: {type(exc).__name__}: {exc}"
failed = True
break
components[component] = {"ok": error is None, "error": error}
print(json.dumps({"ok": not failed, "components": components}, ensure_ascii=False))
if failed:
raise SystemExit(1)
def _arguments(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="imagefind")
parser.add_argument("--version", action="store_true", help="print the ImageFind version and exit")
commands = parser.add_subparsers(dest="command")
admin = commands.add_parser("admin-password", help="initialize or replace the ImageFind administrator password")
admin.add_argument("--stdin", action="store_true", required=True, help="read the password from standard input")
admin.add_argument("--replace", action="store_true", help="replace an existing administrator password")
enroll = commands.add_parser("postgres-enroll", help="register ImageFind with the fnOS PostgreSQL shared service")
enroll.add_argument("--stdin", action="store_true", help="read the enrollment token from standard input")
enroll.add_argument("--force", action="store_true", help="rotate and replace local PostgreSQL credentials")
commands.add_parser("runtime-check", help="validate bundled AI runtime imports")
return parser.parse_args(argv)
def run(argv: list[str] | None = None) -> None:
args = _arguments(sys.argv[1:] if argv is None else argv)
if args.version:
print(__version__)
return
if args.command == "admin-password":
try:
provision_admin_password(sys.stdin.read(), replace=args.replace)
except ValueError as exc:
print(str(exc), file=sys.stderr)
raise SystemExit(2) from exc
return
if args.command == "postgres-enroll":
try:
enroll_postgres(sys.stdin.read() if args.stdin else None, force=args.force)
except (ValueError, RuntimeError) as exc:
print(str(exc), file=sys.stderr)
raise SystemExit(2) from exc
return
if args.command == "runtime-check":
check_ai_runtime()
return
from .config import get_settings
settings = get_settings()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
serve(settings)
if __name__ == "__main__":
run()
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .config import Settings
from .runtime import RuntimeToolManager
from .subtitles import SubtitleCue, parse_subtitles
_PTS = re.compile(r"\bpts_time:([0-9.]+)")
TEXT_SUBTITLE_CODECS = {"ass", "ssa", "subrip", "srt", "webvtt", "mov_text", "text"}
@dataclass(slots=True)
class ExtractedFrame:
path: Path
timestamp_ms: int
segment_start_ms: int
segment_end_ms: int
@dataclass(slots=True)
class MediaInput:
value: str
headers: str | None = None
def ffmpeg_args(self) -> list[str]:
return ["-headers", self.headers, "-i", self.value] if self.headers else ["-i", self.value]
@dataclass(slots=True)
class MediaLease:
input: MediaInput
_lease: Any = None
def close(self) -> None:
if self._lease is not None:
self._lease.close()
self._lease = None
class MediaService:
def __init__(self, settings: Settings, tools: RuntimeToolManager | None = None, remote_cache=None):
self.settings = settings
self.tools = tools
self.remote_cache = remote_cache
def ffmpeg_path(self) -> str:
return self.tools.ffmpeg() if self.tools is not None else self.settings.ffmpeg_path
def ffprobe_path(self) -> str:
return self.tools.ffprobe() if self.tools is not None else self.settings.ffprobe_path
def input_for(self, video: dict) -> MediaInput:
if video.get("source_kind") == "webdav":
# CLI startup assigns a private random loopback port. Falling back
# to the direct port keeps ASGI/dev-server usage backwards
# compatible when it does not use ImageFind's socket orchestrator.
port = self.settings.internal_media_port or self.settings.port
url = f"http://127.0.0.1:{port}/api/internal/remote/{video['id']}"
return MediaInput(url, f"X-ImageFind-Internal: {self.settings.internal_token()}\r\n")
return MediaInput(video["location"])
def acquire_input(self, video: dict, *, progress=None, cancelled=None) -> MediaLease:
if video.get("source_kind") != "webdav" or self.remote_cache is None:
return MediaLease(self.input_for(video))
lease = self.remote_cache.acquire(video, progress=progress, cancelled=cancelled)
return MediaLease(MediaInput(str(lease.path)), lease)
def _run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess:
try:
return subprocess.run(
command,
check=True,
capture_output=True,
timeout=timeout,
preexec_fn=self._demote,
)
except FileNotFoundError as exc:
raise RuntimeError(f"缺少媒体工具:{command[0]}") from exc
except subprocess.CalledProcessError as exc:
detail = self._redact_media_error(exc.stderr.decode("utf-8", errors="replace")[-3000:])
raise RuntimeError(f"媒体处理失败:{detail}") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"媒体处理超时:超过 {timeout or 0} 秒未完成") from exc
@staticmethod
def _redact_media_error(value: str) -> str:
value = re.sub(r"(?i)(X-ImageFind-Internal\s*:\s*)[^\r\n ]+", r"\1[REDACTED]", value)
value = re.sub(r"(?i)(Authorization\s*:\s*)[^\r\n]+", r"\1[REDACTED]", value)
return value
@staticmethod
def _demote() -> None:
try:
os.nice(15)
except OSError:
pass
def probe(self, media: MediaInput) -> dict:
command = [self.ffprobe_path(), "-v", "error", "-show_format", "-show_streams", "-of", "json"]
command.extend(media.ffmpeg_args())
result = self._run(command, timeout=120)
payload = json.loads(result.stdout)
video_stream = next((s for s in payload.get("streams", []) if s.get("codec_type") == "video"), {})
duration = payload.get("format", {}).get("duration") or video_stream.get("duration") or 0
return {
"raw": payload,
"duration_ms": int(float(duration) * 1000),
"width": video_stream.get("width"),
"height": video_stream.get("height"),
"codec": video_stream.get("codec_name"),
"container": payload.get("format", {}).get("format_name"),
}
def extract_frames(self, media: MediaInput, duration_ms: int) -> tuple[Path, list[ExtractedFrame]]:
temporary = Path(tempfile.mkdtemp(prefix="imagefind-frames-"))
output = temporary / "frame-%08d.jpg"
expression = "isnan(prev_selected_t)+gte(t-prev_selected_t\\,8)+gt(scene\\,0.30)*gte(t-prev_selected_t\\,1)"
command = [
self.ffmpeg_path(),
"-hide_banner",
"-loglevel",
"info",
"-threads",
str(self.settings.ai_cpu_threads),
]
command.extend(media.ffmpeg_args())
command.extend(
[
"-an",
"-sn",
"-vf",
f"scale='min(640,iw)':-2,select='{expression}',showinfo",
"-fps_mode",
"vfr",
"-q:v",
"4",
"-threads",
str(self.settings.ai_cpu_threads),
str(output),
]
)
result = self._run(command)
times = [int(float(value) * 1000) for value in _PTS.findall(result.stderr.decode(errors="replace"))]
files = sorted(temporary.glob("frame-*.jpg"))
if len(times) != len(files):
times = [min(index * 8000, max(0, duration_ms - 1)) for index in range(len(files))]
frames = []
for index, (path, timestamp) in enumerate(zip(files, times, strict=True)):
next_time = times[index + 1] if index + 1 < len(times) else min(duration_ms, timestamp + 8000)
frames.append(ExtractedFrame(path, timestamp, timestamp, max(timestamp + 1, next_time)))
return temporary, frames
def extract_embedded_subtitles(self, media: MediaInput, probe: dict) -> list[SubtitleCue]:
cues: list[SubtitleCue] = []
for stream in probe["raw"].get("streams", []):
if stream.get("codec_type") != "subtitle" or stream.get("codec_name") not in TEXT_SUBTITLE_CODECS:
continue
command = [self.ffmpeg_path(), "-v", "error"]
command.extend(media.ffmpeg_args())
command.extend(["-map", f"0:{stream['index']}", "-f", "webvtt", "pipe:1"])
try:
result = self._run(command)
except RuntimeError:
continue
cues.extend(parse_subtitles(result.stdout.decode("utf-8", errors="replace"), "vtt"))
return cues
def sidecar_subtitles(self, video: dict) -> list[SubtitleCue]:
if video.get("source_kind") != "local":
return []
path = Path(video["location"])
cues: list[SubtitleCue] = []
for suffix in (".srt", ".vtt", ".ass", ".ssa"):
for candidate in path.parent.glob(path.stem + "*" + suffix):
try:
content = candidate.read_text(encoding="utf-8-sig", errors="replace")
cues.extend(parse_subtitles(content, suffix.lstrip(".")))
except OSError:
continue
return cues
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import re
from pathlib import Path
from .database import utcnow
_CATALOG_PATTERNS = (
re.compile(r"(?i)\b(FC2[-_ ]?PPV[-_ ]?\d{5,9})\b"),
re.compile(r"(?i)\b([A-Z]{2,10}[-_ ]?\d{2,7})\b"),
)
def infer_catalog_code(filename: str) -> str | None:
stem = Path(filename).stem
for pattern in _CATALOG_PATTERNS:
match = pattern.search(stem)
if match:
value = re.sub(r"[-_ ]+", "-", match.group(1).upper())
if not value.isdigit():
return value
return None
def ensure_inferred_metadata(conn, video_id: str, filename: str) -> None:
code = infer_catalog_code(filename)
if not code:
return
conn.execute(
"INSERT INTO video_metadata(video_id,catalog_code,updated_at) VALUES(?,?,?) "
"ON CONFLICT(video_id) DO UPDATE SET catalog_code=coalesce(video_metadata.catalog_code,excluded.catalog_code)",
(video_id, code, utcnow()),
)
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import json
from pathlib import Path
from xml.etree import ElementTree
def has_openvino_ir(root: Path) -> bool:
"""Return true only for a matching OpenVINO XML/BIN pair."""
return any(xml.with_suffix(".bin").is_file() for xml in root.rglob("openvino*.xml"))
def validate_openvino_ir_directory(root: Path, label: str) -> None:
"""Validate the exact persisted IR directory used for inference.
Recursive IR discovery is useful for model status, but the runtime loader
must receive one unambiguous XML/BIN pair plus the Transformers config that
Optimum inspects before compiling the graph.
"""
required = ("openvino_model.xml", "openvino_model.bin", "config.json")
missing = [name for name in required if not (root / name).is_file()]
if missing:
raise ValueError(f"{label} OpenVINO 模型不完整,缺少:{', '.join(missing)}")
def validate_sentence_transformer_root(root: Path, label: str = "画面语义") -> None:
"""Validate a local SentenceTransformer snapshot without contacting Hugging Face."""
modules_path = root / "modules.json"
if not modules_path.is_file():
raise ValueError(f"{label}模型不完整,缺少 modules.json")
try:
modules = json.loads(modules_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError(f"{label}模型的 modules.json 无效") from exc
if not isinstance(modules, list) or not modules:
raise ValueError(f"{label}模型的 modules.json 无效")
for module in modules:
if not isinstance(module, dict):
raise ValueError(f"{label}模型的 modules.json 无效")
relative = module.get("path")
if relative and not (root / str(relative)).exists():
raise ValueError(f"{label}模型不完整,缺少模块 {relative}")
def sentence_transformer_module_path(root: Path, module_name: str) -> Path:
"""Resolve a module directory from a local SentenceTransformer snapshot."""
validate_sentence_transformer_root(root)
try:
modules = json.loads((root / "modules.json").read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("画面语义模型的 modules.json 无效") from exc
for module in modules:
module_type = str(module.get("type") or "").rsplit(".", 1)[-1]
if module_type != module_name:
continue
relative = str(module.get("path") or "")
path = root / relative if relative else root
if not (path / "config.json").is_file():
raise ValueError(f"画面语义 {module_name} 模块缺少 config.json")
return path
raise ValueError(f"画面语义模型缺少 {module_name} 模块")
def validate_face_model_root(root: Path) -> None:
"""Reject missing/truncated IR files and HTTP error pages returned with status 200."""
required = ("detector.xml", "detector.bin", "reidentification.xml", "reidentification.bin")
missing = [name for name in required if not (root / name).is_file()]
if missing:
raise ValueError(f"人物模型不完整,缺少:{', '.join(missing)}")
for name in ("detector.xml", "reidentification.xml"):
validate_face_model_file(root / name)
for name in ("detector.bin", "reidentification.bin"):
validate_face_model_file(root / name)
def validate_face_model_file(
path: Path,
*,
expected_suffix: str | None = None,
display_name: str | None = None,
) -> None:
name = display_name or path.name
suffix = expected_suffix or path.suffix
if suffix == ".xml":
try:
document = ElementTree.parse(path)
except (OSError, ElementTree.ParseError) as exc:
raise ValueError(f"人物模型文件 {name} 不是有效的 OpenVINO XML") from exc
root_tag = document.getroot()
if root_tag.tag.rsplit("}", 1)[-1] != "net" or root_tag.find("layers") is None:
raise ValueError(f"人物模型文件 {name} 不是有效的 OpenVINO IR")
return
if suffix == ".bin":
try:
size = path.stat().st_size
except OSError as exc:
raise ValueError(f"人物模型文件 {name} 无法读取") from exc
if size < 64 * 1024:
raise ValueError(f"人物模型文件 {name} 过小,下载内容可能是错误页面")
return
raise ValueError(f"无法识别的人物模型文件:{name}")
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from textwrap import dedent
def offline_helper_script() -> str:
"""Return the self-contained helper served to an internet-connected computer."""
return dedent(
r'''#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import io
import json
import shutil
import tarfile
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
IMAGE_REPO = "sentence-transformers/clip-ViT-B-32"
TEXT_REPO = "sentence-transformers/clip-ViT-B-32-multilingual-v1"
AUDIO_REPO = "OpenVINO/whisper-small-fp16-ov"
RAPID_ROOT = (
"https://raw.githubusercontent.com/RapidAI/RapidOCR/main/python/rapidocr_onnxruntime/"
"rapidocr_onnxruntime/models"
)
ZOO_ROOT = "https://storage.openvinotoolkit.org/repositories/open_model_zoo/2022.3/models_bin/1"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def download(url: str, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
partial = destination.with_name(destination.name + ".part")
existing = partial.stat().st_size if partial.exists() else 0
request = urllib.request.Request(url, headers={"Range": f"bytes={existing}-"} if existing else {})
with urllib.request.urlopen(request, timeout=60) as response:
append = existing > 0 and getattr(response, "status", 200) == 206
with partial.open("ab" if append else "wb") as output:
shutil.copyfileobj(response, output, 1024 * 1024)
if destination.suffix == ".xml":
prefix = partial.read_bytes()[:512].lstrip().lower()
if prefix.startswith((b"<!doctype html", b"<html")) or b"<net " not in partial.read_bytes()[:4096]:
partial.unlink(missing_ok=True)
raise RuntimeError(f"人物模型下载地址返回了无效 XML{url}")
if destination.suffix == ".bin" and partial.stat().st_size < 64 * 1024:
partial.unlink(missing_ok=True)
raise RuntimeError(f"人物模型权重过小,下载地址可能返回了错误页面:{url}")
partial.replace(destination)
def main() -> None:
parser = argparse.ArgumentParser(
description="下载 ImageFind 的画面、OCR、人物与音频模型,并生成一个可上传的离线包"
)
parser.add_argument("--hf-endpoint", default="https://hf-mirror.com", help="Hugging Face 官方站或镜像")
parser.add_argument("--cache-dir", type=Path, default=Path(".imagefind-model-cache"), help="断点缓存目录")
parser.add_argument("--output", type=Path, help="输出 .tar.gz;默认包含当前日期")
args = parser.parse_args()
endpoint = args.hf_endpoint.rstrip("/")
if not endpoint.startswith(("http://", "https://")) or "@" in endpoint.split("://", 1)[-1].split("/", 1)[0]:
parser.error("--hf-endpoint 必须是无内嵌账号密码的 HTTP/HTTPS 地址")
try:
from huggingface_hub import snapshot_download
except ImportError as exc:
raise SystemExit("缺少模型准备依赖;请先运行: python -m pip install huggingface_hub") from exc
cache = args.cache_dir.resolve()
stage = cache / "stage"
image_dir = stage / "visual" / "image"
text_dir = stage / "visual" / "text"
snapshot_download(IMAGE_REPO, endpoint=endpoint, cache_dir=cache / "hub", local_dir=image_dir)
snapshot_download(TEXT_REPO, endpoint=endpoint, cache_dir=cache / "hub", local_dir=text_dir)
audio_dir = stage / "audio"
snapshot_download(AUDIO_REPO, endpoint=endpoint, cache_dir=cache / "hub", local_dir=audio_dir)
rapid_files = {
"det.onnx": "ch_PP-OCRv4_det_infer.onnx",
"rec.onnx": "ch_PP-OCRv4_rec_infer.onnx",
"cls.onnx": "ch_ppocr_mobile_v2.0_cls_infer.onnx",
}
for local_name, remote_name in rapid_files.items():
download(f"{RAPID_ROOT}/{remote_name}", stage / "ocr" / local_name)
face_files = {
"detector.xml": "face-detection-retail-0004/FP16/face-detection-retail-0004.xml",
"detector.bin": "face-detection-retail-0004/FP16/face-detection-retail-0004.bin",
"reidentification.xml": (
"face-reidentification-retail-0095/FP16/face-reidentification-retail-0095.xml"
),
"reidentification.bin": (
"face-reidentification-retail-0095/FP16/face-reidentification-retail-0095.bin"
),
}
for local_name, remote_name in face_files.items():
download(f"{ZOO_ROOT}/{remote_name}", stage / "faces" / local_name)
created = datetime.now(UTC)
files = sorted(path for path in stage.rglob("*") if path.is_file() and path.name != "manifest.json")
sources = {
"visual_image": f"https://huggingface.co/{IMAGE_REPO}",
"visual_text": f"https://huggingface.co/{TEXT_REPO}",
"ocr": RAPID_ROOT,
"faces": ZOO_ROOT,
"audio": f"https://huggingface.co/{AUDIO_REPO}",
}
manifest = {
"format_version": 2,
"version": f"offline-{created.date().isoformat()}",
"created_at": created.isoformat(),
"source": "imagefind-offline-helper",
"sources": sources,
"components": {
"visual": {
"version": f"{IMAGE_REPO}+{TEXT_REPO}",
"sources": [sources["visual_image"], sources["visual_text"]],
},
"ocr": {"version": "RapidOCR-PP-OCRv4", "source": sources["ocr"]},
"faces": {"version": "open-model-zoo-2022.3", "source": sources["faces"]},
"audio": {"version": AUDIO_REPO, "source": sources["audio"]},
},
"files": {path.relative_to(stage).as_posix(): sha256(path) for path in files},
}
manifest_bytes = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode()
output = (args.output or Path(f"imagefind-models-{created.date().isoformat()}.tar.gz")).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(output, "w:gz") as archive:
for path in files:
archive.add(path, path.relative_to(stage).as_posix(), recursive=False)
info = tarfile.TarInfo("manifest.json")
info.size = len(manifest_bytes)
info.mode = 0o644
info.mtime = int(created.timestamp())
archive.addfile(info, io.BytesIO(manifest_bytes))
print(f"已生成 {output}")
print(f"SHA-256 {sha256(output)}")
if __name__ == "__main__":
main()
'''
)
+655
View File
@@ -0,0 +1,655 @@
from __future__ import annotations
import hashlib
import json
import os
import shutil
import threading
import time
import uuid
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from .config import Settings
from .database import Database, utcnow
from .remote import AlistClient, openlist_endpoint_from_webdav_url, safe_relative_path
from .security import SecretStore
from .sources import SourceItem, SourceService, _fingerprint
def _remote_join(*parts: str) -> str:
cleaned: list[str] = []
for part in parts:
value = str(part or "").strip()
# PurePosixPath.parent renders a root parent as ".". It is a
# perfectly valid internal representation of the OpenList root, but
# must not be passed to safe_relative_path (where dot segments are
# deliberately rejected).
if not value or value == ".":
continue
cleaned.append(safe_relative_path(value))
return "/".join(value for value in cleaned if value)
def _remote_parent(path: str) -> str:
parent = str(PurePosixPath(safe_relative_path(path, allow_empty=False)).parent)
return "" if parent == "." else safe_relative_path(parent)
def _object_size(value: dict | None) -> int | None:
if value is None or bool(value.get("is_dir")):
return None
try:
return int(value.get("size") or 0)
except (TypeError, ValueError):
return None
def normalize_task_state(value: object) -> int:
"""Normalize AList v3 string states and OpenList v4 integer states."""
names = {
"pending": 0,
"running": 1,
"succeeded": 2,
"finished": 2,
"canceling": 3,
"canceled": 4,
"cancelled": 4,
"errored": 5,
"failing": 6,
"failed": 7,
"waiting_retry": 8,
"before_retry": 9,
}
text = str(value if value is not None else "").strip().lower()
if text in names:
return names[text]
try:
return int(text)
except ValueError:
try:
return int(float(text))
except ValueError:
return -1
@dataclass(slots=True)
class OpenListNativeConfig:
endpoint: str
username: str
password: str
verify_tls: bool
local_staging_path: Path
source_path: str
target_path: str
encrypted: bool
class OpenListNativeService:
"""Coordinate OpenList server-side copy without streaming media bytes."""
def __init__(
self,
db: Database,
settings: Settings,
sources: SourceService,
secrets: SecretStore,
):
self.db = db
self.settings = settings
self.sources = sources
self.secrets = secrets
self._clients_guard = threading.Lock()
self._clients: dict[str, tuple[str, AlistClient]] = {}
@staticmethod
def _endpoint_and_target(config: dict) -> tuple[str, str]:
endpoint = str(config.get("openlist_endpoint") or "").strip().rstrip("/")
target = str(config.get("openlist_target_path") or config.get("root_path") or "")
if not endpoint:
inferred = openlist_endpoint_from_webdav_url(str(config.get("base_url") or ""))
if inferred:
endpoint, inferred_target = inferred
target = target or inferred_target
else:
endpoint = str(config.get("base_url") or "").strip().rstrip("/")
return endpoint, safe_relative_path(target)
def configuration(self, source: dict, *, require_enabled: bool = True) -> OpenListNativeConfig:
config = source["config"]
if require_enabled and config.get("storage_backend") != "openlist_native":
raise ValueError("媒体库未启用 OpenList 原生后台复制")
endpoint, target_path = self._endpoint_and_target(config)
username = str(config.get("username") or "").strip()
password = str(source.get("secrets", {}).get("password") or "")
local_value = str(config.get("openlist_local_staging_path") or "").strip()
# An OpenList local storage can be mounted at its root. Empty here is
# therefore meaningful (the source file is addressed as
# ``/<upload-id>/...``), unlike a missing local staging directory.
source_path = safe_relative_path(str(config.get("openlist_source_path") or ""))
if not endpoint.startswith(("http://", "https://")):
raise ValueError("OpenList API 地址无效")
if not username or not password:
raise ValueError("OpenList 用户名或密码为空")
local_staging = Path(local_value)
if not local_value or not local_staging.is_absolute():
raise ValueError("ImageFind 本地中转目录必须是绝对路径")
if local_staging == Path("/") or local_staging.is_symlink():
raise ValueError("ImageFind 本地中转目录不能是根目录或符号链接")
return OpenListNativeConfig(
endpoint=endpoint,
username=username,
password=password,
verify_tls=bool(config.get("verify_tls", True)),
local_staging_path=local_staging,
source_path=source_path,
target_path=target_path,
encrypted=config.get("mode") == "encrypted",
)
def enabled(self, source_id: str) -> bool:
try:
return self.sources.get(source_id)["config"].get("storage_backend") == "openlist_native"
except KeyError:
return False
@staticmethod
def _fingerprint(config: OpenListNativeConfig) -> str:
value = "\0".join(
(
config.endpoint,
config.username,
hashlib.sha256(config.password.encode()).hexdigest(),
str(config.verify_tls),
)
)
return hashlib.sha256(value.encode()).hexdigest()
def client(self, source_id: str, source: dict | None = None) -> AlistClient:
source = source or self.sources.get(source_id)
config = self.configuration(source)
fingerprint = self._fingerprint(config)
with self._clients_guard:
cached = self._clients.get(source_id)
if cached and cached[0] == fingerprint:
return cached[1]
if cached:
cached[1].close()
client = AlistClient(
config.endpoint,
config.username,
config.password,
verify_tls=config.verify_tls,
timeout=self.settings.remote_timeout_seconds,
)
self._clients[source_id] = (fingerprint, client)
return client
def close(self) -> None:
with self._clients_guard:
clients = list(self._clients.values())
self._clients.clear()
for _, client in clients:
client.close()
def _candidate(self, source_id: str, values: dict) -> dict:
source = self.sources.get(source_id)
config = dict(source["config"])
secret = dict(source.get("secrets", {}))
inferred = openlist_endpoint_from_webdav_url(str(config.get("base_url") or ""))
endpoint = values.get("endpoint") or config.get("openlist_endpoint") or (inferred[0] if inferred else None)
target_path = values.get("target_path")
if target_path is None:
target_path = (
config.get("openlist_target_path")
or config.get("root_path")
or (inferred[1] if inferred else "")
)
config.update(
{
"openlist_endpoint": str(endpoint or "").rstrip("/"),
"openlist_local_staging_path": str(values.get("local_staging_path") or ""),
"openlist_source_path": str(values.get("source_path") or ""),
"openlist_target_path": safe_relative_path(str(target_path or "")),
"username": str(values.get("username") or config.get("username") or ""),
"verify_tls": bool(values.get("verify_tls", config.get("verify_tls", True))),
}
)
if values.get("password"):
secret["password"] = str(values["password"])
return {**source, "config": config, "secrets": secret}
def probe(self, source_id: str, values: dict) -> dict:
candidate = self._candidate(source_id, values)
config = self.configuration(candidate, require_enabled=False)
config.local_staging_path.mkdir(parents=True, exist_ok=True)
probe_id = uuid.uuid4().hex
local_directory = config.local_staging_path / f".imagefind-probe-{probe_id}"
local_file = local_directory / "probe.bin"
target_directory = _remote_join(config.target_path, f".imagefind-probe-{probe_id}")
target_file = _remote_join(target_directory, "probe.bin")
source_file = _remote_join(config.source_path, local_directory.name, "probe.bin")
payload = b"imagefind-openlist-native-copy-probe"
client = AlistClient(
config.endpoint,
config.username,
config.password,
verify_tls=config.verify_tls,
timeout=self.settings.remote_timeout_seconds,
)
task_id = ""
try:
client.probe()
local_directory.mkdir(mode=0o700)
local_file.write_bytes(payload)
visible = None
for delay in (0, 0.5, 1, 2, 4):
if delay:
time.sleep(delay)
visible = client.object_info(source_file)
if _object_size(visible) == len(payload):
break
if _object_size(visible) != len(payload):
raise RuntimeError("OpenList 无法看到 ImageFind 本地中转文件,请检查本地存储挂载映射")
client.ensure_directory(target_directory)
tasks = client.copy_file(source_file, target_file)
task_id = tasks[0] if tasks else ""
deadline = time.monotonic() + 120
finished = not task_id
while task_id and time.monotonic() < deadline:
task = client.copy_task_info(task_id)
if task is None:
break
state = normalize_task_state(task.get("state"))
if state == 2:
finished = True
break
if state in {4, 5, 6, 7}:
raise RuntimeError("OpenList 后台复制测试失败")
time.sleep(1)
if task_id and not finished:
raise RuntimeError("OpenList 后台复制测试超时")
result = client.object_info(target_file)
if _object_size(result) != len(payload):
raise RuntimeError("OpenList 后台复制完成后目标长度不一致")
return {
"ok": True,
"message": "OpenList 登录、本地挂载、后台复制和目标校验均正常",
"target_path": config.target_path,
}
finally:
if task_id:
try:
client.cancel_copy_task(task_id)
except Exception:
pass
try:
client.remove(target_directory)
except Exception:
pass
client.close()
shutil.rmtree(local_directory, ignore_errors=True)
def configure(self, source_id: str, values: dict) -> dict:
enabled = bool(values.get("enabled", True))
source = self.sources.get(source_id)
with self.db.read() as conn:
active = conn.execute(
"SELECT count(*) FROM uploads WHERE source_id=? "
"AND status IN ('receiving','queued','transferring','indexing')",
(source_id,),
).fetchone()[0]
if active:
raise ValueError("该媒体库仍有未完成上传,完成或取消后才能切换 OpenList 模式")
if not enabled:
config = dict(source["config"])
config["storage_backend"] = "legacy_webdav"
with self.db.transaction() as conn:
conn.execute(
"UPDATE sources SET config_json=?,updated_at=? WHERE id=?",
(json.dumps(config), utcnow(), source_id),
)
with self._clients_guard:
cached = self._clients.pop(source_id, None)
if cached:
cached[1].close()
return {"enabled": False, "storage_backend": "legacy_webdav"}
result = self.probe(source_id, values)
candidate = self._candidate(source_id, values)
config = dict(candidate["config"])
config.update(
{
"storage_backend": "openlist_native",
"writable": True,
"openlist_tested_at": utcnow(),
"openlist_test_message": result["message"],
}
)
secret_blob = self.secrets.encrypt_json(candidate["secrets"])
with self.db.transaction() as conn:
conn.execute(
"UPDATE sources SET config_json=?,secret_blob=?,updated_at=? WHERE id=?",
(json.dumps(config), secret_blob, utcnow(), source_id),
)
return {
"enabled": True,
"storage_backend": "openlist_native",
"tested_at": config["openlist_tested_at"],
"message": result["message"],
}
@staticmethod
def _share_staged_file(job_root: Path, local_file: Path) -> None:
"""Grant the separately packaged OpenList service read access.
fnOS applications run under different service users. rclone creates
crypt output with private modes, so OpenList can stat it through its
local storage driver but its background worker cannot open it. The
staging tree is short-lived (and encrypted in crypt mode); expose only
this upload subtree and remove it after promotion.
"""
root = job_root.resolve(strict=True)
file_path = local_file.resolve(strict=True)
file_path.relative_to(root)
if local_file.is_symlink():
raise OSError("OpenList 本地中转文件不能是符号链接")
current = file_path.parent
directories: list[Path] = []
while True:
directories.append(current)
if current == root:
break
current = current.parent
for directory in reversed(directories):
if directory.is_symlink():
raise OSError("OpenList 本地中转目录不能包含符号链接")
directory.chmod(0o755)
file_path.chmod(0o644)
def prepare(self, source_id: str, upload_id: str, target_key: str, input_path: Path) -> dict:
source = self.sources.get(source_id)
config = self.configuration(source)
job_root = config.local_staging_path / upload_id
job_root.mkdir(parents=True, exist_ok=True)
os.chmod(job_root, 0o700)
target_key = safe_relative_path(target_key, allow_empty=False)
if config.encrypted:
encrypted_root = job_root / "encrypted"
encrypted_file = self.sources.rclone.encrypt_to_local(source, input_path, target_key, encrypted_root)
input_path.unlink(missing_ok=True)
physical_relative = encrypted_file.relative_to(encrypted_root).as_posix()
local_file = encrypted_file
source_path = _remote_join(config.source_path, upload_id, "encrypted", physical_relative)
target_path = _remote_join(config.target_path, physical_relative)
else:
direct_root = job_root / "direct"
direct_root.mkdir(parents=True, exist_ok=True)
local_file = direct_root / PurePosixPath(target_key).name
if input_path.resolve() != local_file.resolve():
os.replace(input_path, local_file)
physical_relative = target_key
source_path = _remote_join(config.source_path, upload_id, "direct", local_file.name)
target_path = _remote_join(config.target_path, target_key)
staged_path = _remote_join(
config.target_path,
f".imagefind-staging-{upload_id}",
PurePosixPath(target_path).name,
)
self._share_staged_file(job_root, local_file)
return {
"local_path": str(local_file),
"size_bytes": local_file.stat().st_size,
"source_path": source_path,
"staged_path": staged_path,
"target_path": target_path,
"physical_relative": physical_relative,
}
def submit(self, source_id: str, source_path: str, staged_path: str, expected_size: int) -> str:
client = self.client(source_id)
source = client.object_info(source_path)
if _object_size(source) != expected_size:
raise RuntimeError("OpenList 尚未看到完整的本地中转文件")
client.ensure_directory(_remote_parent(staged_path))
staged = client.object_info(staged_path)
if _object_size(staged) == expected_size:
return ""
if staged is not None:
client.remove(staged_path)
tasks = client.copy_file(source_path, staged_path)
return tasks[0] if tasks else ""
def task_info(self, source_id: str, task_id: str) -> dict | None:
return self.client(source_id).copy_task_info(task_id)
def verify(self, source_id: str, path: str, expected_size: int) -> bool:
return _object_size(self.client(source_id).object_info(path)) == expected_size
def target_exists_for_key(self, source_id: str, key: str) -> bool | None:
"""Return whether a native upload still exists at its physical target.
``None`` means the logical key was not created by the native upload
pipeline, so callers must keep their normal conservative behavior.
This is used to reconcile a WebDAV MOVE whose response and decrypted
directory cache are ambiguous even though OpenList already moved the
physical object away from its original path.
"""
logical_key = safe_relative_path(key, allow_empty=False)
if not self.enabled(source_id):
return None
with self.db.read() as conn:
row = conn.execute(
"SELECT physical_path AS external_target_path FROM videos WHERE source_id=? AND source_key=? "
"AND storage_backend='openlist_native' AND physical_path IS NOT NULL "
"UNION ALL SELECT external_target_path FROM uploads WHERE source_id=? AND target_key=? "
"AND storage_backend='openlist_native' AND external_target_path IS NOT NULL "
"ORDER BY external_target_path NULLS LAST LIMIT 1",
(source_id, logical_key, source_id, logical_key),
).fetchone()
if not row or not row["external_target_path"]:
return None
return self.client(source_id).object_info(str(row["external_target_path"])) is not None
def physical_object_for_key(self, source_id: str, key: str) -> tuple[str, int, int] | None:
"""Resolve a durable native object mapping for deletion and restore."""
logical_key = safe_relative_path(key, allow_empty=False)
if not self.enabled(source_id):
return None
with self.db.read() as conn:
row = conn.execute(
"SELECT physical_path,physical_size_bytes,size_bytes FROM videos WHERE source_id=? AND source_key=? "
"AND storage_backend='openlist_native' AND physical_path IS NOT NULL",
(source_id, logical_key),
).fetchone()
if row is None:
row = conn.execute(
"SELECT external_target_path AS physical_path,"
"external_size_bytes AS physical_size_bytes,size_bytes "
"FROM uploads WHERE source_id=? AND target_key=? AND storage_backend='openlist_native' "
"AND external_target_path IS NOT NULL ORDER BY updated_at DESC LIMIT 1",
(source_id, logical_key),
).fetchone()
if row is None or not row["physical_path"]:
return None
return (
str(row["physical_path"]),
int(row["physical_size_bytes"] or 0),
int(row["size_bytes"] or 0),
)
@staticmethod
def _verified_object_size(client: AlistClient, path: str) -> int | None:
return _object_size(client.object_info(path))
def trash_object(
self,
source_id: str,
trash_id: str,
physical_path: str,
expected_size: int,
) -> str:
"""Move one native object into an isolated recoverable directory."""
source = self.sources.get(source_id)
config = self.configuration(source)
original = safe_relative_path(physical_path, allow_empty=False)
trash_directory = _remote_join(config.target_path, ".imagefind-native-trash", trash_id)
target = _remote_join(trash_directory, PurePosixPath(original).name)
client = self.client(source_id, source)
if client.object_info(target) is not None:
raise RuntimeError("OpenList 原生回收站目标已存在")
client.ensure_directory(trash_directory)
try:
client.move_file(original, trash_directory, overwrite=False)
except Exception:
# OpenList can finish a provider-side move after the control-plane
# request times out. Resolve that ambiguous response from object
# state before deciding whether compensation is required.
if client.object_info(original) is not None or self._verified_object_size(client, target) != expected_size:
raise
if client.object_info(original) is not None:
raise RuntimeError("OpenList 原生回收站移动后源对象仍然存在")
if self._verified_object_size(client, target) != expected_size:
raise RuntimeError("OpenList 原生回收站对象长度不一致")
if config.encrypted:
self.sources.rclone.stop(source_id)
return target
def restore_object(
self,
source_id: str,
physical_trash_path: str,
physical_original_path: str,
expected_size: int,
) -> None:
source = self.sources.get(source_id)
config = self.configuration(source)
trash_path = safe_relative_path(physical_trash_path, allow_empty=False)
original = safe_relative_path(physical_original_path, allow_empty=False)
client = self.client(source_id, source)
if client.object_info(original) is not None:
raise FileExistsError("原物理路径已被占用,回收站对象保持不变")
if self._verified_object_size(client, trash_path) != expected_size:
raise RuntimeError("OpenList 回收站对象不存在或长度不一致")
client.ensure_directory(_remote_parent(original))
try:
client.move_file(trash_path, _remote_parent(original), overwrite=False)
except Exception:
if (
client.object_info(trash_path) is not None
or self._verified_object_size(client, original) != expected_size
):
raise
if client.object_info(trash_path) is not None or self._verified_object_size(client, original) != expected_size:
raise RuntimeError("OpenList 回收站恢复后对象状态不一致")
try:
client.remove(str(PurePosixPath(trash_path).parent))
except Exception:
pass
if config.encrypted:
self.sources.rclone.stop(source_id)
def purge_object(self, source_id: str, physical_trash_path: str) -> None:
source = self.sources.get(source_id)
config = self.configuration(source)
path = safe_relative_path(physical_trash_path, allow_empty=False)
client = self.client(source_id, source)
client.remove(path)
if client.object_info(path) is not None:
raise RuntimeError("OpenList 原生回收站对象永久删除后仍然存在")
try:
client.remove(str(PurePosixPath(path).parent))
except Exception:
pass
if config.encrypted:
self.sources.rclone.stop(source_id)
def catalog_item(self, source_id: str, key: str, upload: dict) -> SourceItem:
"""Build a logical item from the object promoted by OpenList.
Native uploads keep the OpenList object path separate from the
user-visible ``videos.source_key``. This is essential for encrypted
libraries, where rclone-crypt turns the filename into an opaque value;
a WebDAV PROPFIND of ``key`` would therefore return 404.
"""
physical_path = str(upload.get("external_target_path") or "")
if not physical_path:
raise RuntimeError("OpenList 上传缺少最终对象路径")
expected_external = int(upload.get("external_size_bytes") or upload.get("size_bytes") or 0)
obj = self.client(source_id).object_info(physical_path)
actual_external = _object_size(obj)
if obj is None or actual_external is None or actual_external != expected_external:
raise RuntimeError("OpenList 最终对象不存在或长度不一致")
source = self.sources.get(source_id)
logical_key = safe_relative_path(key, allow_empty=False)
logical_size = int(upload.get("size_bytes") or 0)
content_sha256 = str(upload.get("content_sha256") or "")
object_identity = (
obj.get("sign")
or obj.get("hash")
or obj.get("etag")
or obj.get("modified")
or obj.get("updated_at")
or actual_external
)
fingerprint = _fingerprint(content_sha256 or logical_key, object_identity, logical_size)
# OpenList promotes the encrypted object outside rclone's process.
# Drop an existing crypt WebDAV instance before creating the logical
# record so its VFS directory cache cannot retain a pre-promotion 404.
# A new instance is started lazily by remote_access below.
if source["config"].get("mode") == "encrypted":
self.sources.rclone.stop(source_id)
location, _, _, _ = self.sources.remote_access(source_id, logical_key)
return SourceItem(
key=logical_key,
display_name=PurePosixPath(logical_key).name,
location=location,
size_bytes=logical_size,
modified_at=str(obj.get("modified") or obj.get("updated_at") or "") or None,
etag=str(obj.get("sign") or obj.get("etag") or "") or None,
fingerprint=fingerprint,
)
def promote(self, source_id: str, upload_id: str, staged_path: str, target_path: str, expected_size: int) -> None:
client = self.client(source_id)
target = client.object_info(target_path)
backup_path = ""
if target is not None:
backup_name = f".imagefind-old-{upload_id[:8]}-{PurePosixPath(target_path).name}"
client.rename(target_path, backup_name)
backup_path = _remote_join(_remote_parent(target_path), backup_name)
try:
client.ensure_directory(_remote_parent(target_path))
client.move_file(staged_path, _remote_parent(target_path), overwrite=False)
if not self.verify(source_id, target_path, expected_size):
raise RuntimeError("OpenList 暂存文件提升后最终目标长度不一致")
if backup_path:
client.remove(backup_path)
except Exception:
if backup_path and client.object_info(target_path) is None and client.object_info(backup_path) is not None:
client.rename(backup_path, PurePosixPath(target_path).name)
raise
def cleanup(self, source_id: str, upload_id: str, staged_path: str | None = None) -> None:
source = self.sources.get(source_id)
config = self.configuration(source)
if staged_path:
stage_directory = str(PurePosixPath(staged_path).parent)
try:
self.client(source_id).remove(stage_directory)
except Exception:
pass
job_root = config.local_staging_path / upload_id
shutil.rmtree(job_root, ignore_errors=True)
def cancel(self, source_id: str, upload_id: str, task_id: str | None, staged_path: str | None) -> None:
try:
if task_id:
self.client(source_id).cancel_copy_task(task_id)
finally:
self.cleanup(source_id, upload_id, staged_path)
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
from .config import Settings
from .database import Database
from .media import MediaService
class PreviewService:
def __init__(self, db: Database, settings: Settings, media: MediaService):
self.db = db
self.settings = settings
self.media = media
self._encoders: set[str] | None = None
self._lock = threading.RLock()
self._processes: dict[str, tuple[subprocess.Popen, object]] = {}
def _video(self, video_id: str) -> dict:
with self.db.read() as conn:
row = conn.execute(
"SELECT v.*,s.kind AS source_kind FROM videos v JOIN sources s ON s.id=v.source_id "
"WHERE v.id=? AND v.available=1",
(video_id,),
).fetchone()
if not row:
raise KeyError(video_id)
return dict(row)
def _available_encoders(self) -> set[str]:
if self._encoders is None:
try:
result = subprocess.run(
[self.media.ffmpeg_path(), "-hide_banner", "-encoders"],
check=True,
capture_output=True,
text=True,
)
self._encoders = {
line.split()[1]
for line in result.stdout.splitlines()
if line.startswith(" V") and len(line.split()) > 1
}
except (OSError, subprocess.CalledProcessError):
self._encoders = set()
return self._encoders
def _video_args(self) -> list[str]:
encoders = self._available_encoders()
if Path("/dev/dri/renderD128").exists() and "h264_vaapi" in encoders:
return [
"-vaapi_device",
"/dev/dri/renderD128",
"-vf",
"format=nv12,hwupload,scale_vaapi=w='min(1280,iw)':h=-2",
"-c:v",
"h264_vaapi",
"-b:v",
"2500k",
]
if "libopenh264" in encoders:
return ["-vf", "scale='min(1280,iw)':-2", "-c:v", "libopenh264", "-b:v", "2500k"]
if "libx264" in encoders:
return [
"-vf",
"scale='min(1280,iw)':-2",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
]
raise RuntimeError("FFmpeg 缺少 h264_vaapi、libopenh264 或 libx264 编码器")
def _evict(self) -> None:
active = set(self._processes)
directories = [
path for path in self.settings.preview_dir.iterdir() if path.is_dir() and path.name not in active
]
entries = []
total = 0
for directory in directories:
files = [path for path in directory.rglob("*") if path.is_file()]
size = sum(path.stat().st_size for path in files)
modified = max((path.stat().st_mtime for path in files), default=0)
entries.append((modified, size, directory))
total += size
limit = int(self.settings.preview_cache_gb * 1024**3)
for _, size, directory in sorted(entries):
if total <= limit:
break
shutil.rmtree(directory, ignore_errors=True)
total -= size
def prepare(self, video_id: str, start_ms: int) -> str:
video = self._video(video_id)
start_seconds = max(0, start_ms // 1000 - 5)
cache_key = hashlib.sha256(
f"{video_id}:{video['fingerprint']}:{start_seconds}:continuous-v1".encode()
).hexdigest()[:32]
destination = self.settings.preview_dir / cache_key
playlist = destination / "index.m3u8"
with self._lock:
active = self._processes.get(cache_key)
if active and active[0].poll() is None and playlist.is_file():
playlist.touch()
return cache_key
if active:
try:
active[1].close()
except OSError:
pass
self._processes.pop(cache_key, None)
if playlist.is_file() and "#EXT-X-ENDLIST" in playlist.read_text(encoding="utf-8", errors="ignore"):
playlist.touch()
return cache_key
shutil.rmtree(destination, ignore_errors=True)
destination.mkdir(parents=True)
media = self.media.input_for(video)
command = [self.media.ffmpeg_path(), "-hide_banner", "-loglevel", "error", "-y", "-ss", str(start_seconds)]
command.extend(media.ffmpeg_args())
command.extend(["-map", "0:v:0", "-map", "0:a:0?"])
command.extend(self._video_args())
command.extend(
[
"-c:a",
"aac",
"-b:a",
"128k",
"-f",
"hls",
"-hls_time",
"4",
"-hls_playlist_type",
"event",
"-hls_list_size",
"0",
"-hls_flags",
"independent_segments+temp_file",
"-hls_segment_filename",
str(destination / "segment-%05d.ts"),
str(destination / "index.m3u8"),
]
)
metadata = {
"video_id": video_id,
"source_start_ms": start_seconds * 1000,
"status": "transcoding",
}
(destination / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8")
log = (destination / "ffmpeg.log").open("wb")
try:
process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=log,
stderr=log,
preexec_fn=lambda: os.nice(10),
)
except FileNotFoundError as exc:
log.close()
raise RuntimeError("缺少 FFmpeg,无法生成兼容预览") from exc
with self._lock:
self._processes[cache_key] = (process, log)
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
first_segment = destination / "segment-00000.ts"
if playlist.is_file() and first_segment.is_file() and first_segment.stat().st_size:
break
if process.poll() is not None:
log.flush()
detail = (destination / "ffmpeg.log").read_text(errors="replace")[-2000:]
with self._lock:
self._processes.pop(cache_key, None)
log.close()
raise RuntimeError(f"预览转码失败:{detail}")
time.sleep(0.1)
else:
process.terminate()
with self._lock:
self._processes.pop(cache_key, None)
log.close()
raise RuntimeError("预览转码启动超时")
self._evict()
return cache_key
def close(self) -> None:
with self._lock:
entries = list(self._processes.values())
self._processes.clear()
for process, log in entries:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
try:
log.close()
except OSError:
pass
def remove_video(self, video_id: str) -> None:
"""Remove cached HLS previews belonging to a deleted video."""
with self._lock:
active = list(self._processes.items())
for cache_key, (process, log) in active:
metadata_path = self.settings.preview_dir / cache_key / "metadata.json"
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if metadata.get("video_id") != video_id:
continue
if process.poll() is None:
process.terminate()
with self._lock:
self._processes.pop(cache_key, None)
try:
log.close()
except OSError:
pass
shutil.rmtree(metadata_path.parent, ignore_errors=True)
for metadata_path in self.settings.preview_dir.glob("*/metadata.json"):
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if metadata.get("video_id") == video_id:
shutil.rmtree(metadata_path.parent, ignore_errors=True)
File diff suppressed because it is too large Load Diff
+365
View File
@@ -0,0 +1,365 @@
from __future__ import annotations
import hashlib
import json
import os
import shutil
import threading
import time
from dataclasses import dataclass
from pathlib import Path
import httpx
from .jobs import JobCancelled
@dataclass(slots=True)
class RemoteMediaLease:
path: Path
cache_key: str
_cache: RemoteMediaCache
_closed: bool = False
def close(self) -> None:
if self._closed:
return
self._closed = True
self._cache.release(self.cache_key)
def __enter__(self) -> Path:
return self.path
def __exit__(self, *_args) -> None:
self.close()
class RemoteMediaCache:
"""Materialize remote media once for background analysis.
Interactive playback deliberately bypasses this cache. A complete file is
shared by the visual and audio jobs, while partial downloads remain
resumable after an application restart.
"""
_CHUNK_BYTES = 1024 * 1024
_RETRY_DELAYS = (0.5, 1.0, 2.0)
def __init__(self, settings, sources, governor=None):
self.settings = settings
self.sources = sources
self.governor = governor
self.root = settings.remote_media_cache_dir
self.root.mkdir(parents=True, exist_ok=True)
os.chmod(self.root, 0o700)
self._guard = threading.RLock()
self._locks: dict[str, threading.Lock] = {}
self._pins: dict[str, int] = {}
self._usage_at = 0.0
self._usage: dict | None = None
self._remove_stale_partials()
@property
def limit_bytes(self) -> int:
return max(0, int(float(self.settings.remote_cache_gb) * 1024**3))
def _key(self, video: dict) -> str:
identity = "\0".join(
str(video.get(name) or "")
for name in ("source_id", "source_key", "fingerprint", "size_bytes")
)
return hashlib.sha256(identity.encode("utf-8")).hexdigest()
@staticmethod
def _suffix(video: dict) -> str:
suffix = Path(str(video.get("source_key") or "")).suffix.lower()
return suffix if 1 < len(suffix) <= 12 and suffix[1:].isalnum() else ".media"
def _paths(self, video: dict) -> tuple[str, Path, Path, Path]:
key = self._key(video)
data = self.root / f"{key}{self._suffix(video)}"
return key, data, self.root / f"{key}.part", self.root / f"{key}.json"
def _lock_for(self, key: str) -> threading.Lock:
with self._guard:
return self._locks.setdefault(key, threading.Lock())
def _invalidate_usage(self) -> None:
with self._guard:
self._usage_at = 0.0
self._usage = None
def _pin(self, key: str) -> None:
with self._guard:
self._pins[key] = self._pins.get(key, 0) + 1
def _unpin(self, key: str) -> None:
with self._guard:
count = self._pins.get(key, 0)
if count <= 1:
self._pins.pop(key, None)
else:
self._pins[key] = count - 1
@staticmethod
def _atomic_json(path: Path, value: dict) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
os.chmod(temporary, 0o600)
temporary.replace(path)
def _valid(self, data: Path, metadata: Path, video: dict) -> bool:
try:
record = json.loads(metadata.read_text(encoding="utf-8"))
expected = int(video.get("size_bytes") or 0)
return (
data.is_file()
and (not expected or data.stat().st_size == expected)
and record.get("source_id") == video.get("source_id")
and record.get("source_key") == video.get("source_key")
and record.get("fingerprint") == video.get("fingerprint")
)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return False
def _check_space(self, remaining: int) -> None:
self.cleanup(force=False, required_bytes=max(0, remaining))
available = shutil.disk_usage(self.settings.data_dir).free
reserve = int(float(self.settings.resource_disk_reserve_gb) * 1024**3)
if available - max(0, remaining) < reserve:
raise RuntimeError("远程媒体缓存空间不足,无法在保留磁盘安全空间的前提下完成分析")
def _download(
self,
video: dict,
part: Path,
*,
progress=None,
cancelled=None,
) -> None:
expected = int(video.get("size_bytes") or 0)
current = part.stat().st_size if part.is_file() else 0
if expected and current > expected:
part.unlink(missing_ok=True)
current = 0
self._check_space(max(0, expected - current))
opener = getattr(self.sources, "open_remote_access", None)
access = opener(video["source_id"], video["source_key"]) if opener is not None else None
if access is None:
url, username, password, verify_tls = self.sources.remote_access(
video["source_id"], video["source_key"]
)
else:
url, username, password, verify_tls = access.as_tuple()
timeout = httpx.Timeout(
connect=max(10.0, float(self.settings.remote_timeout_seconds)),
read=max(300.0, float(self.settings.remote_timeout_seconds)),
write=max(300.0, float(self.settings.remote_timeout_seconds)),
pool=30.0,
)
last_error: Exception | None = None
try:
for attempt in range(len(self._RETRY_DELAYS) + 1):
if cancelled and cancelled():
raise JobCancelled("任务已取消")
current = part.stat().st_size if part.is_file() else 0
headers = {"Range": f"bytes={current}-"} if current else {}
try:
with httpx.Client(
auth=httpx.BasicAuth(username, password),
verify=verify_tls,
timeout=timeout,
follow_redirects=True,
trust_env=False,
) as client:
with client.stream("GET", url, headers=headers) as response:
if response.status_code == 416 and expected and current == expected:
return
response.raise_for_status()
append = bool(current and response.status_code == 206)
if append:
content_range = str(response.headers.get("content-range") or "")
if not content_range.startswith(f"bytes {current}-"):
raise RuntimeError("远程服务器返回了无效的断点续传区间")
if current and not append:
current = 0
mode = "ab" if append else "wb"
with part.open(mode) as output:
os.chmod(part, 0o600)
received = current
for chunk in response.iter_bytes(self._CHUNK_BYTES):
if cancelled and cancelled():
raise JobCancelled("任务已取消")
if self.governor is not None:
self.governor.throttle_io(len(chunk), lane="ai")
output.write(chunk)
received += len(chunk)
if progress:
progress(received, expected or received)
size = part.stat().st_size
if expected and size != expected:
raise RuntimeError(f"远程媒体下载不完整:{size}/{expected} 字节")
return
except JobCancelled:
raise
except Exception as exc:
last_error = exc
if attempt >= len(self._RETRY_DELAYS):
break
time.sleep(self._RETRY_DELAYS[attempt])
raise RuntimeError(f"远程媒体缓存失败:{last_error}") from last_error
finally:
if access is not None:
access.close()
def acquire(self, video: dict, *, progress=None, cancelled=None) -> RemoteMediaLease:
key, data, part, metadata = self._paths(video)
with self._lock_for(key):
# Pin before opening or creating the .part file. The settings-page
# cleanup endpoint runs on a different thread and must never unlink
# a download that is currently being written.
self._pin(key)
try:
if not self._valid(data, metadata, video):
data.unlink(missing_ok=True)
metadata.unlink(missing_ok=True)
self._download(video, part, progress=progress, cancelled=cancelled)
part.replace(data)
os.chmod(data, 0o600)
self._atomic_json(
metadata,
{
"cache_key": key,
"video_id": video.get("id"),
"source_id": video.get("source_id"),
"source_key": video.get("source_key"),
"fingerprint": video.get("fingerprint"),
"size_bytes": data.stat().st_size,
"filename": data.name,
"created_at": time.time(),
},
)
now = time.time()
os.utime(data, (now, now))
self._invalidate_usage()
return RemoteMediaLease(data, key, self)
except Exception:
self._unpin(key)
self._invalidate_usage()
raise
def release(self, key: str) -> None:
self._unpin(key)
self.cleanup(force=False)
def _records(self) -> list[tuple[str, Path, Path, int, float, dict]]:
records = []
for metadata in self.root.glob("*.json"):
try:
value = json.loads(metadata.read_text(encoding="utf-8"))
key = str(value["cache_key"])
data = self.root / str(value["filename"])
stat = data.stat()
if data.is_symlink() or not data.is_file():
raise OSError
records.append((key, data, metadata, stat.st_size, stat.st_mtime, value))
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError):
metadata.unlink(missing_ok=True)
return records
def cleanup(self, *, force: bool = False, required_bytes: int = 0) -> dict[str, int]:
now = time.time()
idle_seconds = int(self.settings.remote_cache_idle_seconds)
records = self._records()
total = sum(item[3] for item in records)
target = max(0, self.limit_bytes - max(0, required_bytes))
freed = 0
removed = 0
skipped = 0
for key, data, metadata, size, accessed, _value in sorted(records, key=lambda item: item[4]):
with self._guard:
pinned = self._pins.get(key, 0) > 0
if pinned:
skipped += 1
continue
expired = now - accessed >= idle_seconds
if not force and not expired and total - freed <= target:
continue
data.unlink(missing_ok=True)
metadata.unlink(missing_ok=True)
freed += size
removed += 1
if force:
for part in self.root.glob("*.part"):
key = part.stem
with self._guard:
pinned = self._pins.get(key, 0) > 0
if pinned:
skipped += 1
continue
try:
size = part.stat().st_size
except OSError:
size = 0
part.unlink(missing_ok=True)
freed += size
removed += 1
self._invalidate_usage()
return {"freed_bytes": freed, "removed": removed, "skipped_active": skipped}
def invalidate_video(self, video_id: str) -> None:
for key, data, metadata, _size, _accessed, value in self._records():
if value.get("video_id") != video_id:
continue
with self._guard:
if self._pins.get(key, 0):
continue
data.unlink(missing_ok=True)
metadata.unlink(missing_ok=True)
self._invalidate_usage()
def _remove_stale_partials(self) -> None:
cutoff = time.time() - int(self.settings.remote_cache_partial_hours) * 3600
for path in self.root.iterdir():
if path.is_symlink():
path.unlink(missing_ok=True)
continue
if path.name.endswith(".tmp"):
path.unlink(missing_ok=True)
elif path.name.endswith(".part"):
try:
if path.stat().st_mtime < cutoff:
path.unlink(missing_ok=True)
except OSError:
continue
def status(self) -> dict:
now = time.monotonic()
with self._guard:
if self._usage is not None and now - self._usage_at < 5:
return dict(self._usage)
used = active = entries = 0
for key, _data, _metadata, size, _accessed, _value in self._records():
used += size
entries += 1
with self._guard:
if self._pins.get(key, 0):
active += size
partial = 0
for path in self.root.glob("*.part"):
try:
partial += path.stat().st_size
except OSError:
pass
value = {
"limit_bytes": self.limit_bytes,
"used_bytes": used + partial,
"active_bytes": active + partial,
"evictable_bytes": max(0, used - active),
"entries": entries,
"downloading": int(bool(partial)),
}
with self._guard:
self._usage = value
self._usage_at = now
return dict(value)
+595
View File
@@ -0,0 +1,595 @@
from __future__ import annotations
import os
import shutil
import threading
import time
from collections.abc import Callable
from dataclasses import asdict, dataclass
from pathlib import Path
from .accelerator import GPU_MODES
from .config import Settings
from .database import Database
from .jobs import JOB_LANES
IO_PROFILES = {
"quiet": {
"limit_mib": 4.0,
"pressure_limit_mib": 2.0,
"pause_some": 15.0,
"pause_full": 5.0,
"resume_some": 5.0,
"resume_full": 1.0,
"pause_util": 75.0,
"resume_util": 35.0,
"emergency_util": 99.0,
},
"balanced": {
"limit_mib": 12.0,
"pressure_limit_mib": 4.0,
"pause_some": 30.0,
"pause_full": 10.0,
"resume_some": 10.0,
"resume_full": 3.0,
"pause_util": 88.0,
"resume_util": 50.0,
"emergency_util": 99.5,
},
"turbo": {
"limit_mib": 24.0,
"pressure_limit_mib": 8.0,
"pause_some": 60.0,
"pause_full": 25.0,
"resume_some": 25.0,
"resume_full": 10.0,
"pause_util": 96.0,
"resume_util": 70.0,
"emergency_util": 99.8,
},
}
IO_EMERGENCY_FULL_PERCENT = 35.0
@dataclass(slots=True)
class ResourcePolicy:
cpu_pause_percent: float
cpu_resume_percent: float
memory_reserve_mb: int
memory_reserve_percent: float
disk_reserve_gb: float
class ResourceGovernor:
"""Cooperatively protects the NAS while background work is running.
API requests never wait on this class. Only job workers call ``wait_sync``
at safe checkpoints, so a busy index/download can be paused without making
the management UI or SSH compete for the last available resources.
"""
def __init__(self, db: Database, settings: Settings):
self.db = db
self.settings = settings
self._lock = threading.RLock()
self._last_cpu: tuple[int, int] | None = None
self._last_cpu_percent = 0.0
self._last_sample_at = 0.0
self._last_sample: dict[str, float | int] | None = None
self._sample_generation = 0
self._lane_sample_generation = {lane: -1 for lane in ("ai", "transfer", "download", "scan")}
self._high_samples = {lane: 0 for lane in ("ai", "transfer", "download", "scan")}
self._pressure_reasons: dict[str, str | None] = {
lane: None for lane in ("ai", "transfer", "download", "scan")
}
self._io_state = "normal"
self._io_high_samples = 0
self._io_emergency_samples = 0
self._io_low_samples = 0
self._io_sample_generation = -1
self._io_budget_at = 0.0
self._last_volume_io: tuple[str, int, float] | None = None
def profile(self) -> str:
value = str(self.db.setting("resource_profile", "balanced") or "balanced")
return value if value in IO_PROFILES else "balanced"
def gpu_mode(self) -> str:
value = str(self.db.setting("ai_gpu_mode", self.settings.ai_gpu_mode) or "balanced")
if value not in GPU_MODES:
value = "balanced"
self.settings.ai_gpu_mode = value
return value
def set_gpu_mode(self, mode: str) -> dict:
if mode not in GPU_MODES:
raise ValueError("GPU 推理档位无效")
self.db.set_setting("ai_gpu_mode", mode)
self.settings.ai_gpu_mode = mode
return self.status()
def set_profile(self, profile: str) -> dict:
if profile not in IO_PROFILES:
raise ValueError("资源保护档位无效")
self.db.set_setting("resource_profile", profile)
with self._lock:
self._io_high_samples = 0
self._io_emergency_samples = 0
self._io_low_samples = 0
self._io_state = "normal"
self._io_budget_at = time.monotonic()
return {"profile": profile}
def policy(self) -> ResourcePolicy:
stored = self.db.setting("resource_policy", {})
values = stored if isinstance(stored, dict) else {}
pause = float(values.get("cpu_pause_percent", self.settings.resource_cpu_pause_percent))
resume = float(values.get("cpu_resume_percent", self.settings.resource_cpu_resume_percent))
if resume >= pause:
resume = max(10.0, pause - 10.0)
return ResourcePolicy(
cpu_pause_percent=min(max(pause, 20.0), 100.0),
cpu_resume_percent=min(max(resume, 10.0), 95.0),
memory_reserve_mb=int(values.get("memory_reserve_mb", self.settings.resource_memory_reserve_mb)),
memory_reserve_percent=float(
values.get("memory_reserve_percent", self.settings.resource_memory_reserve_percent)
),
disk_reserve_gb=float(values.get("disk_reserve_gb", self.settings.resource_disk_reserve_gb)),
)
def configure(self, values: dict) -> dict:
current = asdict(self.policy())
current.update({key: value for key, value in values.items() if value is not None})
pause = float(current["cpu_pause_percent"])
resume = float(current["cpu_resume_percent"])
if resume >= pause:
raise ValueError("CPU 恢复阈值必须低于暂停阈值")
self.db.set_setting("resource_policy", current)
return self.status()
def set_manual_pause(self, paused: bool) -> dict:
self.db.set_setting("resource_jobs_paused", bool(paused))
if not paused:
for lane, reason in self._pressure_reasons.items():
if reason == "管理员已暂停后台任务":
self._pressure_reasons[lane] = None
return self.status()
@staticmethod
def _cpu_values(path: Path = Path("/proc/stat")) -> tuple[int, int]:
fields = path.read_text(encoding="utf-8").splitlines()[0].split()[1:]
values = [int(value) for value in fields]
idle = values[3] + (values[4] if len(values) > 4 else 0)
return sum(values), idle
@staticmethod
def _memory_values(path: Path = Path("/proc/meminfo")) -> tuple[int, int]:
values: dict[str, int] = {}
for line in path.read_text(encoding="utf-8").splitlines():
key, _, raw = line.partition(":")
if key in {"MemTotal", "MemAvailable"}:
values[key] = int(raw.strip().split()[0]) * 1024
return values.get("MemTotal", 0), values.get("MemAvailable", 0)
@staticmethod
def _io_pressure(path: Path = Path("/proc/pressure/io")) -> dict:
result = {
"supported": False,
"some_avg10": 0.0,
"some_avg60": 0.0,
"some_avg300": 0.0,
"full_avg10": 0.0,
"full_avg60": 0.0,
"full_avg300": 0.0,
}
try:
for line in path.read_text(encoding="utf-8").splitlines():
fields = line.split()
if not fields or fields[0] not in {"some", "full"}:
continue
prefix = fields[0]
for field in fields[1:]:
name, _, raw = field.partition("=")
if name in {"avg10", "avg60", "avg300"}:
result[f"{prefix}_{name}"] = float(raw)
result["supported"] = True
except (OSError, ValueError):
pass
return result
@staticmethod
def _unescape_mount_field(value: str) -> str:
for encoded, decoded in (("\\040", " "), ("\\011", "\t"), ("\\012", "\n"), ("\\134", "\\")):
value = value.replace(encoded, decoded)
return value
@classmethod
def _data_volume_io_values(
cls,
data_dir: Path,
*,
mountinfo_path: Path = Path("/proc/self/mountinfo"),
sysfs_root: Path = Path("/sys/dev/block"),
) -> dict | None:
"""Resolve the data directory's mount and read its block I/O clock.
PSI is system-wide and cannot distinguish a busy recording disk from
ImageFind's data volume. Btrfs mounts commonly expose a synthetic
``0:*`` mount id, so the backing ``/dev/mapper`` source is resolved
when necessary.
"""
try:
target = data_dir.resolve()
best: tuple[int, str, str, str] | None = None
for line in mountinfo_path.read_text(encoding="utf-8").splitlines():
fields = line.split()
try:
separator = fields.index("-")
mount_point = cls._unescape_mount_field(fields[4])
mount = Path(mount_point)
target.relative_to(mount)
candidate = (
len(mount.parts),
fields[2],
mount_point,
cls._unescape_mount_field(fields[separator + 2]),
)
if best is None or candidate[0] > best[0]:
best = candidate
except (ValueError, IndexError):
continue
if best is None:
return None
_depth, device, mount_point, source = best
major, minor = (int(value) for value in device.split(":", 1))
if major == 0 and source.startswith("/dev/"):
source_stat = os.stat(source)
major, minor = os.major(source_stat.st_rdev), os.minor(source_stat.st_rdev)
device = f"{major}:{minor}"
fields = (sysfs_root / device / "stat").read_text(encoding="utf-8").split()
if len(fields) < 10:
return None
return {
"device": device,
"mount_point": mount_point,
"in_flight": int(fields[8]),
"io_ticks_ms": int(fields[9]),
}
except (OSError, ValueError):
return None
def _volume_io_pressure(self, now: float) -> dict:
values = self._data_volume_io_values(self.settings.data_dir)
if values is None:
self._last_volume_io = None
return {
"scope": "system",
"volume_supported": False,
"volume_sampled": False,
}
device = str(values["device"])
ticks = int(values["io_ticks_ms"])
previous = self._last_volume_io
self._last_volume_io = (device, ticks, now)
result = {
"scope": "data_volume",
"volume_supported": True,
"volume_sampled": False,
"volume_device": device,
"volume_mount_point": str(values["mount_point"]),
"volume_in_flight": int(values["in_flight"]),
"volume_utilization_percent": 0.0,
}
if previous is None or previous[0] != device or now <= previous[2]:
return result
elapsed_ms = (now - previous[2]) * 1000
utilization = max(0.0, min(100.0, (ticks - previous[1]) / elapsed_ms * 100))
result["volume_sampled"] = True
result["volume_utilization_percent"] = round(utilization, 1)
return result
def sample(self) -> dict:
with self._lock:
now = time.monotonic()
# Four independent job lanes poll the governor. Sampling /proc for
# every caller produces tiny one-jiffy deltas that often look like
# a false 100% CPU reading. Share one representative interval.
minimum_interval = max(0.5, self.settings.resource_sample_seconds)
if self._last_sample is not None and now - self._last_sample_at < minimum_interval:
return dict(self._last_sample)
try:
total, idle = self._cpu_values()
if self._last_cpu is not None:
total_delta = max(1, total - self._last_cpu[0])
idle_delta = max(0, idle - self._last_cpu[1])
self._last_cpu_percent = max(0.0, min(100.0, (1 - idle_delta / total_delta) * 100))
self._last_cpu = (total, idle)
except (OSError, ValueError, IndexError):
pass
try:
memory_total, memory_available = self._memory_values()
except (OSError, ValueError):
memory_total = memory_available = 0
try:
disk = shutil.disk_usage(self.settings.data_dir)
disk_free = disk.free
except OSError:
disk_free = 0
io = self._io_pressure()
io.update(self._volume_io_pressure(now))
sample = {
"cpu_percent": round(self._last_cpu_percent, 1),
"memory_total_bytes": memory_total,
"memory_available_bytes": memory_available,
"disk_available_bytes": disk_free,
"io": io,
}
self._last_sample_at = now
self._last_sample = sample
self._sample_generation += 1
return dict(sample)
def _update_io_state(self, sample: dict) -> str:
io = sample.get("io") or {}
if not io.get("supported"):
self._io_state = "normal"
return self._io_state
with self._lock:
generation = self._sample_generation
if self._io_sample_generation == generation:
return self._io_state
self._io_sample_generation = generation
profile = IO_PROFILES[self.profile()]
volume_scoped = io.get("scope") == "data_volume"
if volume_scoped:
# The first sample establishes a block-device counter
# baseline. Keep the prior state until a real interval exists.
if not io.get("volume_sampled"):
return self._io_state
utilization = float(io.get("volume_utilization_percent") or 0)
high = utilization >= float(profile["pause_util"])
low = utilization < float(profile["resume_util"])
emergency = utilization >= float(profile["emergency_util"])
else:
some = float(io.get("some_avg10") or 0)
full = float(io.get("full_avg10") or 0)
high = some >= profile["pause_some"] or full >= profile["pause_full"]
low = some < profile["resume_some"] and full < profile["resume_full"]
emergency = full >= IO_EMERGENCY_FULL_PERCENT
if emergency:
self._io_emergency_samples += 1
self._io_low_samples = 0
if not volume_scoped or self._io_emergency_samples >= 3:
self._io_state = "emergency"
self._io_high_samples = max(3, self._io_high_samples)
return self._io_state
self._io_emergency_samples = 0
if self._io_state in {"paused", "emergency"}:
if low:
self._io_low_samples += 1
if self._io_low_samples >= 5:
self._io_state = "normal"
self._io_high_samples = 0
self._io_emergency_samples = 0
self._io_low_samples = 0
else:
self._io_low_samples = 0
return self._io_state
if high:
self._io_high_samples += 1
if self._io_high_samples >= 3:
self._io_state = "paused"
self._io_low_samples = 0
else:
self._io_high_samples = 0
return self._io_state
def throttle_io(self, byte_count: int, *, lane: str = "transfer") -> None:
if byte_count <= 0:
return
sample = self.sample()
state = self._update_io_state(sample)
profile = IO_PROFILES[self.profile()]
if state == "emergency":
limit_mib = 1.0
elif state == "paused":
limit_mib = float(profile["pressure_limit_mib"])
else:
limit_mib = float(profile["limit_mib"])
rate = max(1.0, limit_mib * 1024**2)
duration = byte_count / rate
with self._lock:
now = time.monotonic()
start = max(now, self._io_budget_at)
self._io_budget_at = start + duration
delay = start - now
if delay > 0:
time.sleep(delay)
def effective_io_limit_bytes(self) -> int:
sample = self.sample()
state = self._update_io_state(sample)
profile = IO_PROFILES[self.profile()]
limit_mib = (
1.0
if state == "emergency"
else float(profile["pressure_limit_mib"] if state == "paused" else profile["limit_mib"])
)
return int(limit_mib * 1024**2)
def pressure_reason(self, *, lane: str = "ai", running: bool = False) -> str | None:
lane = lane if lane in self._pressure_reasons else "ai"
policy = self.policy()
sample = self.sample()
if bool(self.db.setting("resource_jobs_paused", False)):
self._pressure_reasons[lane] = "管理员已暂停后台任务"
return self._pressure_reasons[lane]
memory_total = sample["memory_total_bytes"]
absolute_memory_floor = policy.memory_reserve_mb * 1024**2
# The percentage reserve is an admission threshold: do not start a
# heavyweight task unless it has room to load its model. Once the task
# is running, pausing at that same threshold can deadlock it because a
# stopped worker keeps the resident model allocated. During execution
# retain the absolute emergency reserve instead.
admission_sensitive = lane in {"ai", "scan"}
memory_floor = absolute_memory_floor
if admission_sensitive and not running:
memory_floor = max(
absolute_memory_floor,
int(memory_total * policy.memory_reserve_percent / 100),
)
if memory_total and sample["memory_available_bytes"] < memory_floor:
self._pressure_reasons[lane] = "可用内存不足,等待系统恢复"
return self._pressure_reasons[lane]
if sample["disk_available_bytes"] and sample["disk_available_bytes"] < policy.disk_reserve_gb * 1024**3:
self._pressure_reasons[lane] = "应用数据盘剩余空间不足"
return self._pressure_reasons[lane]
io_state = self._update_io_state(sample)
if io_state in {"paused", "emergency"} and (
lane in {"ai", "scan", "download"} or io_state == "emergency"
):
label = "磁盘 I/O 已达到紧急压力" if io_state == "emergency" else "磁盘 I/O 持续繁忙"
self._pressure_reasons[lane] = f"{label},后台任务已让出资源"
return self._pressure_reasons[lane]
if lane in {"transfer", "download"}:
self._pressure_reasons[lane] = None
self._high_samples[lane] = 0
return None
if running:
# CPU usage produced by the current low-priority worker must not
# SIGSTOP that same worker. A stopped OpenVINO process retains
# its model and immediately crosses the threshold again after
# SIGCONT, creating an endless pause/resume oscillation. CPU is
# still enforced before a heavy AI/scan job is admitted; once it
# is running, niceness and per-runtime thread/stream limits keep
# interactive work responsive. Emergency memory and disk floors
# above remain active throughout execution.
self._pressure_reasons[lane] = None
self._high_samples[lane] = 0
return None
cpu = sample["cpu_percent"]
reason = self._pressure_reasons[lane]
if reason and "CPU" in reason:
if cpu > policy.cpu_resume_percent:
return reason
self._pressure_reasons[lane] = None
self._high_samples[lane] = 0
if cpu >= policy.cpu_pause_percent:
with self._lock:
# Unit/service callers may replace sample() with their own
# uncached sampler. Treat each such call as a fresh interval.
if self._last_sample is None:
self._sample_generation += 1
generation = self._sample_generation
if self._lane_sample_generation[lane] != generation:
self._lane_sample_generation[lane] = generation
self._high_samples[lane] += 1
if self._high_samples[lane] >= 3:
self._pressure_reasons[lane] = "CPU 持续繁忙,后台任务已让出资源"
return self._pressure_reasons[lane]
else:
self._high_samples[lane] = 0
reason = self._pressure_reasons[lane]
if reason and "内存" not in reason and "空间" not in reason:
self._pressure_reasons[lane] = None
elif reason and ("内存" in reason or "空间" in reason):
self._pressure_reasons[lane] = None
return None
def wait_sync(
self,
*,
lane: str = "ai",
paused: Callable[[str], None] | None = None,
resumed: Callable[[], None] | None = None,
cancelled: Callable[[], bool] | None = None,
) -> None:
was_paused = False
while True:
if cancelled and cancelled():
return
reason = self.pressure_reason(lane=lane, running=True)
if not reason:
if was_paused and resumed:
resumed()
return
was_paused = True
if paused:
paused(reason)
time.sleep(self.settings.resource_sample_seconds)
def status(self) -> dict:
policy = asdict(self.policy())
sample = self.sample()
manual = bool(self.db.setting("resource_jobs_paused", False))
with self.db.read() as conn:
rows = conn.execute(
"SELECT kind,status,count(*) AS count FROM jobs "
"WHERE status IN ('queued','running') GROUP BY kind,status"
).fetchall()
paused_rows = conn.execute(
"SELECT kind,count(*) AS count FROM jobs WHERE status='running' "
"AND pause_reason IS NOT NULL GROUP BY kind"
).fetchall()
lanes = {
lane: {
"limit": 1,
"running": 0,
"queued": 0,
"paused": 0,
"pressure_reason": "管理员已暂停后台任务" if manual else None,
}
for lane in ("ai", "transfer", "download", "scan")
}
for row in rows:
lane = JOB_LANES.get(row["kind"], "ai")
lanes[lane][row["status"]] += int(row["count"])
for row in paused_rows:
lane = JOB_LANES.get(row["kind"], "ai")
lanes[lane]["paused"] += int(row["count"])
if not manual:
for lane, value in lanes.items():
value["pressure_reason"] = self.pressure_reason(
lane=lane,
running=bool(value["running"]),
)
running = sum(value["running"] for value in lanes.values())
paused = sum(value["paused"] for value in lanes.values())
pressure_reason = next(
(lanes[lane]["pressure_reason"] for lane in ("ai", "scan", "transfer", "download")
if lanes[lane]["pressure_reason"]),
None,
)
io_state = self._update_io_state(sample)
io = dict(sample.get("io") or {})
profile_name = self.profile()
profile = IO_PROFILES[profile_name]
io.update(
{
"state": io_state,
"effective_limit_bytes": int(
(1.0 if io_state == "emergency" else profile[
"pressure_limit_mib" if io_state == "paused" else "limit_mib"
])
* 1024**2
),
}
)
sample.pop("io", None)
return {
"profile": profile_name,
"ai_gpu_mode": self.gpu_mode(),
"ai_gpu_profile": {
"mode": self.gpu_mode(),
**GPU_MODES[self.gpu_mode()],
},
"manual_pause": manual,
"pressure_reason": pressure_reason,
"running_jobs": int(running),
"paused_jobs": int(paused),
"lanes": lanes,
"policy": policy,
"database": self.db.status(),
"io": io,
**sample,
}
+813
View File
@@ -0,0 +1,813 @@
from __future__ import annotations
import hashlib
import importlib
import json
import os
import shutil
import subprocess
import sys
import sysconfig
import tarfile
import tempfile
import threading
import time
import zipfile
from collections.abc import Callable
from pathlib import Path
from typing import cast
import httpx
from . import __version__
from .config import Settings
ProgressCallback = Callable[[float, str], None]
ClientFactory = Callable[..., httpx.Client]
AI_IMPORTS = {
"visual": ("numpy", "openvino", "optimum.intel", "sentence_transformers", "torch", "transformers"),
"ocr": ("numpy", "openvino", "rapidocr_onnxruntime"),
"faces": ("cv2", "numpy", "openvino"),
"audio": ("numpy", "openvino", "optimum.intel", "torch", "transformers", "webrtcvad"),
}
# RapidOCR declares the desktop opencv-python distribution even though the
# server only needs cv2. Install its fully locked dependencies first, then the
# package itself without dependencies so fnOS never receives two conflicting
# cv2 wheels or an unnecessary GUI runtime.
AI_NO_DEPENDENCIES = {"ocr": ("rapidocr-onnxruntime==1.4.4",)}
AI_RUNTIME_SCHEMA = 3
AI_RUNTIME_DIGEST_FORMAT = "requirements-v1"
# 0.3.16-0.3.19 changed only the release number in the first comment of the
# shared constraints file. Schema 2 hashed the raw file, so those harmless
# comment edits made every installed component look stale. Keep the known raw
# digests readable during the schema-3 migration; the semantic digest below is
# still checked, so an actual dependency change cannot be accepted by mistake.
LEGACY_CONSTRAINT_DIGESTS = {
"1756445d1c3705b156f7b238251c43dfb32c5bc9d4bc06c8d45bb1e5ffe7a6ca": {
"7d8c9d26b04ec56a35137ee52e40b231120824f1ff70ecb87da037fd77731b10",
"c381fe04ecce1bb887e7a41966d9ab0e3b61739066a3e064af3177b807ba7289",
"cbf305260f58761eb2d889b3601398777abbc95399dd52eb8fb9c0a43b4e2f78",
"3783e4c32c75147d20888ad3c3739d1a4662e5b7cd889b192c0ec3d77ca0d458",
"3f27c2db264cd09307ec97c53ce0b0464acb3982cd9eb48c52ddde15d8061f80",
},
"bdb17c6dc232377b377ad8560143fee85938660e1272b8d2ad0993a898b6153a": {
"c381fe04ecce1bb887e7a41966d9ab0e3b61739066a3e064af3177b807ba7289",
"cbf305260f58761eb2d889b3601398777abbc95399dd52eb8fb9c0a43b4e2f78",
"3783e4c32c75147d20888ad3c3739d1a4662e5b7cd889b192c0ec3d77ca0d458",
"3f27c2db264cd09307ec97c53ce0b0464acb3982cd9eb48c52ddde15d8061f80",
}
}
# 0.5.17 adds only the audio-specific WebRTC VAD wheel to the shared pin
# file. Existing schema-3 visual/OCR/faces records remain valid when their own
# requirements and the exact previous constraints digest still match. Audio
# deliberately isn't listed and receives the small incremental dependency.
COMPATIBLE_COMPONENT_CONSTRAINT_DIGESTS = {
"1756445d1c3705b156f7b238251c43dfb32c5bc9d4bc06c8d45bb1e5ffe7a6ca": {
"visual": {"bdb17c6dc232377b377ad8560143fee85938660e1272b8d2ad0993a898b6153a"},
"ocr": {"bdb17c6dc232377b377ad8560143fee85938660e1272b8d2ad0993a898b6153a"},
"faces": {"bdb17c6dc232377b377ad8560143fee85938660e1272b8d2ad0993a898b6153a"},
}
}
TOOL_RELEASES = {
"ffmpeg": {
"version": "7.0.2",
"url": "https://johnvansickle.com/ffmpeg/old-releases/ffmpeg-7.0.2-amd64-static.tar.xz",
"files": {
"ffmpeg": "e7e7fb30477f717e6f55f9180a70386c62677ef8a4d4d1a5d948f4098aa3eb99",
"ffprobe": "4f231a1960d83e403d08f7971e271707bec278a9ae18e21b8b5b03186668450d",
},
},
"rclone": {
"version": "1.74.4",
"url": "https://downloads.rclone.org/v1.74.4/rclone-v1.74.4-linux-amd64.zip",
"files": {"rclone": "9f56ca5edfac24a3ed37226c2ba1de69f1ec9e05fa2526cddee5cd97e202be6b"},
},
}
def _directory_size(path: Path) -> int:
total = 0
if not path.exists():
return 0
for root, directories, files in os.walk(path, followlinks=False):
directories[:] = [name for name in directories if not (Path(root) / name).is_symlink()]
for name in files:
try:
total += (Path(root) / name).lstat().st_size
except OSError:
continue
return total
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def _requirements_lines(path: Path) -> tuple[str, ...]:
effective = []
for line in path.read_text(encoding="utf-8").splitlines():
normalized = line.strip()
if not normalized or normalized.startswith("#"):
continue
effective.append(normalized)
return tuple(effective)
def _requirements_sha256(path: Path) -> str:
"""Hash effective requirement lines, independent of comments/whitespace."""
effective = _requirements_lines(path)
content = "\n".join(effective)
if effective:
content += "\n"
return hashlib.sha256(content.encode()).hexdigest()
class AIDependencyManager:
"""Installs component-specific AI packages outside the core virtualenv."""
def __init__(
self,
settings: Settings,
proxy_config: Callable[..., dict] | None = None,
):
self.settings = settings
self.proxy_config = proxy_config
self.current = settings.ai_site_path
self.bundled = settings.bundled_ai_runtime
self.managed = settings.runtime_requirements_dir is not None
self._lock = threading.Lock()
self._size_lock = threading.Lock()
self._size_cached_at = 0.0
self._size_cached_bytes = 0
self._states: dict[str, dict[str, str]] = {}
self._ensure_import_path()
def _ensure_import_path(self) -> None:
value = str(self.current)
if value not in sys.path:
sys.path.insert(0, value)
@property
def stamp_path(self) -> Path:
return self.current / ".imagefind-runtime.json"
def _stamp(self) -> dict:
try:
value = json.loads(self.stamp_path.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def _requirements(self, component: str) -> Path:
root = self.settings.runtime_requirements_dir
if root is None:
root = Path(__file__).resolve().parents[2] / "requirements" / "runtime-ai"
path = root / f"{component}.txt"
if not path.is_file():
raise RuntimeError(f"缺少 {component} AI 运行时锁文件,请升级 ImageFind")
return path
def _constraints(self) -> Path:
root = self.settings.runtime_requirements_dir
if root is None:
root = Path(__file__).resolve().parents[2] / "requirements" / "runtime-ai"
path = root / "constraints-cp312.txt"
if not path.is_file():
raise RuntimeError("缺少 AI 运行时约束锁文件,请升级 ImageFind")
return path
@staticmethod
def _python_abi() -> str:
return str(sysconfig.get_config_var("SOABI") or f"cp{sys.version_info.major}{sys.version_info.minor}")
def _component_lock(self, component: str) -> dict[str, str]:
requirements = self._requirements(component)
constraints = self._constraints()
requirement_digest = _requirements_sha256(requirements)
constraints_digest = _requirements_sha256(constraints)
combined = hashlib.sha256()
combined.update(self._python_abi().encode())
combined.update(requirement_digest.encode())
combined.update(constraints_digest.encode())
for value in AI_NO_DEPENDENCIES.get(component, ()):
combined.update(value.encode())
return {
"digest_format": AI_RUNTIME_DIGEST_FORMAT,
"python_abi": self._python_abi(),
"requirements_sha256": requirement_digest,
"constraints_sha256": constraints_digest,
"digest": combined.hexdigest(),
}
def _installed_record(self, component: str) -> dict:
stamp = self._stamp()
if stamp.get("schema") not in {2, AI_RUNTIME_SCHEMA}:
return {}
components = stamp.get("components")
if not isinstance(components, dict):
return {}
record = components.get(component)
return record if isinstance(record, dict) else {}
def _legacy_ready(self, component: str, record: dict, expected: dict[str, str]) -> bool:
"""Accept a schema-2 stamp when only the version comment changed."""
if self._stamp().get("schema") != 2:
return False
legacy_constraints = str(record.get("constraints_sha256") or "")
if legacy_constraints not in LEGACY_CONSTRAINT_DIGESTS.get(
expected["constraints_sha256"], set()
):
return False
legacy_requirements = _sha256(self._requirements(component))
if (
record.get("python_abi") != expected["python_abi"]
or record.get("requirements_sha256") != legacy_requirements
):
return False
combined = hashlib.sha256()
combined.update(expected["python_abi"].encode())
combined.update(legacy_requirements.encode())
combined.update(legacy_constraints.encode())
for value in AI_NO_DEPENDENCIES.get(component, ()):
combined.update(value.encode())
return record.get("digest") == combined.hexdigest()
def _compatible_component_ready(self, component: str, record: dict, expected: dict[str, str]) -> bool:
if self._stamp().get("schema") != AI_RUNTIME_SCHEMA:
return False
prior_constraints = str(record.get("constraints_sha256") or "")
allowed = COMPATIBLE_COMPONENT_CONSTRAINT_DIGESTS.get(
expected["constraints_sha256"], {}
).get(component, set())
if (
prior_constraints not in allowed
or record.get("digest_format") != expected["digest_format"]
or record.get("python_abi") != expected["python_abi"]
or record.get("requirements_sha256") != expected["requirements_sha256"]
):
return False
combined = hashlib.sha256()
combined.update(expected["python_abi"].encode())
combined.update(expected["requirements_sha256"].encode())
combined.update(prior_constraints.encode())
for value in AI_NO_DEPENDENCIES.get(component, ()):
combined.update(value.encode())
return record.get("digest") == combined.hexdigest()
def _record_ready(self, component: str, expected: dict[str, str] | None = None) -> bool:
expected = expected or self._component_lock(component)
record = self._installed_record(component)
return (
all(record.get(key) == expected[key] for key in expected)
or self._legacy_ready(component, record, expected)
or self._compatible_component_ready(component, record, expected)
)
def _covered_by(self, component: str, providers: list[str]) -> bool:
"""Return whether installed providers already contain a component runtime."""
if not providers:
return False
requirements = {
line for provider in providers for line in _requirements_lines(self._requirements(provider))
}
no_dependencies = {
value for provider in providers for value in AI_NO_DEPENDENCIES.get(provider, ())
}
return (
set(_requirements_lines(self._requirements(component))).issubset(requirements)
and set(AI_NO_DEPENDENCIES.get(component, ())).issubset(no_dependencies)
)
def _ready(self, component: str) -> bool:
if not self.current.is_dir():
return False
try:
expected = self._component_lock(component)
except RuntimeError:
return False
if self._record_ready(component, expected):
return True
providers = [
candidate
for candidate in AI_IMPORTS
if candidate != component and self._record_ready(candidate)
]
return self._covered_by(component, providers)
def _current_size(self) -> int:
now = time.monotonic()
with self._size_lock:
if now - self._size_cached_at < 30:
return self._size_cached_bytes
self._size_cached_bytes = _directory_size(self.current)
self._size_cached_at = now
return self._size_cached_bytes
def _invalidate_size(self) -> None:
with self._size_lock:
self._size_cached_at = 0.0
def status(self) -> dict[str, dict]:
if self.bundled:
return {
component: {"state": "ready", "version": __version__, "size_bytes": 0, "source": "bundled"}
for component in AI_IMPORTS
}
if not self.managed:
return {
component: {"state": "external", "version": None, "size_bytes": 0}
for component in AI_IMPORTS
}
result = {}
shared_size = self._current_size()
for component in AI_IMPORTS:
ready = self._ready(component)
record = self._installed_record(component)
transient = self._states.get(component, {})
expected = self._component_lock(component)
result[component] = {
"state": transient.get("state") or ("ready" if ready else "missing"),
"version": (record.get("runtime_version") or self._stamp().get("runtime_version"))
if ready
else None,
"digest": (record.get("digest") or expected["digest"]) if ready else expected["digest"],
"python_abi": (record.get("python_abi") or expected["python_abi"])
if ready
else self._python_abi(),
"size_bytes": shared_size if ready else 0,
"error": transient.get("error"),
}
return result
def environment_status(self, component_status: dict[str, dict] | None = None) -> dict:
if self.bundled:
return {
"state": "ready",
"version": __version__,
"size_bytes": 0,
"components": list(AI_IMPORTS),
"source": "bundled",
}
if not self.managed:
return {"state": "external", "version": None, "size_bytes": 0, "components": list(AI_IMPORTS)}
component_status = component_status or self.status()
installed = sorted(
component
for component in AI_IMPORTS
if component_status.get(component, {}).get("state") in {"ready", "external"}
)
states = {value.get("state") for value in self._states.values()}
return {
"state": "installing" if "installing" in states else ("ready" if installed else "missing"),
"version": __version__ if installed else None,
"size_bytes": self._current_size() if installed else 0,
"components": installed,
"source": "on_demand",
}
@staticmethod
def _copy_link(source: str, destination: str) -> str:
try:
os.link(source, destination)
except OSError:
shutil.copy2(source, destination)
return destination
def _proxy_environment(self) -> tuple[dict[str, str], list[str]]:
environment = dict(os.environ)
secrets: list[str] = []
if self.proxy_config is None:
for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
environment.pop(name, None)
return environment, secrets
config = self.proxy_config(include_password=True)
if not config.get("enabled"):
for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
environment.pop(name, None)
return environment, secrets
proxy_url = str(config.get("url") or "")
if not proxy_url:
return environment, secrets
username = str(config.get("username") or "")
password = str(config.get("password") or "")
if username or password:
from urllib.parse import quote, urlsplit, urlunsplit
parsed = urlsplit(proxy_url)
host = parsed.hostname or ""
if ":" in host and not host.startswith("["):
host = f"[{host}]"
encoded_password = quote(password, safe="")
netloc = f"{quote(username, safe='')}:{encoded_password}@{host}"
if parsed.port:
netloc += f":{parsed.port}"
proxy_url = urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, ""))
if password:
secrets.extend((password, encoded_password))
environment.update(HTTP_PROXY=proxy_url, HTTPS_PROXY=proxy_url, http_proxy=proxy_url, https_proxy=proxy_url)
return environment, secrets
@staticmethod
def _redact(value: str, secrets: list[str]) -> str:
for secret in secrets:
if secret:
value = value.replace(secret, "***")
return value[-1600:]
def _validate(self, target: Path, component: str, environment: dict[str, str]) -> None:
imports = ";".join(f"import {name}" for name in AI_IMPORTS[component])
validation_environment = dict(environment)
prior = validation_environment.get("PYTHONPATH", "")
validation_environment["PYTHONPATH"] = str(target) + (os.pathsep + prior if prior else "")
subprocess.run(
[sys.executable, "-I", "-c", f"import sys;sys.path.insert(0,{str(target)!r});{imports}"],
check=True,
capture_output=True,
text=True,
timeout=120,
env=validation_environment,
)
@staticmethod
def _install_with_heartbeat(
command: list[str],
*,
component: str,
phase_start: float,
phase_end: float,
progress: ProgressCallback | None,
environment: dict[str, str],
) -> subprocess.CompletedProcess[str]:
outcome: dict[str, object] = {}
def install() -> None:
try:
outcome["result"] = subprocess.run(
command,
capture_output=True,
text=True,
timeout=3600,
env=environment,
)
except BaseException as exc: # propagated on the calling job thread
outcome["error"] = exc
if progress:
progress(phase_start, f"开始下载并安装 {component} AI 依赖")
started = time.monotonic()
worker = threading.Thread(target=install, name=f"imagefind-pip-{component}", daemon=True)
worker.start()
while worker.is_alive():
worker.join(timeout=5)
if worker.is_alive() and progress:
elapsed = max(1, round(time.monotonic() - started))
# pip does not expose a stable aggregate byte count. Advance
# conservatively for five minutes, then keep the elapsed-time
# heartbeat visible until validation can confirm completion.
fraction = min(0.9, elapsed / 300)
value = phase_start + (phase_end - phase_start) * fraction
minutes, seconds = divmod(elapsed, 60)
waited = f"{minutes}{seconds}" if minutes else f"{seconds}"
progress(value, f"正在下载并安装 {component} AI 依赖 · 已等待 {waited}")
error = outcome.get("error")
if isinstance(error, BaseException):
raise error
result = cast(subprocess.CompletedProcess[str], outcome["result"])
if progress:
progress(phase_end, f"{component} AI 依赖安装完成")
return result
def ensure(self, component: str, progress: ProgressCallback | None = None) -> None:
if component not in AI_IMPORTS:
raise ValueError("AI 运行时组件无效")
if self.bundled:
imports = AI_IMPORTS[component]
for index, name in enumerate(imports, start=1):
if progress:
progress((index - 1) / len(imports), f"校验内置 {component} AI 运行环境")
try:
importlib.import_module(name)
except Exception as exc:
raise RuntimeError(f"内置 {component} AI 运行环境不可用:{type(exc).__name__}: {exc}") from exc
if progress:
progress(1, "内置 AI 运行环境已就绪")
return
if not self.managed:
if progress:
progress(1, "使用外部 AI 运行环境")
return
if self._ready(component):
if progress:
progress(1, "AI 运行依赖已就绪")
return
with self._lock:
if self._ready(component):
return
self.settings.runtime_dir.mkdir(parents=True, exist_ok=True)
self._requirements(component)
constraints = self._constraints()
stage = Path(tempfile.mkdtemp(prefix="ai-runtime-", dir=self.settings.runtime_dir))
backup = self.current.with_name(f"{self.current.name}.previous-{os.getpid()}-{time.time_ns()}")
environment, secret_values = self._proxy_environment()
environment["PIP_INDEX_URL"] = self.settings.pip_index_url
if self.settings.pytorch_index_url:
environment["PIP_EXTRA_INDEX_URL"] = self.settings.pytorch_index_url
else:
environment.pop("PIP_EXTRA_INDEX_URL", None)
environment["PIP_DEFAULT_TIMEOUT"] = "60"
environment["PIP_RETRIES"] = "3"
self._states[component] = {"state": "installing", "error": ""}
try:
preserved = [name for name in AI_IMPORTS if name != component and self._ready(name)]
desired = [name for name in AI_IMPORTS if name == component or name in preserved]
if progress:
progress(0.05, f"准备 {component} AI 运行环境")
if preserved:
shutil.copytree(
self.current,
stage,
copy_function=self._copy_link,
symlinks=True,
dirs_exist_ok=True,
)
commands = []
commands.append(
[
sys.executable,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"--upgrade",
"--target",
str(stage),
"--constraint",
str(constraints),
"--requirement",
str(self._requirements(component)),
]
)
for requirement in AI_NO_DEPENDENCIES.get(component, ()):
commands.append(
[
sys.executable,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"--upgrade",
"--no-deps",
"--target",
str(stage),
requirement,
]
)
for index, install_command in enumerate(commands):
span = 0.68 / len(commands)
result = self._install_with_heartbeat(
install_command,
component=component,
phase_start=0.1 + index * span,
phase_end=0.1 + (index + 1) * span,
progress=progress,
environment=environment,
)
if result.returncode:
detail = self._redact(result.stderr or result.stdout, secret_values)
raise RuntimeError(f"AI 运行依赖安装失败:{detail.strip() or 'pip 返回错误'}")
if progress:
progress(0.86, "校验 AI 运行依赖")
for target_component in desired:
self._validate(stage, target_component, environment)
installed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
component_records = {
target_component: {
**self._component_lock(target_component),
"runtime_version": __version__,
"installed_at": installed_at,
}
for target_component in desired
}
# The preserved layer uses hard links for an atomic, space-
# efficient stage. Break the old stamp link before replacing
# it so a failed activation cannot mutate the active record.
(stage / self.stamp_path.name).unlink(missing_ok=True)
(stage / ".imagefind-runtime.json").write_text(
json.dumps(
{
"schema": AI_RUNTIME_SCHEMA,
"runtime_version": __version__,
"python_abi": self._python_abi(),
"components": component_records,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
if self.current.exists():
self.current.rename(backup)
try:
stage.rename(self.current)
except Exception:
if backup.exists():
backup.rename(self.current)
raise
shutil.rmtree(backup, ignore_errors=True)
importlib.invalidate_caches()
self._ensure_import_path()
self._invalidate_size()
self._states.pop(component, None)
if progress:
progress(1, "AI 运行依赖安装完成")
except subprocess.TimeoutExpired as exc:
self._states[component] = {"state": "error", "error": "AI 运行依赖安装超时"}
raise RuntimeError("AI 运行依赖安装超时,请检查网络或代理后重试") from exc
except Exception as exc:
detail = self._redact(str(exc), secret_values)
self._states[component] = {"state": "error", "error": detail[-300:]}
raise
finally:
shutil.rmtree(stage, ignore_errors=True)
class RuntimeToolManager:
"""Prefers compatible system tools and atomically installs a private fallback."""
def __init__(self, settings: Settings, client_factory: ClientFactory | None = None):
self.settings = settings
self.client_factory = client_factory or httpx.Client
self.root = settings.runtime_dir / "tools"
self._lock = threading.Lock()
self._resolved: dict[str, str] = {}
def set_client_factory(self, factory: ClientFactory) -> None:
self.client_factory = factory
@staticmethod
def _works(path: str, name: str) -> bool:
try:
result = subprocess.run(
[path, "version"] if name == "rclone" else [path, "-version"],
capture_output=True,
text=True,
timeout=10,
check=True,
)
if name != "rclone":
return bool(result.stdout.strip())
backends = subprocess.run(
[path, "help", "backends"],
capture_output=True,
text=True,
timeout=10,
check=True,
).stdout.lower()
return "webdav" in backends and "crypt" in backends
except (OSError, subprocess.SubprocessError):
return False
def _system_candidate(self, name: str, configured: str) -> str | None:
candidates: list[str] = []
found = shutil.which(configured)
if found:
candidates.append(found)
configured_path = Path(configured)
if configured_path.is_file():
candidates.append(str(configured_path))
candidates.extend(
str(path)
for path in (
Path(f"/var/apps/{name}/target/bin/{name}"),
Path(f"/usr/local/bin/{name}"),
Path(f"/usr/bin/{name}"),
)
if path.is_file()
)
for candidate in dict.fromkeys(candidates):
if self._works(candidate, name):
return candidate
return None
def _download(self, url: str, destination: Path) -> None:
partial = destination.with_suffix(destination.suffix + ".part")
last_error: Exception | None = None
for attempt in range(1, 4):
try:
existing = partial.stat().st_size if partial.exists() else 0
headers = {"Range": f"bytes={existing}-"} if existing else {}
with self.client_factory(follow_redirects=True, timeout=httpx.Timeout(120, connect=20)) as client:
with client.stream("GET", url, headers=headers) as response:
response.raise_for_status()
append = bool(existing and response.status_code == 206)
with partial.open("ab" if append else "wb") as output:
for chunk in response.iter_bytes(1024 * 1024):
output.write(chunk)
os.replace(partial, destination)
return
except (httpx.HTTPError, OSError) as exc:
last_error = exc
if attempt < 3:
time.sleep(attempt * 0.5)
raise RuntimeError("运行时工具下载失败,请检查网络或代理后重试") from last_error
@staticmethod
def _archive_member(archive, name: str):
members = archive.getmembers() if isinstance(archive, tarfile.TarFile) else archive.infolist()
return next(
(
member
for member in members
if Path(member.name if isinstance(member, tarfile.TarInfo) else member.filename).name == name
and (member.isfile() if isinstance(member, tarfile.TarInfo) else not member.is_dir())
),
None,
)
def _install_release(self, release_name: str) -> None:
release = TOOL_RELEASES[release_name]
self.settings.runtime_dir.mkdir(parents=True, exist_ok=True)
temporary = Path(tempfile.mkdtemp(prefix=f"{release_name}-", dir=self.settings.runtime_dir))
archive_path = temporary / ("release.zip" if release_name == "rclone" else "release.tar.xz")
stage = temporary / "stage"
stage.mkdir()
try:
if self.root.is_dir():
shutil.copytree(self.root, stage, dirs_exist_ok=True, symlinks=True)
self._download(str(release["url"]), archive_path)
opener = zipfile.ZipFile if release_name == "rclone" else tarfile.open
with opener(archive_path) as archive:
for name, digest in dict(release["files"]).items():
member = self._archive_member(archive, name)
if member is None:
raise RuntimeError(f"运行时工具包缺少 {name}")
source = (
archive.extractfile(member)
if isinstance(archive, tarfile.TarFile)
else archive.open(member)
)
if source is None:
raise RuntimeError(f"运行时工具包无法读取 {name}")
target = stage / name
with source, target.open("wb") as output:
shutil.copyfileobj(source, output)
if _sha256(target) != digest:
raise RuntimeError(f"{name} SHA-256 校验失败")
target.chmod(0o755)
backup = self.root.with_name(f"{self.root.name}.previous-{os.getpid()}-{time.time_ns()}")
if self.root.exists():
self.root.rename(backup)
try:
stage.rename(self.root)
except Exception:
if backup.exists():
backup.rename(self.root)
raise
shutil.rmtree(backup, ignore_errors=True)
finally:
shutil.rmtree(temporary, ignore_errors=True)
def _resolve(self, name: str, configured: str, release_name: str) -> str:
cached = self._resolved.get(name)
if cached and self._works(cached, name):
return cached
system = self._system_candidate(name, configured)
if system:
self._resolved[name] = system
return system
bundled = self.settings.bundled_tools_dir / name if self.settings.bundled_tools_dir else None
if bundled is not None and self._works(str(bundled), name):
self._resolved[name] = str(bundled)
return str(bundled)
private = self.root / name
if not self._works(str(private), name):
with self._lock:
if not self._works(str(private), name):
self._install_release(release_name)
if not self._works(str(private), name):
raise RuntimeError(f"{name} 运行时安装后兼容性检测失败")
self._resolved[name] = str(private)
return str(private)
def ffmpeg(self) -> str:
return self._resolve("ffmpeg", self.settings.ffmpeg_path, "ffmpeg")
def ffprobe(self) -> str:
return self._resolve("ffprobe", self.settings.ffprobe_path, "ffmpeg")
def rclone(self) -> str:
return self._resolve("rclone", self.settings.rclone_path, "rclone")
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
import asyncio
import json
import uuid
from .collections import apply_video_tags, assign_videos
from .database import Database, utcnow
from .jobs import JobQueue
from .metadata import ensure_inferred_metadata
from .openlist_native import OpenListNativeService
from .sources import SourceService, WebDavConnector
class Scanner:
def __init__(
self,
db: Database,
sources: SourceService,
jobs: JobQueue,
vectors,
openlist_native: OpenListNativeService | None = None,
):
self.db = db
self.sources = sources
self.jobs = jobs
self.vectors = vectors
self.openlist_native = openlist_native
def _scan_batch(self, source_id: str, scan_id: str, items: list, changed_ids: list[str]) -> None:
"""Apply one bounded discovery batch in a single PostgreSQL transaction."""
with self.db.transaction() as conn:
for item in items:
guard = conn.execute(
"SELECT 1 FROM ingest_guards WHERE source_id=? AND target_key=?",
(source_id, item.key),
).fetchone()
if guard:
conn.execute(
"UPDATE videos SET seen_scan_id=? WHERE source_id=? AND source_key=?",
(scan_id, source_id, item.key),
)
continue
tombstone = conn.execute(
"SELECT 1 FROM video_tombstones WHERE source_id=? AND source_key=?",
(source_id, item.key),
).fetchone()
if tombstone:
conn.execute(
"UPDATE videos SET available=0,status='offline',updated_at=? "
"WHERE source_id=? AND source_key=?",
(utcnow(), source_id, item.key),
)
continue
existing = conn.execute(
"SELECT id,fingerprint,indexed_fingerprint FROM videos WHERE source_id=? AND source_key=?",
(source_id, item.key),
).fetchone()
now = utcnow()
if existing:
video_id = existing["id"]
conn.execute(
"UPDATE videos SET display_name=?,location=?,size_bytes=?,modified_at=?,etag=?,"
"fingerprint=?,available=1,seen_scan_id=?,updated_at=? WHERE id=?",
(
item.display_name,
item.location,
item.size_bytes,
item.modified_at,
item.etag,
item.fingerprint,
scan_id,
now,
video_id,
),
)
if existing["indexed_fingerprint"] != item.fingerprint:
changed_ids.append(video_id)
else:
video_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO videos(id,source_id,source_key,display_name,location,size_bytes,"
"modified_at,etag,fingerprint,seen_scan_id,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(
video_id,
source_id,
item.key,
item.display_name,
item.location,
item.size_bytes,
item.modified_at,
item.etag,
item.fingerprint,
scan_id,
now,
now,
),
)
changed_ids.append(video_id)
ensure_inferred_metadata(conn, video_id, item.display_name)
async def handle(self, job_id: str, payload: dict) -> None:
await asyncio.to_thread(self.scan, job_id, payload["source_id"])
async def handle_refresh(self, job_id: str, payload: dict) -> None:
await asyncio.to_thread(
self.refresh_path,
job_id,
payload["source_id"],
payload["key"],
payload.get("upload_id"),
payload.get("restored_native"),
)
@staticmethod
def _native_refresh_record(upload_row, restored_native: dict | None) -> dict | None:
if (
upload_row
and upload_row["storage_backend"] == "openlist_native"
and upload_row["external_target_path"]
):
return dict(upload_row)
if not isinstance(restored_native, dict):
return None
physical_path = str(restored_native.get("physical_path") or "")
physical_size = int(restored_native.get("physical_size_bytes") or 0)
logical_size = int(restored_native.get("logical_size_bytes") or 0)
if not physical_path or physical_size <= 0 or logical_size < 0:
return None
return {
"storage_backend": "openlist_native",
"external_target_path": physical_path,
"external_size_bytes": physical_size,
"size_bytes": logical_size,
"content_sha256": "",
}
def refresh_path(
self,
job_id: str,
source_id: str,
key: str,
upload_id: str | None = None,
restored_native: dict | None = None,
) -> None:
connector: WebDavConnector | None = None
upload_row = None
if upload_id:
with self.db.read() as conn:
upload_row = conn.execute(
"SELECT title,collection_id,collection_parent_id,tag_ids_json,content_sha256,deduplicated,"
"storage_backend,external_target_path,external_size_bytes,size_bytes "
"FROM uploads WHERE id=?",
(upload_id,),
).fetchone()
native_catalog_record = (
self._native_refresh_record(upload_row, restored_native) if self.openlist_native else None
)
native_catalog = bool(
self.openlist_native
and native_catalog_record
and native_catalog_record["external_target_path"]
)
try:
if native_catalog:
item = self.openlist_native.catalog_item(source_id, key, native_catalog_record)
else:
connector = self.sources.connector(source_id)
item = connector.item(key)
now = utcnow()
with self.db.transaction() as conn:
upload = upload_row
# refresh_path is only used after an explicit user operation
# such as upload/restore/move, so it deliberately revives the
# path if the user reintroduced a previously removed file.
conn.execute(
"DELETE FROM video_tombstones WHERE source_id=? AND source_key=?",
(source_id, item.key),
)
existing = conn.execute(
"SELECT id,indexed_fingerprint,status FROM videos WHERE source_id=? AND source_key=?",
(source_id, item.key),
).fetchone()
if existing:
video_id = existing["id"]
preserve_index = bool(
upload
and upload["deduplicated"]
and existing["indexed_fingerprint"] == item.fingerprint
)
uploaded_fingerprint = (
item.fingerprint if upload and upload["content_sha256"] else None
)
conn.execute(
"UPDATE videos SET display_name=?,location=?,size_bytes=?,modified_at=?,etag=?,fingerprint=?,"
"available=1,status=?,content_sha256=coalesce(?,content_sha256),"
"content_fingerprint=coalesce(?,content_fingerprint),storage_backend=?,physical_path=?,"
"physical_size_bytes=?,updated_at=? "
"WHERE id=?",
(
item.display_name,
item.location,
item.size_bytes,
item.modified_at,
item.etag,
item.fingerprint,
existing["status"] if preserve_index else "pending",
upload["content_sha256"] if upload else None,
uploaded_fingerprint,
"openlist_native" if native_catalog else "legacy_webdav",
native_catalog_record["external_target_path"] if native_catalog else item.key,
int(native_catalog_record["external_size_bytes"] or item.size_bytes)
if native_catalog
else item.size_bytes,
now,
video_id,
),
)
else:
video_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO videos(id,source_id,source_key,display_name,location,size_bytes,modified_at,etag,"
"fingerprint,status,content_sha256,content_fingerprint,storage_backend,physical_path,"
"physical_size_bytes,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,'pending',?,?,?,?,?,?,?)",
(
video_id,
source_id,
item.key,
item.display_name,
item.location,
item.size_bytes,
item.modified_at,
item.etag,
item.fingerprint,
upload["content_sha256"] if upload else None,
item.fingerprint if upload and upload["content_sha256"] else None,
"openlist_native" if native_catalog else "legacy_webdav",
native_catalog_record["external_target_path"] if native_catalog else item.key,
int(native_catalog_record["external_size_bytes"] or item.size_bytes)
if native_catalog
else item.size_bytes,
now,
now,
),
)
ensure_inferred_metadata(conn, video_id, item.display_name)
if not (existing and preserve_index):
self.jobs.enqueue(
"index_video",
{"video_id": video_id},
dedupe_key=f"index:{video_id}",
connection=conn,
)
if upload_id:
title = str(upload["title"] or "").strip() if upload else ""
if title:
conn.execute(
"INSERT INTO video_metadata(video_id,title,updated_at) VALUES(?,?,?) "
"ON CONFLICT(video_id) DO UPDATE SET title=excluded.title,updated_at=excluded.updated_at",
(video_id, title, now),
)
warning = ""
if upload and upload["collection_id"]:
if conn.execute(
"SELECT 1 FROM collections WHERE id=?", (upload["collection_id"],)
).fetchone():
assign_videos(
conn,
str(upload["collection_id"]),
[video_id],
parent_id=(
str(upload["collection_parent_id"])
if upload["collection_parent_id"]
else None
),
)
else:
warning = ";所选合集已不存在"
if upload:
try:
selected_tags = list(dict.fromkeys(json.loads(upload["tag_ids_json"] or "[]")))
except (TypeError, json.JSONDecodeError):
selected_tags = []
if selected_tags:
placeholders = ",".join("?" for _ in selected_tags)
existing_tags = [
str(row["id"])
for row in conn.execute(
f"SELECT id FROM tags WHERE id IN ({placeholders})", selected_tags
).fetchall()
]
apply_video_tags(conn, [video_id], existing_tags)
if len(existing_tags) != len(selected_tags):
warning += ";部分所选分类已不存在"
conn.execute(
"UPDATE uploads SET status='completed',phase='ai_queued',progress=1,error=NULL,"
"failure_stage=NULL,external_state=CASE WHEN storage_backend='openlist_native' THEN 2 "
"ELSE external_state END,external_progress=CASE WHEN storage_backend='openlist_native' "
"THEN 1 ELSE external_progress END,external_status=CASE WHEN storage_backend='openlist_native' "
"THEN 'completed' ELSE external_status END,external_error=NULL,external_task_id=NULL,"
"message=?,updated_at=? "
"WHERE id=?",
(f"上传完成,AI 识别已独立排队{warning}", now, upload_id),
)
conn.execute("DELETE FROM ingest_guards WHERE upload_id=?", (upload_id,))
self.jobs.update(job_id, 1, "文件已加入媒体库,视频资料解析已排队")
except Exception as exc:
if upload_id:
with self.db.transaction() as conn:
conn.execute(
"UPDATE uploads SET status='failed',failure_stage='catalog',error=?,"
"message='文件已写入,但加入媒体库失败,可重试',updated_at=? WHERE id=?",
(str(exc)[:4000], utcnow(), upload_id),
)
raise
finally:
if isinstance(connector, WebDavConnector):
connector.close()
def scan(self, job_id: str, source_id: str) -> None:
scan_id = str(uuid.uuid4())
connector = self.sources.connector(source_id)
discovered = 0
changed_ids: list[str] = []
try:
with self.db.transaction() as conn:
conn.execute(
"UPDATE sources SET status='scanning',last_error=NULL,updated_at=? WHERE id=?",
(utcnow(), source_id),
)
batch: list = []
for item in connector.items():
discovered += 1
if discovered == 1 or discovered % 20 == 0:
self.jobs.checkpoint(job_id, f"已发现 {discovered} 个视频")
batch.append(item)
if len(batch) >= 100:
self._scan_batch(source_id, scan_id, batch, changed_ids)
batch.clear()
if discovered % 20 == 0:
self.jobs.update(job_id, min(0.9, discovered / (discovered + 100)), f"已发现 {discovered} 个视频")
if batch:
self._scan_batch(source_id, scan_id, batch, changed_ids)
with self.db.transaction() as conn:
removed = conn.execute(
"SELECT id FROM videos WHERE source_id=? AND available=1 AND "
"(seen_scan_id IS NULL OR seen_scan_id<>?) AND NOT EXISTS("
"SELECT 1 FROM ingest_guards ig WHERE ig.source_id=videos.source_id "
"AND ig.target_key=videos.source_key)",
(source_id, scan_id),
).fetchall()
conn.execute(
"UPDATE videos SET available=0,status='offline',updated_at=? WHERE source_id=? "
"AND (seen_scan_id IS NULL OR seen_scan_id<>?) AND NOT EXISTS("
"SELECT 1 FROM ingest_guards ig WHERE ig.source_id=videos.source_id "
"AND ig.target_key=videos.source_key)",
(utcnow(), source_id, scan_id),
)
conn.execute(
"UPDATE sources SET status='idle',last_scan_at=?,updated_at=? WHERE id=?",
(utcnow(), utcnow(), source_id),
)
for row in removed:
self.vectors.delete_video(row["id"])
for video_id in changed_ids:
self.jobs.enqueue("index_video", {"video_id": video_id}, dedupe_key=f"index:{video_id}")
self.jobs.update(job_id, 1, f"扫描完成:{discovered} 个视频,{len(changed_ids)} 个待解析")
except Exception as exc:
with self.db.transaction() as conn:
conn.execute(
"UPDATE sources SET status='error',last_error=?,updated_at=? WHERE id=?",
(str(exc)[:2000], utcnow(), source_id),
)
raise
finally:
if isinstance(connector, WebDavConnector):
connector.close()
+410
View File
@@ -0,0 +1,410 @@
from __future__ import annotations
import base64
import json
import threading
import time
import uuid
from collections import defaultdict
from pathlib import Path
from PIL import Image
from .ai import EmbeddingService, ModelUnavailable, difference_hash, hash_similarity
from .database import Database
from .text import chinese_variants, normalize_text, search_tokens
class SearchService:
def __init__(self, db: Database, embeddings: EmbeddingService, vectors):
self.db = db
self.embeddings = embeddings
self.vectors = vectors
self._session_guard = threading.Lock()
self._sessions: dict[str, tuple[float, list[dict]]] = {}
@staticmethod
def _cursor(session_id: str, offset: int) -> str:
raw = json.dumps([session_id, offset], separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
@staticmethod
def _decode_cursor(cursor: str | None) -> tuple[str, int] | None:
if not cursor:
return None
try:
padded = cursor + "=" * (-len(cursor) % 4)
value = json.loads(base64.urlsafe_b64decode(padded).decode())
if not isinstance(value, list) or len(value) != 2:
return None
return str(value[0]), max(0, int(value[1]))
except (ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError):
return None
def _cached_page(self, cursor: str | None, limit: int) -> dict | None:
decoded = self._decode_cursor(cursor)
if decoded is None:
return None
session_id, offset = decoded
now = time.monotonic()
with self._session_guard:
session = self._sessions.get(session_id)
if session is None or session[0] <= now:
self._sessions.pop(session_id, None)
raise ValueError("搜索结果已过期,请重新搜索")
items = session[1]
page = [dict(item) for item in items[offset : offset + limit]]
next_cursor = self._cursor(session_id, offset + limit) if offset + limit < len(items) else None
return {"items": page, "next_cursor": next_cursor, "total_candidates": len(items)}
def _store_page(self, items: list[dict], limit: int) -> dict:
now = time.monotonic()
session_id = uuid.uuid4().hex
with self._session_guard:
self._sessions = {
key: value for key, value in self._sessions.items() if value[0] > now
}
while len(self._sessions) >= 32:
oldest = min(self._sessions, key=lambda key: self._sessions[key][0])
self._sessions.pop(oldest, None)
self._sessions[session_id] = (now + 300, [dict(item) for item in items])
page = [dict(item) for item in items[:limit]]
next_cursor = self._cursor(session_id, limit) if limit < len(items) else None
return {"items": page, "next_cursor": next_cursor, "total_candidates": len(items)}
def _semantic(self, text: str | None, image_path: Path | None, source_ids: list[str] | None) -> list[str]:
rankings: list[list[str]] = []
if text:
try:
vector = self.embeddings.encode_text(text)
rankings.append([hit.id for hit in self.vectors.search("frames", vector, 300, source_ids)])
except ModelUnavailable:
pass
if image_path:
vector = self.embeddings.encode_image(image_path)
rankings.append([hit.id for hit in self.vectors.search("frames", vector, 500, source_ids)])
return [item for ranking in rankings for item in ranking]
def _text_rankings(
self,
text: str,
source_ids: list[str] | None,
kinds: set[str],
) -> tuple[dict[str, list[str]], dict[str, list[dict]]]:
variants = chinese_variants(text)
variant_tokens = [search_tokens(value) for value in variants]
variant_tokens = [tokens for tokens in variant_tokens if tokens]
if not variant_tokens:
return {}, {}
def quote(token: str) -> str:
return f"'{token.replace(chr(39), chr(39) * 2)}'"
partial_query = " | ".join(quote(token) for token in dict.fromkeys(
token for tokens in variant_tokens for token in tokens
))
complete_query = " | ".join(
f"({' & '.join(quote(token) for token in tokens)})" for tokens in variant_tokens
)
with self.db.read() as conn:
frame_ids: dict[str, list[str]] = defaultdict(list)
match_details: dict[str, list[dict]] = defaultdict(list)
# Each recognition kind owns its candidate budget. A library with
# hundreds of OCR rows can therefore no longer evict every audio hit
# before the Python-side result merger sees it.
for kind in sorted(kinds):
phrase_checks = " OR ".join("lower(e.raw_text) LIKE ?" for _ in variants)
args: list[object] = [
*(f"%{value}%" for value in variants),
complete_query,
partial_query,
kind,
partial_query,
]
source_filter = ""
if source_ids:
source_filter = f" AND v.source_id IN ({','.join('?' for _ in source_ids)})"
args.extend(source_ids)
rows = conn.execute(
"SELECT e.id,e.video_id,coalesce(e.frame_id,nearest.id) AS resolved_frame_id,"
"e.start_ms,e.end_ms,e.kind,e.raw_text,"
f"CASE WHEN ({phrase_checks}) THEN 3 "
"WHEN e.search_vector @@ to_tsquery('simple',?) THEN 2 ELSE 1 END AS match_tier,"
"ts_rank_cd(e.search_vector,to_tsquery('simple',?)) AS rank "
"FROM text_entries e JOIN videos v ON v.id=e.video_id "
"LEFT JOIN LATERAL (SELECT f.id FROM frames f WHERE f.video_id=e.video_id "
"ORDER BY abs(f.timestamp_ms-e.start_ms),f.id LIMIT 1) nearest ON TRUE "
"WHERE e.kind=? AND e.search_vector @@ to_tsquery('simple',?) AND v.available=1 "
"AND (e.kind<>'audio' OR coalesce(e.quality_score,1)>=0.55) "
+ source_filter
+ " ORDER BY match_tier DESC,rank DESC,e.start_ms LIMIT 500",
args,
).fetchall()
for row in rows:
frame_id = row["resolved_frame_id"]
if frame_id:
frame_ids[row["kind"]].append(frame_id)
detail_type = "metadata" if row["kind"] in {"filename", "path"} else row["kind"]
if len(match_details[frame_id]) < 3:
match_details[frame_id].append(
{
"type": detail_type,
"text": row["raw_text"],
"start_ms": row["start_ms"],
"end_ms": row["end_ms"],
}
)
return dict(frame_ids), dict(match_details)
def _person_ranking(self, text: str, source_ids: list[str] | None, *, exact: bool = False) -> list[str]:
normalized = normalize_text(text)
if not normalized:
return []
args: list[object] = [normalized, text.strip()] if exact else [f"%{normalized}%", f"%{normalized}%"]
source_filter = ""
if source_ids:
source_filter = f" AND v.source_id IN ({','.join('?' for _ in source_ids)})"
args.extend(source_ids)
with self.db.read() as conn:
rows = conn.execute(
"SELECT DISTINCT f.frame_id FROM people p JOIN faces f ON f.person_id=p.id "
"JOIN videos v ON v.id=f.video_id WHERE p.is_named=1 AND p.hidden=0 "
+ ("AND (p.normalized_name = ? OR lower(p.name) = lower(?)) AND v.available=1 " if exact else
"AND (p.normalized_name ILIKE ? OR p.name ILIKE ?) AND v.available=1 ")
+ source_filter
+ " LIMIT 500",
args,
).fetchall()
return [row["frame_id"] for row in rows]
def _metadata_ranking(self, text: str, source_ids: list[str] | None, *, exact: bool = False) -> list[str]:
value = text.strip()
if not value:
return []
pattern = value if exact else f"%{value}%"
args: list[object] = [pattern] * (3 if exact else 7)
source_filter = ""
if source_ids:
source_filter = f" AND v.source_id IN ({','.join('?' for _ in source_ids)})"
args.extend(source_ids)
with self.db.read() as conn:
condition = (
"(lower(coalesce(vm.title,''))=lower(?) OR lower(coalesce(a.name,''))=lower(?) "
"OR lower(coalesce(t.name,''))=lower(?))"
if exact
else "(coalesce(vm.title,'') ILIKE ? OR coalesce(vm.catalog_code,'') ILIKE ? "
"OR coalesce(vm.studio,'') ILIKE ? OR coalesce(vm.series,'') ILIKE ? "
"OR coalesce(vm.description,'') ILIKE ? OR coalesce(a.name,'') ILIKE ? "
"OR coalesce(t.name,'') ILIKE ?)"
)
rows = conn.execute(
"SELECT DISTINCT (SELECT f.id FROM frames f WHERE f.video_id=v.id ORDER BY f.timestamp_ms LIMIT 1) "
"AS frame_id FROM videos v LEFT JOIN video_metadata vm ON vm.video_id=v.id "
"LEFT JOIN video_actors va ON va.video_id=v.id LEFT JOIN actors a ON a.id=va.actor_id "
"LEFT JOIN video_tags vt ON vt.video_id=v.id LEFT JOIN tags t ON t.id=vt.tag_id "
"WHERE v.available=1 AND " + condition
+ source_filter
+ " LIMIT 500",
args,
).fetchall()
return [row["frame_id"] for row in rows if row["frame_id"]]
@staticmethod
def _rrf(rankings: list[list[str]], constant: int = 60) -> dict[str, float]:
scores: dict[str, float] = defaultdict(float)
for ranking in rankings:
seen: set[str] = set()
for rank, item_id in enumerate(ranking, 1):
if item_id in seen:
continue
seen.add(item_id)
scores[item_id] += 1 / (constant + rank)
return scores
def search(
self,
*,
text: str | None,
image_path: Path | None,
source_ids: list[str] | None,
min_duration_ms: int | None,
max_duration_ms: int | None,
min_width: int | None,
has_people: bool | None,
tag_ids: list[str] | None,
sort: str,
limit: int,
cursor: str | None,
recognition_types: list[str] | None = None,
) -> dict:
if not text and not image_path:
raise ValueError("文字和图片至少提供一项")
if cursor:
cached = self._cached_page(cursor, limit)
if cached is not None:
return cached
rankings: list[list[str]] = []
match_sources: dict[str, set[str]] = defaultdict(set)
match_details: dict[str, list[dict]] = defaultdict(list)
allowed_types = set(recognition_types or ("visual", "ocr", "person", "audio", "subtitle", "metadata"))
supported_types = {"visual", "ocr", "person", "audio", "subtitle", "metadata"}
if not allowed_types <= supported_types:
raise ValueError("搜索识别类型无效")
def add_ranking(name: str, ranking: list[str]) -> None:
if not ranking:
return
rankings.append(ranking)
for frame_id in ranking:
match_sources[frame_id].add(name)
if text:
compact = "".join(character for character in text.strip() if character.isalnum())
cjk_count = sum("\u3400" <= character <= "\u9fff" for character in compact)
short_exact = bool(compact) and (
(cjk_count == len(compact) and cjk_count < 2)
or (cjk_count == 0 and len(compact) < 3)
)
text_kinds = set()
if "ocr" in allowed_types and not short_exact:
text_kinds.add("ocr")
if "audio" in allowed_types and not short_exact:
text_kinds.add("audio")
if "subtitle" in allowed_types and not short_exact:
text_kinds.add("subtitle")
if "metadata" in allowed_types and not short_exact:
text_kinds.update(("filename", "path"))
text_rankings, text_details = self._text_rankings(text, source_ids, text_kinds)
for frame_id, details in text_details.items():
match_details[frame_id].extend(details)
for kind, ranking in text_rankings.items():
add_ranking(kind, ranking)
if "person" in allowed_types:
person = self._person_ranking(text, source_ids, exact=short_exact)
add_ranking("person", person)
if "metadata" in allowed_types:
add_ranking("metadata", self._metadata_ranking(text, source_ids, exact=short_exact))
if "visual" in allowed_types and not short_exact:
try:
vector = self.embeddings.encode_text(text)
add_ranking(
"semantic",
[hit.id for hit in self.vectors.search("frames", vector, 500, source_ids)],
)
except ModelUnavailable:
pass
query_hash = None
if image_path:
if "visual" not in allowed_types:
raise ValueError("以图搜索必须启用画面语义类型")
with Image.open(image_path) as image:
query_hash = difference_hash(image)
vector = self.embeddings.encode_image(image_path)
add_ranking(
"image",
[hit.id for hit in self.vectors.search("frames", vector, 800, source_ids)],
)
scores = self._rrf(rankings)
if not scores:
return {"items": [], "next_cursor": None, "total_candidates": 0}
ids = list(scores)
placeholders = ",".join("?" for _ in ids)
args: list[object] = ids
where = [f"f.id IN ({placeholders})", "v.available=1"]
if min_duration_ms is not None:
where.append("v.duration_ms>=?")
args.append(min_duration_ms)
if max_duration_ms is not None:
where.append("v.duration_ms<=?")
args.append(max_duration_ms)
if min_width is not None:
where.append("coalesce(v.width,0)>=?")
args.append(min_width)
if has_people is not None:
expression = (
"(EXISTS(SELECT 1 FROM faces sf WHERE sf.video_id=v.id) "
"OR EXISTS(SELECT 1 FROM video_actors sva WHERE sva.video_id=v.id))"
)
where.append(expression if has_people else f"NOT {expression}")
if tag_ids:
where.append(
f"EXISTS(SELECT 1 FROM video_tags svt WHERE svt.video_id=v.id "
f"AND svt.tag_id IN ({','.join('?' for _ in tag_ids)}))"
)
args.extend(tag_ids)
with self.db.read() as conn:
rows = conn.execute(
"SELECT f.id AS frame_id,f.timestamp_ms,f.segment_start_ms,f.segment_end_ms,"
"f.thumbnail_path,f.perceptual_hash,v.id AS video_id,coalesce(vm.title,v.display_name) AS display_name,"
"vm.catalog_code,v.source_key,"
"v.duration_ms,v.width,v.height,v.source_id,v.updated_at,s.name AS source_name,s.kind AS source_kind "
"FROM frames f JOIN videos v ON v.id=f.video_id JOIN sources s ON s.id=v.source_id "
"LEFT JOIN video_metadata vm ON vm.video_id=v.id WHERE "
+ " AND ".join(where),
args,
).fetchall()
candidates = []
for row in rows:
item = dict(row)
score = scores[item["frame_id"]]
if query_hash:
similarity = hash_similarity(query_hash, item["perceptual_hash"])
if similarity >= 0.75:
score += 0.02 * similarity
item["near_duplicate"] = True
item["score"] = score
item["match_sources"] = sorted(match_sources[item["frame_id"]])
item["match_details"] = match_details[item["frame_id"]][:3]
timed = next(
(detail for detail in item["match_details"] if detail["end_ms"] > detail["start_ms"]),
None,
)
if timed:
item["timestamp_ms"] = timed["start_ms"]
item["segment_start_ms"] = timed["start_ms"]
item["segment_end_ms"] = timed["end_ms"]
candidates.append(item)
if sort == "newest":
candidates.sort(key=lambda item: item.get("updated_at") or "", reverse=True)
elif sort == "duration":
candidates.sort(key=lambda item: item.get("duration_ms") or 0, reverse=True)
else:
candidates.sort(key=lambda item: item["score"], reverse=True)
# Merge near-adjacent hits from the same video while preserving separate scenes.
merged: list[dict] = []
by_video: dict[str, list[int]] = defaultdict(list)
for item in candidates:
duplicate = False
for position in by_video[item["video_id"]]:
prior = merged[position]
if abs(prior["timestamp_ms"] - item["timestamp_ms"]) <= 10000:
prior["segment_start_ms"] = min(prior["segment_start_ms"], item["segment_start_ms"])
prior["segment_end_ms"] = max(prior["segment_end_ms"], item["segment_end_ms"])
prior["match_sources"] = sorted(set(prior["match_sources"]) | set(item["match_sources"]))
known = {
(detail["type"], detail["text"], detail["start_ms"], detail["end_ms"])
for detail in prior["match_details"]
}
for detail in item["match_details"]:
key = (detail["type"], detail["text"], detail["start_ms"], detail["end_ms"])
if key not in known and len(prior["match_details"]) < 3:
prior["match_details"].append(detail)
known.add(key)
duplicate = True
break
if not duplicate:
by_video[item["video_id"]].append(len(merged))
merged.append(item)
if sort == "newest":
merged.sort(key=lambda item: item.get("updated_at") or "", reverse=True)
elif sort == "duration":
merged.sort(key=lambda item: item.get("duration_ms") or 0, reverse=True)
else:
merged.sort(key=lambda item: item["score"], reverse=True)
for item in merged:
item["thumbnail_url"] = f"/api/v1/frames/{item['frame_id']}/thumbnail"
item["playback_url"] = f"/api/v1/videos/{item['video_id']}/stream"
item.pop("thumbnail_path", None)
item.pop("perceptual_hash", None)
return self._store_page(merged, limit)
+451
View File
@@ -0,0 +1,451 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
import threading
import time
import uuid
from collections import defaultdict, deque
from datetime import UTC, datetime, timedelta
from pathlib import Path
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from cryptography.fernet import Fernet, InvalidToken
from .database import Database, utcnow
class SecretStore:
def __init__(self, key_path: Path):
self.key_path = key_path
if not key_path.exists():
key_path.write_bytes(Fernet.generate_key())
os.chmod(key_path, 0o600)
self._fernet = Fernet(key_path.read_bytes())
def encrypt_json(self, value: dict) -> str:
raw = json.dumps(value, ensure_ascii=False).encode()
return self._fernet.encrypt(raw).decode()
def decrypt_json(self, value: str | None) -> dict:
if not value:
return {}
try:
return json.loads(self._fernet.decrypt(value.encode()))
except (InvalidToken, json.JSONDecodeError) as exc:
raise ValueError("无法解密数据源凭据") from exc
class LoginRateLimitError(ValueError):
def __init__(self, retry_after: int):
self.retry_after = max(1, retry_after)
super().__init__(f"登录尝试过于频繁,请在 {self.retry_after} 秒后重试")
class AuthService:
GATEWAY_MEDIA_TOKEN_HOURS = 12
GATEWAY_MEDIA_TOKENS_PER_SESSION = 8
AUTH_CACHE_SECONDS = 30
TOKEN_SCOPES = frozenset({"admin", "webdav", "media:read"})
LOGIN_WINDOW_SECONDS = 300
LOGIN_MAX_FAILURES = 5
LOGIN_LOCK_SECONDS = 900
def __init__(self, db: Database, session_days: int):
self.db = db
self.session_days = session_days
self.passwords = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2)
self._auth_cache_lock = threading.Lock()
self._session_cache: dict[str, tuple[float, dict]] = {}
self._api_token_cache: dict[str, tuple[float, dict]] = {}
self._token_usage_lock = threading.Lock()
self._token_usage_pending: dict[str, str] = {}
self._login_lock = threading.Lock()
self._login_failures: dict[str, deque[float]] = defaultdict(deque)
self._login_blocked_until: dict[str, float] = {}
self._password_verifiers = threading.BoundedSemaphore(2)
with self.db.read() as conn:
self._configured = conn.execute("SELECT 1 FROM admin WHERE id=1").fetchone() is not None
@staticmethod
def _digest(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def is_configured(self) -> bool:
# This powers the unauthenticated health endpoint. Keep it independent
# of filesystem and SQLite latency so upload writeback cannot make the
# reverse proxy or UI conclude that the whole application is down.
return self._configured
@staticmethod
def validate_password(password: str) -> None:
if len(password) < 10:
raise ValueError("管理员密码至少需要 10 个字符")
if len(password) > 256:
raise ValueError("管理员密码不能超过 256 个字符")
def set_password(self, password: str, *, replace: bool = False) -> None:
self.validate_password(password)
password_hash = self.passwords.hash(password)
with self.db.transaction() as conn:
configured = conn.execute("SELECT 1 FROM admin WHERE id=1").fetchone() is not None
if configured and not replace:
raise ValueError("管理员已经初始化")
now = utcnow()
if configured:
conn.execute("UPDATE admin SET password_hash=?,updated_at=? WHERE id=1", (password_hash, now))
conn.execute("DELETE FROM sessions")
else:
conn.execute(
"INSERT INTO admin(id,password_hash,created_at,updated_at) VALUES(1,?,?,?)",
(password_hash, now, now),
)
self._configured = True
with self._auth_cache_lock:
self._session_cache.clear()
def change_password(self, current_password: str, new_password: str) -> None:
"""Change the single administrator password and revoke every browser session."""
self.validate_password(new_password)
with self.db.read() as conn:
row = conn.execute("SELECT password_hash FROM admin WHERE id=1").fetchone()
if row is None:
raise ValueError("应用尚未初始化")
try:
valid = self.passwords.verify(row["password_hash"], current_password)
except VerifyMismatchError as exc:
raise ValueError("当前密码错误") from exc
if not valid:
raise ValueError("当前密码错误")
password_hash = self.passwords.hash(new_password)
with self.db.transaction() as conn:
conn.execute("UPDATE admin SET password_hash=?,updated_at=? WHERE id=1", (password_hash, utcnow()))
conn.execute("DELETE FROM sessions")
with self._auth_cache_lock:
self._session_cache.clear()
def setup(self, password: str) -> None:
self.set_password(password)
def _create_session(
self,
*,
remember_device: bool,
auth_source: str,
external_user_id: str | None = None,
external_username: str | None = None,
) -> tuple[str, str, datetime]:
if auth_source not in {"local", "gateway"}:
raise ValueError("不支持的登录来源")
if auth_source == "gateway" and not external_user_id:
raise ValueError("缺少飞牛用户身份")
token = secrets.token_urlsafe(48)
csrf = secrets.token_urlsafe(32)
expires = datetime.now(UTC) + timedelta(days=self.session_days if remember_device else 1)
now = utcnow()
with self.db.transaction() as conn:
conn.execute("DELETE FROM gateway_media_tokens WHERE expires_at < ?", (now,))
conn.execute("DELETE FROM sessions WHERE expires_at < ?", (now,))
conn.execute(
"INSERT INTO sessions(token_hash,csrf_token,expires_at,created_at,auth_source,"
"external_user_id,external_username) VALUES(?,?,?,?,?,?,?)",
(
self._digest(token),
csrf,
expires.isoformat(),
now,
auth_source,
external_user_id,
external_username,
),
)
session = {
"csrf_token": csrf,
"expires_at": expires.isoformat(),
"auth_source": auth_source,
"external_user_id": external_user_id,
"external_username": external_username,
}
with self._auth_cache_lock:
self._session_cache[self._digest(token)] = (
time.monotonic() + self.AUTH_CACHE_SECONDS,
session,
)
return token, csrf, expires
def _check_login_rate(self, client_key: str) -> None:
now = time.monotonic()
with self._login_lock:
blocked_until = self._login_blocked_until.get(client_key, 0)
if blocked_until > now:
raise LoginRateLimitError(int(blocked_until - now) + 1)
failures = self._login_failures[client_key]
while failures and failures[0] <= now - self.LOGIN_WINDOW_SECONDS:
failures.popleft()
def _record_login_failure(self, client_key: str) -> None:
now = time.monotonic()
with self._login_lock:
failures = self._login_failures[client_key]
while failures and failures[0] <= now - self.LOGIN_WINDOW_SECONDS:
failures.popleft()
failures.append(now)
if len(failures) >= self.LOGIN_MAX_FAILURES:
self._login_blocked_until[client_key] = now + self.LOGIN_LOCK_SECONDS
failures.clear()
def login(
self,
password: str,
remember_device: bool = True,
*,
client_key: str = "local",
) -> tuple[str, str, datetime]:
client_key = client_key.strip()[:256] or "unknown"
self._check_login_rate(client_key)
with self.db.read() as conn:
row = conn.execute("SELECT password_hash FROM admin WHERE id=1").fetchone()
if row is None:
raise ValueError("应用尚未初始化")
if not self._password_verifiers.acquire(timeout=2):
raise LoginRateLimitError(3)
try:
try:
valid = self.passwords.verify(row["password_hash"], password)
except VerifyMismatchError as exc:
self._record_login_failure(client_key)
raise ValueError("密码错误") from exc
finally:
self._password_verifiers.release()
if not valid:
self._record_login_failure(client_key)
raise ValueError("密码错误")
with self._login_lock:
self._login_failures.pop(client_key, None)
self._login_blocked_until.pop(client_key, None)
return self._create_session(remember_device=remember_device, auth_source="local")
def login_gateway(self, user_id: str, username: str | None) -> tuple[str, str, datetime]:
if not self.is_configured():
raise ValueError("应用尚未初始化")
user_id = user_id.strip()
username = (username or "").strip()[:256] or None
if not user_id or len(user_id) > 256:
raise ValueError("飞牛用户身份无效")
return self._create_session(
remember_device=True,
auth_source="gateway",
external_user_id=user_id,
external_username=username,
)
def session(self, token: str | None) -> dict | None:
if not token:
return None
digest = self._digest(token)
monotonic_now = time.monotonic()
with self._auth_cache_lock:
cached = self._session_cache.get(digest)
if cached and cached[0] >= monotonic_now:
return dict(cached[1])
self._session_cache.pop(digest, None)
now = utcnow()
# Authentication is on every API request and therefore must remain a
# WAL reader. Deleting expired sessions here used to turn every GET,
# WebDAV HEAD and media request into a SQLite writer. Under a batch DAV
# upload those tiny writes queued behind AI saves and delayed
# ``100 Continue`` long enough for clients to report a network error.
with self.db.read() as conn:
row = conn.execute(
"SELECT csrf_token,expires_at,auth_source,external_user_id,external_username "
"FROM sessions WHERE token_hash=? AND expires_at>=?",
(digest, now),
).fetchone()
if row is None:
return None
session = dict(row)
with self._auth_cache_lock:
self._session_cache[digest] = (
monotonic_now + self.AUTH_CACHE_SECONDS,
session,
)
return dict(session)
def create_gateway_media_token(self, session_token: str | None) -> tuple[str, datetime]:
"""Issue an opaque, media-only token linked to an active gateway session.
The full gateway session never needs to appear in an image or video URL.
Multiple tokens are retained for independent tabs, with an intentionally
small per-session cap so abandoned tabs cannot grow the table forever.
"""
if not session_token:
raise ValueError("飞牛管理员会话需要重新建立")
session_hash = self._digest(session_token)
now = datetime.now(UTC)
token = secrets.token_urlsafe(32)
with self.db.transaction() as conn:
conn.execute("DELETE FROM gateway_media_tokens WHERE expires_at < ?", (now.isoformat(),))
row = conn.execute(
"SELECT expires_at,auth_source FROM sessions WHERE token_hash=? AND expires_at>=?",
(session_hash, now.isoformat()),
).fetchone()
if row is None or row["auth_source"] != "gateway":
raise ValueError("飞牛管理员会话需要重新建立")
session_expires = datetime.fromisoformat(row["expires_at"])
expires = min(session_expires, now + timedelta(hours=self.GATEWAY_MEDIA_TOKEN_HOURS))
conn.execute(
"INSERT INTO gateway_media_tokens(token_hash,session_token_hash,expires_at,created_at) "
"VALUES(?,?,?,?)",
(self._digest(token), session_hash, expires.isoformat(), now.isoformat()),
)
conn.execute(
"DELETE FROM gateway_media_tokens WHERE token_hash IN ("
"SELECT token_hash FROM gateway_media_tokens WHERE session_token_hash=? "
"ORDER BY created_at DESC OFFSET ?)",
(session_hash, self.GATEWAY_MEDIA_TOKENS_PER_SESSION),
)
return token, expires
def gateway_media_session(self, token: str | None, external_user_id: str) -> dict | None:
if not token or not external_user_id:
return None
now = utcnow()
with self.db.read() as conn:
row = conn.execute(
"SELECT s.csrf_token,s.expires_at,s.auth_source,s.external_user_id,s.external_username "
"FROM gateway_media_tokens mt JOIN sessions s ON s.token_hash=mt.session_token_hash "
"WHERE mt.token_hash=? AND mt.expires_at>=? AND s.expires_at>=? "
"AND s.auth_source='gateway' AND s.external_user_id=?",
(self._digest(token), now, now, external_user_id),
).fetchone()
return dict(row) if row else None
def logout(self, token: str | None) -> None:
if token:
digest = self._digest(token)
with self.db.transaction() as conn:
conn.execute("DELETE FROM sessions WHERE token_hash=?", (digest,))
with self._auth_cache_lock:
self._session_cache.pop(digest, None)
@classmethod
def _scopes(cls, scopes) -> list[str]:
values = list(dict.fromkeys(str(value).strip() for value in (scopes or ["admin"])))
if not values or any(value not in cls.TOKEN_SCOPES for value in values):
raise ValueError("API Token 权限范围无效")
return values
def create_api_token(self, name: str, scopes=None) -> tuple[str, str]:
token_id = str(uuid.uuid4())
secret = "if_" + secrets.token_urlsafe(40)
token_scopes = self._scopes(scopes)
with self.db.transaction() as conn:
conn.execute(
"INSERT INTO api_tokens(id,name,token_hash,scopes_json,created_at) VALUES(?,?,?,?,?)",
(token_id, name.strip() or "API Token", self._digest(secret), json.dumps(token_scopes), utcnow()),
)
with self._auth_cache_lock:
self._api_token_cache[self._digest(secret)] = (
time.monotonic() + self.AUTH_CACHE_SECONDS,
{"id": token_id, "scopes": token_scopes},
)
return token_id, secret
def verify_api_token(self, token: str | None, required_scope: str = "admin") -> bool:
if not token:
return False
digest = self._digest(token)
monotonic_now = time.monotonic()
with self._auth_cache_lock:
cached = self._api_token_cache.get(digest)
if cached and cached[0] >= monotonic_now:
token_info = cached[1]
else:
self._api_token_cache.pop(digest, None)
token_info = None
if token_info is not None:
scopes = set(token_info["scopes"])
if "admin" not in scopes and required_scope not in scopes:
return False
with self._token_usage_lock:
self._token_usage_pending[token_info["id"]] = utcnow()
return True
with self.db.read() as conn:
row = conn.execute(
"SELECT id,scopes_json FROM api_tokens WHERE token_hash=? AND revoked_at IS NULL", (digest,)
).fetchone()
if row is None:
return False
token_id = str(row["id"])
try:
scopes = self._scopes(json.loads(row["scopes_json"]))
except (TypeError, ValueError, json.JSONDecodeError):
scopes = ["admin"]
with self._auth_cache_lock:
self._api_token_cache[digest] = (
monotonic_now + self.AUTH_CACHE_SECONDS,
{"id": token_id, "scopes": scopes},
)
if "admin" not in scopes and required_scope not in scopes:
return False
# Coalesce audit metadata in memory. A DAV client can issue hundreds of
# PUT/HEAD/PROPFIND/MOVE requests for one batch; flushing one row later
# must never serialize every authentication check on SQLite's writer.
with self._token_usage_lock:
self._token_usage_pending[token_id] = utcnow()
return True
def flush_api_token_usage(self) -> int:
with self._token_usage_lock:
pending = self._token_usage_pending
self._token_usage_pending = {}
if not pending:
return 0
try:
self.db.write_with_retry(
lambda conn: conn.executemany(
"UPDATE api_tokens SET last_used_at=? WHERE id=? AND revoked_at IS NULL",
((used_at, token_id) for token_id, used_at in pending.items()),
),
timeout_seconds=5,
)
except Exception:
# Preserve the newest pending timestamp so a transient writer lock
# can be retried by the next maintenance sweep.
with self._token_usage_lock:
for token_id, used_at in pending.items():
self._token_usage_pending.setdefault(token_id, used_at)
raise
return len(pending)
def revoke_api_token(self, token_id: str) -> None:
with self.db.transaction() as conn:
conn.execute("UPDATE api_tokens SET revoked_at=? WHERE id=?", (utcnow(), token_id))
with self._auth_cache_lock:
self._api_token_cache = {
digest: cached
for digest, cached in self._api_token_cache.items()
if cached[1]["id"] != token_id
}
with self._token_usage_lock:
self._token_usage_pending.pop(token_id, None)
def opaque_cursor(offset: int) -> str:
return base64.urlsafe_b64encode(str(offset).encode()).decode().rstrip("=")
def decode_cursor(cursor: str | None) -> int:
if not cursor:
return 0
try:
padded = cursor + "=" * (-len(cursor) % 4)
return max(0, int(base64.urlsafe_b64decode(padded).decode()))
except (ValueError, UnicodeDecodeError):
return 0
+593
View File
@@ -0,0 +1,593 @@
from __future__ import annotations
import hashlib
import json
import os
import posixpath
import re
import secrets
import uuid
import xml.etree.ElementTree as ET
from collections import deque
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from pathlib import Path, PurePosixPath
from urllib.parse import quote, unquote, urljoin, urlparse
import httpx
from .config import Settings
from .database import Database, utcnow
from .remote import AlistClient, RcloneManager, alist_webdav_url, quoted_path, safe_relative_path
from .runtime import RuntimeToolManager
from .security import SecretStore
VIDEO_EXTENSIONS = {
".3gp",
".asf",
".avi",
".flv",
".m2ts",
".m4v",
".mkv",
".mov",
".mp4",
".mpeg",
".mpg",
".mts",
".ogv",
".ts",
".vob",
".webm",
".wmv",
}
@dataclass(slots=True)
class SourceItem:
key: str
display_name: str
location: str
size_bytes: int
modified_at: str | None
etag: str | None
fingerprint: str
@dataclass(slots=True)
class RemoteAccess:
url: str
username: str
password: str
verify_tls: bool
_release: Callable[[], None] | None = None
_closed: bool = False
def close(self) -> None:
if self._closed:
return
self._closed = True
if self._release is not None:
self._release()
def as_tuple(self) -> tuple[str, str, str, bool]:
return self.url, self.username, self.password, self.verify_tls
def __enter__(self) -> RemoteAccess:
return self
def __exit__(self, *_args) -> None:
self.close()
def _fingerprint(*parts: object) -> str:
value = "\0".join("" if part is None else str(part) for part in parts)
return hashlib.sha256(value.encode()).hexdigest()
class LocalConnector:
def __init__(self, root: str):
self.root = Path(root).expanduser().resolve(strict=True)
if not self.root.is_dir():
raise ValueError("本地数据源必须是目录")
def items(self) -> Iterator[SourceItem]:
for current, directories, filenames in os.walk(self.root, followlinks=False):
directories[:] = [
name
for name in directories
if name != ".imagefind-trash" and not (Path(current) / name).is_symlink()
]
for filename in filenames:
path = Path(current) / filename
if path.suffix.lower() not in VIDEO_EXTENSIONS or path.is_symlink():
continue
try:
stat = path.stat()
resolved = path.resolve(strict=True)
resolved.relative_to(self.root)
except (OSError, ValueError):
continue
key = resolved.relative_to(self.root).as_posix()
modified = datetime.fromtimestamp(stat.st_mtime, UTC).isoformat()
yield SourceItem(
key=key,
display_name=path.name,
location=str(resolved),
size_bytes=stat.st_size,
modified_at=modified,
etag=None,
fingerprint=_fingerprint(stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns),
)
def item(self, key: str) -> SourceItem:
relative = safe_relative_path(key, allow_empty=False)
path = (self.root / relative).resolve(strict=True)
path.relative_to(self.root)
if not path.is_file() or path.is_symlink() or path.suffix.lower() not in VIDEO_EXTENSIONS:
raise KeyError(relative)
stat = path.stat()
modified = datetime.fromtimestamp(stat.st_mtime, UTC).isoformat()
return SourceItem(
key=relative,
display_name=path.name,
location=str(path),
size_bytes=stat.st_size,
modified_at=modified,
etag=None,
fingerprint=_fingerprint(stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns),
)
class WebDavConnector:
def __init__(
self,
base_url: str,
username: str,
password: str,
*,
verify_tls: bool = True,
timeout: float = 30,
on_close: Callable[[], None] | None = None,
):
self.base_url = base_url.rstrip("/") + "/"
parsed = urlparse(self.base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("WebDAV 地址必须是 http 或 https URL")
self._base = parsed
self.client = httpx.Client(
auth=httpx.BasicAuth(username, password),
verify=verify_tls,
timeout=httpx.Timeout(timeout),
follow_redirects=True,
trust_env=False,
)
self._on_close = on_close
self._closed = False
def close(self) -> None:
if self._closed:
return
self._closed = True
self.client.close()
if self._on_close is not None:
self._on_close()
def _safe_url(self, href: str) -> str | None:
url = urljoin(self.base_url, href)
parsed = urlparse(url)
base_path = posixpath.normpath(unquote(self._base.path))
path = posixpath.normpath(unquote(parsed.path))
if parsed.scheme != self._base.scheme or parsed.netloc != self._base.netloc:
return None
if path != base_path.rstrip("/") and not path.startswith(base_path.rstrip("/") + "/"):
return None
return url
@staticmethod
def _parse_multistatus(content: bytes) -> ET.Element:
try:
return ET.fromstring(content)
except ET.ParseError as original:
text = content.decode("utf-8", errors="replace")
match = re.search(r"<(?P<prefix>[A-Za-z_][\w.-]*:)?multistatus\b", text)
if not match:
raise RuntimeError(f"WebDAV PROPFIND 返回 XML 无法解析:{original}") from original
prefix = match.group("prefix") or ""
closing = f"</{prefix}multistatus>"
end = text.find(closing, match.end())
if end < 0:
raise RuntimeError(f"WebDAV PROPFIND 返回 XML 无法解析:{original}") from original
fragment = text[match.start() : end + len(closing)]
try:
return ET.fromstring(fragment.encode("utf-8"))
except ET.ParseError as exc:
raise RuntimeError(f"WebDAV PROPFIND 返回 XML 无法解析:{exc}") from exc
def _list(self, url: str, depth: str = "1") -> list[tuple[str, bool, int, str | None, str | None]]:
response = self.client.request(
"PROPFIND",
url,
headers={"Depth": depth, "Content-Type": "application/xml; charset=utf-8"},
content=(
"<?xml version='1.0' encoding='utf-8'?>"
"<d:propfind xmlns:d='DAV:'><d:prop>"
"<d:resourcetype/><d:getcontentlength/><d:getlastmodified/><d:getetag/>"
"</d:prop></d:propfind>"
),
)
if response.status_code != 207:
raise RuntimeError(f"WebDAV PROPFIND 失败:HTTP {response.status_code}")
root = self._parse_multistatus(response.content)
rows: list[tuple[str, bool, int, str | None, str | None]] = []
for item in root.findall("{DAV:}response"):
href_node = item.find("{DAV:}href")
if href_node is None or not href_node.text:
continue
safe_url = self._safe_url(href_node.text)
if not safe_url:
continue
props = None
for propstat in item.findall("{DAV:}propstat"):
status = propstat.findtext("{DAV:}status", "")
if " 200 " in status:
props = propstat.find("{DAV:}prop")
break
if props is None:
continue
resource_type = props.find("{DAV:}resourcetype")
is_dir = resource_type is not None and resource_type.find("{DAV:}collection") is not None
try:
size = int(props.findtext("{DAV:}getcontentlength", "0"))
except ValueError:
size = 0
modified = props.findtext("{DAV:}getlastmodified")
if modified:
try:
modified = parsedate_to_datetime(modified).astimezone(UTC).isoformat()
except (TypeError, ValueError):
pass
etag = props.findtext("{DAV:}getetag")
rows.append((safe_url, is_dir, size, modified, etag))
return rows
def items(self) -> Iterator[SourceItem]:
queue = deque([self.base_url])
visited: set[str] = set()
base_path = unquote(self._base.path).rstrip("/") + "/"
while queue:
directory = queue.popleft()
normalized_directory = directory.rstrip("/") + "/"
if normalized_directory in visited:
continue
visited.add(normalized_directory)
for url, is_dir, size, modified, etag in self._list(directory):
normalized = url.rstrip("/") + "/" if is_dir else url
if normalized == normalized_directory:
continue
parsed = urlparse(url)
relative = unquote(parsed.path)
if relative.startswith(base_path):
relative = relative[len(base_path) :]
relative = relative.strip("/")
if not relative:
continue
if relative == ".imagefind-trash" or relative.startswith(".imagefind-trash/"):
continue
if is_dir:
queue.append(normalized)
continue
if Path(relative).suffix.lower() not in VIDEO_EXTENSIONS:
continue
yield SourceItem(
key=relative,
display_name=posixpath.basename(relative),
location=url,
size_bytes=size,
modified_at=modified,
etag=etag,
fingerprint=_fingerprint(etag, size, modified),
)
def url_for(self, key: str) -> str:
relative = safe_relative_path(key, allow_empty=False)
encoded = "/".join(quote(part, safe="") for part in PurePosixPath(relative).parts)
return urljoin(self.base_url, encoded)
def item(self, key: str) -> SourceItem:
relative = safe_relative_path(key, allow_empty=False)
url = self.url_for(relative)
rows = self._list(url, depth="0")
row = next((value for value in rows if not value[1]), None)
if not row:
raise KeyError(relative)
_, _, size, modified, etag = row
return SourceItem(
key=relative,
display_name=posixpath.basename(relative),
location=url,
size_bytes=size,
modified_at=modified,
etag=etag,
fingerprint=_fingerprint(etag, size, modified),
)
class SourceService:
def __init__(
self,
db: Database,
settings: Settings,
secrets: SecretStore,
tools: RuntimeToolManager | None = None,
):
self.db = db
self.settings = settings
self.secrets = secrets
self.rclone = RcloneManager(settings, tools)
def add_local(self, name: str, path: str) -> str:
connector = LocalConnector(path)
source_id = str(uuid.uuid4())
now = utcnow()
config = {"path": str(connector.root)}
with self.db.transaction() as conn:
conn.execute(
"INSERT INTO sources(id,kind,name,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?)",
(source_id, "local", name.strip() or connector.root.name, json.dumps(config), now, now),
)
return source_id
def add_webdav(
self,
name: str,
base_url: str,
username: str,
password: str,
verify_tls: bool = True,
) -> str:
probe = WebDavConnector(
base_url,
username,
password,
verify_tls=verify_tls,
timeout=self.settings.remote_timeout_seconds,
)
try:
probe._list(probe.base_url)
finally:
probe.close()
source_id = str(uuid.uuid4())
now = utcnow()
config = {"base_url": probe.base_url, "username": username, "verify_tls": verify_tls}
secret_blob = self.secrets.encrypt_json({"password": password})
with self.db.transaction() as conn:
conn.execute(
"INSERT INTO sources(id,kind,name,config_json,secret_blob,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
(source_id, "webdav", name.strip() or "WebDAV", json.dumps(config), secret_blob, now, now),
)
return source_id
def add_alist(
self,
name: str,
base_url: str,
root_path: str,
username: str,
password: str,
*,
mode: str,
verify_tls: bool = True,
) -> tuple[str, dict | None]:
return self._create_alist(
name,
base_url,
root_path,
username,
password,
mode=mode,
verify_tls=verify_tls,
)
def _create_alist(
self,
name: str,
base_url: str,
root_path: str,
username: str,
password: str,
*,
mode: str,
verify_tls: bool,
crypt_password: str | None = None,
crypt_salt: str | None = None,
) -> tuple[str, dict | None]:
if mode not in {"direct", "encrypted"}:
raise ValueError("AList 模式必须是 direct 或 encrypted")
root_path = safe_relative_path(root_path)
control = AlistClient(
base_url,
username,
password,
root_path=root_path,
verify_tls=verify_tls,
timeout=self.settings.remote_timeout_seconds,
)
try:
control.probe()
finally:
control.close()
source_id = str(uuid.uuid4())
now = utcnow()
config = {
"driver": "alist",
"mode": mode,
"base_url": base_url.rstrip("/"),
"root_path": root_path,
"username": username,
"verify_tls": verify_tls,
"writable": True,
}
recovery = None
secret = {"password": password}
if mode == "encrypted":
secret["crypt_password"] = crypt_password or secrets.token_urlsafe(32)
secret["crypt_salt"] = crypt_salt or secrets.token_urlsafe(24)
recovery = {
"format": "imagefind-rclone-crypt-v1",
"base_url": config["base_url"],
"root_path": root_path,
"username": username,
"crypt_password": secret["crypt_password"],
"crypt_salt": secret["crypt_salt"],
}
secret_blob = self.secrets.encrypt_json(secret)
with self.db.transaction() as conn:
conn.execute(
"INSERT INTO sources(id,kind,name,config_json,secret_blob,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
(source_id, "webdav", name.strip() or "AList", json.dumps(config), secret_blob, now, now),
)
try:
connector = self.connector(source_id)
try:
connector._list(connector.base_url)
finally:
connector.close()
except Exception:
self.rclone.stop(source_id)
with self.db.transaction() as conn:
conn.execute("DELETE FROM sources WHERE id=?", (source_id,))
raise
return source_id, recovery
def restore_alist(
self,
name: str,
base_url: str,
root_path: str,
username: str,
password: str,
crypt_password: str,
crypt_salt: str,
*,
verify_tls: bool = True,
) -> str:
if not crypt_password or not crypt_salt:
raise ValueError("恢复文件缺少加密口令")
source_id, _ = self._create_alist(
name,
base_url,
root_path,
username,
password,
mode="encrypted",
verify_tls=verify_tls,
crypt_password=crypt_password,
crypt_salt=crypt_salt,
)
return source_id
def list_sources(self) -> list[dict]:
with self.db.read() as conn:
rows = conn.execute(
"SELECT id,kind,name,config_json,secret_blob,enabled,status,last_scan_at,last_error,created_at "
"FROM sources ORDER BY created_at"
).fetchall()
result = []
for row in rows:
item = dict(row)
config = json.loads(item.pop("config_json"))
item["has_password"] = bool(item.pop("secret_blob", None))
if item["kind"] == "webdav":
config.pop("username", None)
item["config"] = config
item["enabled"] = bool(item["enabled"])
result.append(item)
return result
def get(self, source_id: str) -> dict:
with self.db.read() as conn:
row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
if not row:
raise KeyError(source_id)
item = dict(row)
item["config"] = json.loads(item.pop("config_json"))
item["secrets"] = self.secrets.decrypt_json(item.pop("secret_blob"))
return item
def connector(self, source_id: str) -> LocalConnector | WebDavConnector:
source = self.get(source_id)
if source["kind"] == "local":
return LocalConnector(source["config"]["path"])
if source["config"].get("driver") == "alist":
if source["config"].get("mode") == "encrypted":
endpoint = self.rclone.acquire(source)
return WebDavConnector(
endpoint.url,
endpoint.username,
endpoint.password,
verify_tls=True,
timeout=self.settings.remote_timeout_seconds,
on_close=endpoint.close,
)
return WebDavConnector(
alist_webdav_url(source["config"]["base_url"], source["config"].get("root_path", "")),
source["config"].get("username", ""),
source["secrets"].get("password", ""),
verify_tls=source["config"].get("verify_tls", True),
timeout=self.settings.remote_timeout_seconds,
)
return WebDavConnector(
source["config"]["base_url"],
source["config"]["username"],
source["secrets"]["password"],
verify_tls=source["config"].get("verify_tls", True),
timeout=self.settings.remote_timeout_seconds,
)
def open_remote_access(self, source_id: str, key: str) -> RemoteAccess:
source = self.get(source_id)
if source["kind"] != "webdav":
raise ValueError("数据源不是远程媒体库")
config = source["config"]
secret = source["secrets"]
if config.get("driver") == "alist":
if config.get("mode") == "encrypted":
endpoint = self.rclone.acquire(source)
return RemoteAccess(
urljoin(endpoint.url, quoted_path(key)),
endpoint.username,
endpoint.password,
True,
endpoint.close,
)
base = alist_webdav_url(config["base_url"], config.get("root_path", ""))
return RemoteAccess(
urljoin(base, quoted_path(key)),
config.get("username", ""),
secret.get("password", ""),
config.get("verify_tls", True),
)
return RemoteAccess(
urljoin(config["base_url"], quoted_path(key)),
config.get("username", ""),
secret.get("password", ""),
config.get("verify_tls", True),
)
def remote_access(self, source_id: str, key: str) -> tuple[str, str, str, bool]:
access = self.open_remote_access(source_id, key)
try:
return access.as_tuple()
finally:
access.close()
def delete(self, source_id: str) -> None:
self.rclone.stop(source_id)
with self.db.transaction() as conn:
conn.execute("DELETE FROM sources WHERE id=?", (source_id,))
def close(self) -> None:
self.rclone.stop_all()
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
from __future__ import annotations
import math
import re
import unicodedata
import zlib
from collections import Counter
from dataclasses import dataclass
LANGUAGE_ALIASES = {
"chi": "zh",
"cmn": "zh",
"cn": "zh",
"zho": "zh",
"zh": "zh",
"eng": "en",
"en": "en",
"jpn": "ja",
"ja": "ja",
"kor": "ko",
"ko": "ko",
}
_FILLER_HALLUCINATIONS = {
"and",
"the",
"thank you",
"thanks for watching",
"subtitles by",
"字幕",
"谢谢观看",
"謝謝觀看",
"謝謝觀賞",
}
_WORD = re.compile(r"[\w']+", re.UNICODE)
@dataclass(frozen=True, slots=True)
class TranscriptQuality:
score: float
accepted: bool
flags: tuple[str, ...]
units: int
def normalize_language(value: object) -> str | None:
language = str(value or "").strip().lower()
if language.startswith("<|") and language.endswith("|>"):
language = language[2:-2]
language = language.replace("_", "-")
if not language or language in {"und", "unknown", "auto"}:
return None
base = language.split("-", 1)[0]
return LANGUAGE_ALIASES.get(language, LANGUAGE_ALIASES.get(base, base if len(base) in {2, 3} else None))
def select_language(detections: list[str], *, policy: str, stream_language: str | None = None) -> str | None:
stream = normalize_language(stream_language)
if policy == "zh":
return "zh"
values = [value for item in detections if (value := normalize_language(item))]
if not values:
# Container metadata is frequently absent or wrong (many phone videos
# are tagged ``eng`` regardless of their spoken language). It may be a
# tie-breaker in automatic mode, but never overrides Chinese-priority.
return "zh" if policy == "zh_priority" else stream
counts = Counter(values)
most = max(counts.values())
leaders = sorted(language for language, count in counts.items() if count == most)
if policy == "zh_priority":
# The selector itself remains conservative. The isolated worker owns
# the audio samples required for the bounded zh/candidate dual decode.
return "zh"
if stream in leaders:
return stream
return leaders[0]
def transcript_quality_state(
score: float | None,
accepted_segments: int,
rejected_segments: int,
flags: list[str] | tuple[str, ...] | None = None,
) -> str:
accepted = max(0, int(accepted_segments))
rejected = max(0, int(rejected_segments))
if accepted == 0 and rejected == 0:
return "empty"
total = accepted + rejected
rejected_ratio = rejected / total if total else 0.0
value = float(score or 0.0)
high_risk = {
"invalid_characters",
"repeated_characters",
"low_character_diversity",
"repetitive_text",
"short_hallucination",
"no_speech_hallucination",
"language_script_conflict",
"untrusted_language",
}
if high_risk.intersection(flags or ()):
return "low_quality"
if value >= 0.70 and rejected_ratio <= 0.40:
return "ready"
if value >= 0.55 and rejected_ratio <= 0.60:
return "filtered"
return "low_quality"
def _script(character: str) -> str:
code = ord(character)
if 0x3400 <= code <= 0x9FFF:
return "han"
if 0x3040 <= code <= 0x30FF:
return "japanese"
if 0xAC00 <= code <= 0xD7AF:
return "hangul"
name = unicodedata.name(character, "")
for key, label in (
("LATIN", "latin"),
("SINHALA", "sinhala"),
("CYRILLIC", "cyrillic"),
("ARABIC", "arabic"),
("DEVANAGARI", "devanagari"),
("THAI", "thai"),
):
if key in name:
return label
return "other"
def _unexpected_script_ratio(text: str, language: str | None) -> float:
expected = {
"zh": {"han", "latin"},
"en": {"latin", "han"},
"ja": {"han", "japanese", "latin"},
"ko": {"hangul", "han", "latin"},
}.get(normalize_language(language) or "")
if not expected:
return 0.0
scripts = [_script(character) for character in text if character.isalpha()]
if len(scripts) < 10:
return 0.0
return sum(script not in expected for script in scripts) / len(scripts)
def _longest_run(text: str) -> int:
longest = current = 0
previous = ""
for character in text:
if character.isspace():
continue
current = current + 1 if character == previous else 1
longest = max(longest, current)
previous = character
return longest
def transcript_quality(
text: str,
duration_ms: int,
*,
speech_ratio: float = 1.0,
expected_language: str | None = None,
) -> TranscriptQuality:
normalized = unicodedata.normalize("NFKC", str(text or "")).strip()
units = sum(character.isalnum() for character in normalized)
if not normalized:
return TranscriptQuality(1.0 if speech_ratio < 0.02 else 0.0, speech_ratio < 0.02, (), 0)
flags: list[str] = []
score = 1.0
if any(character == "\ufffd" or unicodedata.category(character) == "Cc" for character in normalized):
flags.append("invalid_characters")
score -= 1.0
compact = "".join(character.casefold() for character in normalized if character.isalnum())
if len(compact) >= 12 and _longest_run(compact) >= 8:
flags.append("repeated_characters")
score -= 1.0
if len(compact) >= 20 and len(set(compact)) / max(1, len(compact)) < 0.12:
flags.append("low_character_diversity")
score -= 0.8
encoded = normalized.encode("utf-8", errors="replace")
compression = len(encoded) / max(1, len(zlib.compress(encoded)))
if len(encoded) >= 40 and compression >= 2.4:
flags.append("repetitive_text")
score -= 0.8
unexpected = _unexpected_script_ratio(normalized, expected_language)
if unexpected >= 0.45:
flags.append("unexpected_script")
score -= min(0.9, 0.45 + unexpected / 2)
expected = normalize_language(expected_language)
scripts = [_script(character) for character in normalized if character.isalpha()]
if expected == "zh" and len(scripts) >= 8 and "han" not in scripts and scripts.count("latin") >= 8:
flags.append("language_script_conflict")
score -= 0.75
cleaned = " ".join(_WORD.findall(normalized.casefold()))
duration_seconds = max(0.001, duration_ms / 1000)
if duration_ms >= 8000 and speech_ratio < 0.12 and cleaned in _FILLER_HALLUCINATIONS:
flags.append("short_hallucination")
score -= 0.9
# A 30-second media chunk may contain only a short spoken phrase. Base the
# minimum-content heuristic on voiced duration rather than wall-clock
# duration, otherwise sparse but legitimate speech is rejected and causes
# an expensive CPU verification.
voiced_duration_ms = duration_ms * min(1.0, max(0.0, speech_ratio))
low_content_limit = max(2, min(24, round(voiced_duration_ms / 2000)))
if voiced_duration_ms >= 8000 and 0 < units <= low_content_limit:
flags.append("too_little_text")
score -= 0.55
if units / duration_seconds > 30:
flags.append("implausible_density")
score -= 0.45
if speech_ratio < 0.015 and units:
flags.append("no_speech_hallucination")
score -= 0.65
score = min(1.0, max(0.0, score))
hard_reject = {
"invalid_characters",
"repeated_characters",
"low_character_diversity",
"repetitive_text",
"short_hallucination",
"no_speech_hallucination",
"language_script_conflict",
}
accepted = score >= 0.55 and not hard_reject.intersection(flags)
return TranscriptQuality(round(score, 3), accepted, tuple(dict.fromkeys(flags)), units)
def pcm16_speech_ratio(raw: bytes, sample_rate: int, *, mode: int = 2) -> float:
"""Estimate voiced coverage without changing timestamps or dropping audio."""
frame_bytes = max(2, int(sample_rate * 0.03) * 2)
frames = [raw[offset : offset + frame_bytes] for offset in range(0, len(raw) - frame_bytes + 1, frame_bytes)]
if not frames:
return 0.0
try:
import webrtcvad
detector = webrtcvad.Vad(mode)
voiced = sum(bool(detector.is_speech(frame, sample_rate)) for frame in frames)
except (ImportError, ValueError, RuntimeError):
# Source checkouts and partially repaired runtimes still get a bounded
# energy fallback. Release audio runtimes install webrtcvad-wheels.
voiced = 0
for frame in frames:
total = 0.0
count = 0
for offset in range(0, len(frame) - 1, 2):
sample = int.from_bytes(frame[offset : offset + 2], "little", signed=True) / 32768.0
total += sample * sample
count += 1
if count and math.sqrt(total / count) >= 0.008:
voiced += 1
return voiced / len(frames)
def pcm16_voiced_regions(
raw: bytes,
sample_rate: int,
*,
mode: int = 2,
minimum_voiced_ms: int = 240,
bridge_silence_ms: int = 1500,
padding_ms: int = 250,
maximum_window_ms: int = 28_000,
) -> list[tuple[int, int, float]]:
"""Return bounded voiced regions as PCM frame offsets and speech ratios.
Regions are intentionally derived before Whisper decoding so music and
silence do not become prompts for hallucinated text. The energy fallback
keeps source checkouts useful when the optional WebRTC wheel is missing.
"""
frame_samples = max(1, int(sample_rate * 0.03))
frame_bytes = frame_samples * 2
frame_count = len(raw) // frame_bytes
if frame_count <= 0:
return []
frames = [raw[index * frame_bytes : (index + 1) * frame_bytes] for index in range(frame_count)]
try:
import webrtcvad
detector = webrtcvad.Vad(mode)
voiced = [bool(detector.is_speech(frame, sample_rate)) for frame in frames]
except (ImportError, ValueError, RuntimeError):
threshold = 0.006 if mode <= 1 else 0.008
voiced = []
for frame in frames:
total = 0.0
samples = 0
for offset in range(0, len(frame) - 1, 2):
value = int.from_bytes(frame[offset : offset + 2], "little", signed=True) / 32768.0
total += value * value
samples += 1
voiced.append(bool(samples and math.sqrt(total / samples) >= threshold))
minimum_frames = max(1, math.ceil(minimum_voiced_ms / 30))
bridge_frames = max(0, round(bridge_silence_ms / 30))
padding_frames = max(0, round(padding_ms / 30))
runs: list[tuple[int, int]] = []
start: int | None = None
for index, is_voiced in enumerate(voiced + [False]):
if is_voiced and start is None:
start = index
elif not is_voiced and start is not None:
if index - start >= minimum_frames:
runs.append((start, index))
start = None
if not runs:
return []
merged: list[tuple[int, int]] = []
for start, end in runs:
if merged and start - merged[-1][1] <= bridge_frames:
merged[-1] = (merged[-1][0], end)
else:
merged.append((start, end))
padded: list[tuple[int, int]] = []
for start, end in merged:
value = (max(0, start - padding_frames), min(frame_count, end + padding_frames))
if padded and value[0] <= padded[-1][1]:
padded[-1] = (padded[-1][0], max(padded[-1][1], value[1]))
else:
padded.append(value)
maximum_frames = max(1, maximum_window_ms // 30)
windows: list[tuple[int, int, float]] = []
for start, end in padded:
length = end - start
pieces = max(1, math.ceil(length / maximum_frames))
for piece in range(pieces):
piece_start = start + round(length * piece / pieces)
piece_end = start + round(length * (piece + 1) / pieces)
if piece_end <= piece_start:
continue
ratio = sum(voiced[piece_start:piece_end]) / (piece_end - piece_start)
windows.append((piece_start * frame_samples, piece_end * frame_samples, ratio))
return windows
+982
View File
@@ -0,0 +1,982 @@
from __future__ import annotations
import json
import os
import posixpath
import shutil
import threading
import time
import uuid
from collections.abc import Callable, Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlparse
import httpx
from .config import Settings
from .database import Database, utcnow
from .remote import AlistClient, openlist_endpoint_from_webdav_url, safe_relative_path
from .sources import SourceService, WebDavConnector
ProgressCallback = Callable[[int, int], None]
CancelCallback = Callable[[], bool]
CommitCallback = Callable[[str], None]
class TransferCancelled(RuntimeError):
pass
def _target_key(directory: str, filename: str) -> str:
directory = safe_relative_path(directory)
filename = safe_relative_path(filename, allow_empty=False)
if "/" in filename:
raise ValueError("文件名不能包含目录")
return posixpath.join(directory, filename) if directory else filename
class StorageService:
_REMOTE_MOVE_ATTEMPTS = 4
_REMOTE_MOVE_RETRY_DELAYS = (0.25, 0.5, 1.0)
_REMOTE_MOVE_TIMEOUT_SECONDS = 10 * 60
_REMOTE_PUT_TIMEOUT = httpx.Timeout(connect=30, read=30 * 60, write=10 * 60, pool=30)
_REMOTE_CANCEL_POLL_SECONDS = 0.25
_REMOTE_CANCEL_JOIN_SECONDS = 2
def __init__(self, db: Database, settings: Settings, sources: SourceService, governor=None):
self.db = db
self.settings = settings
self.sources = sources
self.governor = governor
self._write_locks_guard = threading.Lock()
self._write_locks: dict[str, threading.RLock] = {}
self._openlist_probe_lock = threading.Lock()
self._openlist_compatible: set[tuple[str, str, str]] = set()
self.openlist_native = None
def _write_lock(self, source_id: str) -> threading.RLock:
with self._write_locks_guard:
return self._write_locks.setdefault(source_id, threading.RLock())
def _openlist_upload_client(self, source_id: str, source: dict) -> AlistClient | None:
"""Return a verified hash-aware OpenList client when available.
Users may configure OpenList through the generic WebDAV form. Detect
only explicit same-origin ``/dav/...`` URLs and require a successful
authenticated API probe before choosing the OpenList upload path. A
failed probe falls back to standards-based WebDAV without consuming
any bytes from the staged file.
"""
config = source["config"]
if config.get("driver") == "alist":
if config.get("mode") != "direct":
return None
base_url = config["base_url"]
root_path = config.get("root_path", "")
verified = True
else:
inferred = openlist_endpoint_from_webdav_url(config.get("base_url", ""))
if not inferred:
return None
base_url, root_path = inferred
cache_key = (source_id, base_url, root_path)
with self._openlist_probe_lock:
verified = cache_key in self._openlist_compatible
client = AlistClient(
base_url,
config.get("username", ""),
source["secrets"].get("password", ""),
root_path=root_path,
verify_tls=config.get("verify_tls", True),
timeout=self.settings.remote_timeout_seconds,
)
if verified:
return client
try:
client.probe()
except Exception:
client.close()
return None
with self._openlist_probe_lock:
self._openlist_compatible.add(cache_key)
return client
@staticmethod
def _local_path(source: dict, key: str = "", *, must_exist: bool = False) -> Path:
root = Path(source["config"]["path"]).resolve(strict=True)
relative = safe_relative_path(key)
path = root / relative
resolved = path.resolve(strict=must_exist)
resolved.relative_to(root)
return resolved
@staticmethod
def _writable(source: dict) -> bool:
if source["kind"] == "local":
return bool(source["config"].get("writable", False))
if source["config"].get("storage_backend") == "openlist_native":
return True
if source["config"].get("driver") == "alist":
return True
return bool(source["config"].get("writable", False))
def require_writable(self, source_id: str) -> dict:
source = self.sources.get(source_id)
if not self._writable(source):
raise PermissionError("该数据源是只读的,请先在数据源设置中启用写入")
return source
def matching_content_key(
self,
source_id: str,
directory: str,
filename: str,
*,
size_bytes: int,
content_sha256: str,
) -> str | None:
"""Return an unchanged same-path object with identical trusted content."""
key = _target_key(directory, filename)
with self.db.read() as conn:
row = conn.execute(
"SELECT fingerprint,content_fingerprint FROM videos "
"WHERE source_id=? AND source_key=? AND available=1 AND size_bytes=? "
"AND content_sha256=?",
(source_id, key, size_bytes, content_sha256),
).fetchone()
if not row or not row["content_fingerprint"] or row["fingerprint"] != row["content_fingerprint"]:
return None
source = self.sources.get(source_id)
if source["kind"] == "local":
try:
item = self.sources.connector(source_id).item(key)
except (KeyError, OSError):
return None
else:
connector = self.sources.connector(source_id)
try:
item = connector.item(key)
except (KeyError, OSError, RuntimeError):
return None
finally:
connector.close()
return key if item.size_bytes == size_bytes and item.fingerprint == row["content_fingerprint"] else None
def set_writable(self, source_id: str, writable: bool) -> dict:
source = self.sources.get(source_id)
if writable and source["kind"] == "local":
root = self._local_path(source)
probe = root / f".imagefind-write-test-{uuid.uuid4().hex}"
try:
probe.write_bytes(b"")
finally:
probe.unlink(missing_ok=True)
config = source["config"]
config["writable"] = bool(writable)
with self.db.transaction() as conn:
conn.execute(
"UPDATE sources SET config_json=?,updated_at=? WHERE id=?",
(json.dumps(config), utcnow(), source_id),
)
return {"writable": bool(writable)}
@staticmethod
def _remote_relative(connector: WebDavConnector, url: str) -> str:
base = unquote(urlparse(connector.base_url).path).rstrip("/") + "/"
path = unquote(urlparse(url).path)
return path[len(base) :].strip("/") if path.startswith(base) else ""
def list_dir(self, source_id: str, path: str = "") -> list[dict]:
source = self.sources.get(source_id)
relative = safe_relative_path(path)
if relative == ".imagefind-trash" or relative.startswith(".imagefind-trash/"):
raise PermissionError("回收站目录不能直接浏览")
if source["kind"] == "local":
directory = self._local_path(source, relative, must_exist=True)
if not directory.is_dir():
raise NotADirectoryError(relative)
items = []
for child in sorted(directory.iterdir(), key=lambda value: (not value.is_dir(), value.name.lower())):
if child.name == ".imagefind-trash" or child.is_symlink():
continue
stat = child.stat()
items.append(
{
"key": child.relative_to(Path(source["config"]["path"]).resolve()).as_posix(),
"name": child.name,
"is_dir": child.is_dir(),
"size_bytes": 0 if child.is_dir() else stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(),
}
)
return items
connector = self.sources.connector(source_id)
try:
url = connector.base_url if not relative else connector.url_for(relative).rstrip("/") + "/"
rows = connector._list(url)
items = []
for item_url, is_dir, size, modified, _ in rows:
key = self._remote_relative(connector, item_url)
if not key or key == relative or key == ".imagefind-trash" or key.startswith(".imagefind-trash/"):
continue
items.append(
{
"key": key,
"name": posixpath.basename(key.rstrip("/")),
"is_dir": is_dir,
"size_bytes": 0 if is_dir else size,
"modified_at": modified,
}
)
return sorted(items, key=lambda value: (not value["is_dir"], value["name"].lower()))
finally:
connector.close()
@staticmethod
def _renamed_key(key: str, number: int) -> str:
path = PurePosixPath(key)
suffixes = "".join(path.suffixes)
stem = path.name[: -len(suffixes)] if suffixes else path.name
return str(path.with_name(f"{stem} ({number}){suffixes}"))
def _local_conflict_key(self, source: dict, key: str, conflict: str) -> str | None:
target = self._local_path(source, key)
if not target.exists():
return key
if conflict == "replace":
return key
if conflict == "skip":
return None
for number in range(2, 10_000):
candidate = self._renamed_key(key, number)
if not self._local_path(source, candidate).exists():
return candidate
raise RuntimeError("无法生成不冲突的文件名")
@staticmethod
def _remote_exists(connector: WebDavConnector, key: str) -> bool:
response = connector.client.request(
"PROPFIND",
connector.url_for(key),
headers={"Depth": "0", "Content-Type": "application/xml; charset=utf-8"},
content="<?xml version='1.0'?><d:propfind xmlns:d='DAV:'><d:prop><d:resourcetype/></d:prop></d:propfind>",
)
if response.status_code == 207:
return True
if response.status_code in {404, 410}:
return False
raise RuntimeError(f"核验远程文件状态失败:HTTP {response.status_code}")
def _remote_move(
self,
connector: WebDavConnector,
source_key: str,
destination_key: str,
*,
overwrite: bool,
failure_label: str,
) -> None:
"""Move a WebDAV object and reconcile false failure responses.
Some WebDAV implementations finish a MOVE and then return HTTP 500 or
lose the response while committing metadata. Retrying based only on
that response turns a successful operation into a failure and can
strand an object in a temporary or trash path. The source/destination
probe makes the operation idempotent across those ambiguous outcomes.
"""
last_failure = "MOVE 未执行"
for attempt in range(self._REMOTE_MOVE_ATTEMPTS):
try:
response = connector.client.request(
"MOVE",
connector.url_for(source_key),
headers={
"Destination": connector.url_for(destination_key),
"Overwrite": "T" if overwrite else "F",
},
# A server-side MOVE may take longer than the ordinary
# metadata timeout for large remote objects, but it must
# not pin the only transfer lane forever.
timeout=self._REMOTE_MOVE_TIMEOUT_SECONDS,
)
if response.status_code in {201, 204}:
return
last_failure = f"MOVE HTTP {response.status_code}"
except Exception as exc:
# Do not include the exception text: an HTTP client's error
# may contain a credential-bearing URL supplied by a user.
last_failure = f"MOVE 网络异常({type(exc).__name__}"
try:
source_exists = self._remote_exists(connector, source_key)
destination_exists = self._remote_exists(connector, destination_key)
except Exception as exc:
verification = f"状态核验失败({type(exc).__name__}"
else:
if not source_exists and destination_exists:
return
if source_exists and not destination_exists:
verification = "源文件仍存在,目标文件不存在"
elif source_exists:
verification = "源文件和目标文件均存在"
else:
verification = "源文件和目标文件均不存在"
last_failure = f"{last_failure}{verification}"
if attempt + 1 < self._REMOTE_MOVE_ATTEMPTS:
time.sleep(self._REMOTE_MOVE_RETRY_DELAYS[attempt])
raise RuntimeError(f"{failure_label}{last_failure}")
def _remote_conflict_key(self, connector: WebDavConnector, key: str, conflict: str) -> str | None:
if not self._remote_exists(connector, key):
return key
if conflict == "replace":
return key
if conflict == "skip":
return None
for number in range(2, 10_000):
candidate = self._renamed_key(key, number)
if not self._remote_exists(connector, candidate):
return candidate
raise RuntimeError("无法生成不冲突的文件名")
def resolve_target_key(self, source_id: str, directory: str, filename: str, conflict: str) -> str | None:
"""Resolve a logical destination without writing file content.
OpenList native uploads still use the normal decrypted WebDAV view for
conflict checks. This keeps rename/replace/skip behavior identical to
legacy writes, including rclone-crypt libraries, while the subsequent
media transfer is performed by OpenList itself.
"""
source = self.require_writable(source_id)
key = _target_key(directory, filename)
with self._write_lock(source_id):
if source["kind"] == "local":
return self._local_conflict_key(source, key, conflict)
connector = self.sources.connector(source_id)
try:
return self._remote_conflict_key(connector, key, conflict)
finally:
connector.close()
@staticmethod
def _trash_container_key(trash_key: str) -> str | None:
"""Return the per-item trash directory used by the current layout."""
parent = posixpath.dirname(safe_relative_path(trash_key, allow_empty=False))
if parent.startswith(".imagefind-trash/") and parent.count("/") == 1:
return parent
return None
@staticmethod
def _trash_key(trash_id: str, original_key: str) -> str:
return posixpath.join(".imagefind-trash", trash_id, posixpath.basename(original_key))
def _remote_restore_target(self, connector: WebDavConnector, item: dict) -> str:
target = item["original_key"]
if not self._remote_exists(connector, target):
return target
parent = posixpath.dirname(target)
basename = posixpath.basename(target)
recovered = posixpath.join(parent, f"已恢复-{item['id']}", basename)
number = 2
while self._remote_exists(connector, recovered):
recovered = posixpath.join(parent, f"已恢复-{item['id']}-{number}", basename)
number += 1
return recovered
def _cleanup_trash_container(self, source_id: str, trash_key: str) -> None:
container = self._trash_container_key(trash_key)
if not container:
return
source = self.sources.get(source_id)
if source["kind"] == "local":
try:
self._local_path(source, container).rmdir()
except OSError:
pass
return
connector = self.sources.connector(source_id)
try:
try:
connector.client.delete(connector.url_for(container).rstrip("/") + "/")
except Exception:
pass
finally:
connector.close()
def transfer_committed(
self,
source_id: str,
target_key: str,
operation_id: str,
staging_path: Path,
) -> bool:
"""Resolve the narrow crash window around an atomic destination commit."""
source = self.sources.get(source_id)
if source["kind"] == "local":
return not staging_path.exists() and self._local_path(source, target_key).is_file()
connector = self.sources.connector(source_id)
try:
temporary_key = posixpath.join(
posixpath.dirname(target_key),
f".{posixpath.basename(target_key)}.imagefind-{operation_id}.part",
)
if self._remote_exists(connector, temporary_key):
return False
return self._remote_exists(connector, target_key)
finally:
connector.close()
@staticmethod
def _check_cancel(cancelled: CancelCallback | None) -> None:
if cancelled and cancelled():
raise TransferCancelled("传输已取消")
@staticmethod
def _copy_local(
source_path: Path,
target: Path,
progress: ProgressCallback | None,
cancelled: CancelCallback | None,
before_commit: CommitCallback | None = None,
key: str = "",
) -> None:
size = source_path.stat().st_size
copied = 0
temporary = target.with_name(f".{target.name}.imagefind-{uuid.uuid4().hex}.part")
target.parent.mkdir(parents=True, exist_ok=True)
try:
with source_path.open("rb") as input_file, temporary.open("xb") as output_file:
while chunk := input_file.read(4 * 1024 * 1024):
StorageService._check_cancel(cancelled)
output_file.write(chunk)
copied += len(chunk)
if progress:
progress(copied, size)
output_file.flush()
os.fsync(output_file.fileno())
StorageService._check_cancel(cancelled)
if before_commit:
before_commit(key)
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
def _upload_iterator(
self,
source_path: Path,
progress: ProgressCallback | None,
cancelled: CancelCallback | None,
) -> Iterator[bytes]:
total = source_path.stat().st_size
sent = 0
with source_path.open("rb") as handle:
while chunk := handle.read(4 * 1024 * 1024):
StorageService._check_cancel(cancelled)
if self.governor is not None:
self.governor.throttle_io(len(chunk), lane="transfer")
sent += len(chunk)
if progress:
progress(sent, total)
yield chunk
def _run_remote_request(
self,
request: Callable[[], object],
close: Callable[[], None],
cancelled: CancelCallback | None,
) -> object:
"""Run a blocking remote upload while retaining prompt cancellation.
Once an HTTP client finishes sending the request body it can block for
many minutes waiting for the storage provider's response. The content
iterator can no longer observe cancellation at that point, so execute
the request in a daemon worker and close its client when cancellation
is requested. A finite HTTP timeout bounds providers that never reply.
"""
finished = threading.Event()
outcome: dict[str, object] = {}
def run() -> None:
try:
outcome["response"] = request()
except BaseException as exc: # propagated on the transfer worker
outcome["error"] = exc
finally:
finished.set()
worker = threading.Thread(target=run, name="imagefind-remote-put", daemon=True)
worker.start()
while not finished.wait(self._REMOTE_CANCEL_POLL_SECONDS):
if cancelled and cancelled():
try:
close()
finally:
finished.wait(self._REMOTE_CANCEL_JOIN_SECONDS)
raise TransferCancelled("传输已取消")
error = outcome.get("error")
if isinstance(error, BaseException):
raise error
return outcome["response"]
@staticmethod
def _ensure_remote_directories(connector: WebDavConnector, directory: str) -> None:
current = ""
for part in PurePosixPath(directory).parts if directory else ():
current = posixpath.join(current, part)
response = connector.client.request("MKCOL", connector.url_for(current).rstrip("/") + "/")
if response.status_code not in {201, 405}:
raise RuntimeError(f"创建远程目录失败:HTTP {response.status_code}")
def write_file(
self,
source_id: str,
directory: str,
filename: str,
source_path: Path,
*,
conflict: str = "rename",
progress: ProgressCallback | None = None,
cancelled: CancelCallback | None = None,
consume_source: bool = False,
operation_id: str | None = None,
content_sha256: str | None = None,
before_commit: CommitCallback | None = None,
) -> str | None:
with self._write_lock(source_id):
return self._write_file(
source_id,
directory,
filename,
source_path,
conflict=conflict,
progress=progress,
cancelled=cancelled,
consume_source=consume_source,
operation_id=operation_id,
content_sha256=content_sha256,
before_commit=before_commit,
)
def _write_file(
self,
source_id: str,
directory: str,
filename: str,
source_path: Path,
*,
conflict: str,
progress: ProgressCallback | None,
cancelled: CancelCallback | None,
consume_source: bool,
operation_id: str | None,
content_sha256: str | None,
before_commit: CommitCallback | None,
) -> str | None:
source = self.require_writable(source_id)
key = _target_key(directory, filename)
if source["kind"] == "local":
key = self._local_conflict_key(source, key, conflict)
if key is None:
return None
target = self._local_path(source, key)
if consume_source:
self._check_cancel(cancelled)
target.parent.mkdir(parents=True, exist_ok=True)
if source_path.stat().st_dev == target.parent.stat().st_dev:
if before_commit:
before_commit(key)
size = source_path.stat().st_size
os.replace(source_path, target)
if progress:
progress(size, size)
else:
self._copy_local(
source_path,
target,
progress,
cancelled,
before_commit=before_commit,
key=key,
)
source_path.unlink(missing_ok=True)
else:
self._copy_local(source_path, target, progress, cancelled, before_commit=before_commit, key=key)
return key
connector = self.sources.connector(source_id)
control: AlistClient | None = None
temporary_key: str | None = None
committed = False
try:
key = self._remote_conflict_key(connector, key, conflict)
if key is None:
return None
self._ensure_remote_directories(connector, posixpath.dirname(key))
token = operation_id or uuid.uuid4().hex
temporary_key = posixpath.join(
posixpath.dirname(key),
f".{posixpath.basename(key)}.imagefind-{token}.part",
)
connector.client.delete(connector.url_for(temporary_key))
size = source_path.stat().st_size
control = self._openlist_upload_client(source_id, source) if content_sha256 else None
if control:
self._run_remote_request(
lambda: control.put_stream(
temporary_key,
self._upload_iterator(source_path, progress, cancelled),
size_bytes=size,
content_sha256=content_sha256,
timeout=self._REMOTE_PUT_TIMEOUT,
),
control.close,
cancelled,
)
else:
response = self._run_remote_request(
lambda: connector.client.put(
connector.url_for(temporary_key),
headers={
"Content-Length": str(size),
"Content-Type": "application/octet-stream",
},
content=self._upload_iterator(source_path, progress, cancelled),
timeout=self._REMOTE_PUT_TIMEOUT,
),
connector.close,
cancelled,
)
if response.status_code not in {200, 201, 204}:
raise RuntimeError(f"远程上传失败:HTTP {response.status_code}")
self._check_cancel(cancelled)
if before_commit:
before_commit(key)
self._remote_move(
connector,
temporary_key,
key,
overwrite=conflict == "replace",
failure_label="远程媒体库不支持安全原子写入",
)
committed = True
return key
finally:
if temporary_key and not committed:
try:
connector.client.delete(connector.url_for(temporary_key))
except Exception:
pass
if control:
control.close()
connector.close()
def mkdir(self, source_id: str, path: str) -> None:
with self._write_lock(source_id):
self._mkdir(source_id, path)
def _mkdir(self, source_id: str, path: str) -> None:
source = self.require_writable(source_id)
key = safe_relative_path(path, allow_empty=False)
if source["kind"] == "local":
self._local_path(source, key).mkdir(parents=True, exist_ok=True)
return
connector = self.sources.connector(source_id)
try:
self._ensure_remote_directories(connector, key)
finally:
connector.close()
def move(self, source_id: str, source_key: str, destination_key: str, *, overwrite: bool = False) -> None:
with self._write_lock(source_id):
self._move(source_id, source_key, destination_key, overwrite=overwrite)
def _move(self, source_id: str, source_key: str, destination_key: str, *, overwrite: bool) -> None:
source = self.require_writable(source_id)
old = safe_relative_path(source_key, allow_empty=False)
new = safe_relative_path(destination_key, allow_empty=False)
if old == new:
return
if source["kind"] == "local":
old_path = self._local_path(source, old, must_exist=True)
new_path = self._local_path(source, new)
new_path.parent.mkdir(parents=True, exist_ok=True)
if new_path.exists() and not overwrite:
raise FileExistsError(new)
os.replace(old_path, new_path)
return
connector = self.sources.connector(source_id)
try:
self._ensure_remote_directories(connector, posixpath.dirname(new))
self._remote_move(
connector,
old,
new,
overwrite=overwrite,
failure_label="远程移动失败",
)
finally:
connector.close()
def trash(self, source_id: str, key: str) -> str:
with self._write_lock(source_id):
return self._trash(source_id, key)
def _trash(self, source_id: str, key: str) -> str:
source = self.require_writable(source_id)
original = safe_relative_path(key, allow_empty=False)
trash_id = str(uuid.uuid4())
# OpenList and some cloud-backed WebDAV providers cannot reliably
# rename while moving across directories. A unique directory lets the
# MOVE preserve the original basename while still avoiding conflicts.
trash_key = self._trash_key(trash_id, original)
size = 0
storage_backend = "legacy_webdav"
physical_original_path: str | None = None
physical_trash_path: str | None = None
physical_size = 0
with self.db.read() as conn:
video = conn.execute(
"SELECT size_bytes,storage_backend,physical_path,physical_size_bytes FROM videos "
"WHERE source_id=? AND source_key=?",
(source_id, original),
).fetchone()
if video is not None:
size = int(video["size_bytes"] or 0)
storage_backend = str(video["storage_backend"] or "legacy_webdav")
physical_original_path = str(video["physical_path"] or "") or None
physical_size = int(video["physical_size_bytes"] or 0)
native_mapping = None
if self.openlist_native is not None and self.openlist_native.enabled(source_id):
native_mapping = self.openlist_native.physical_object_for_key(source_id, original)
if native_mapping is None and storage_backend == "openlist_native":
raise RuntimeError("OpenList 原生文件缺少持久物理路径,无法安全移入回收站")
if native_mapping is not None:
physical_original_path, physical_size, logical_size = native_mapping
size = logical_size or size
storage_backend = "openlist_native"
if source["kind"] == "local":
path = self._local_path(source, original, must_exist=True)
size = path.stat().st_size if path.is_file() else 0
if native_mapping is not None:
physical_trash_path = self.openlist_native.trash_object(
source_id,
trash_id,
physical_original_path,
physical_size,
)
else:
self.move(source_id, original, trash_key)
now = datetime.now(UTC)
try:
with self.db.transaction() as conn:
conn.execute(
"INSERT INTO trash(id,source_id,original_key,trash_key,display_name,size_bytes,"
"storage_backend,physical_original_path,physical_trash_path,physical_size_bytes,"
"deleted_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(
trash_id,
source_id,
original,
trash_key,
posixpath.basename(original),
size,
storage_backend,
physical_original_path,
physical_trash_path,
physical_size if native_mapping is not None else None,
now.isoformat(),
(now + timedelta(days=self.settings.trash_retention_days)).isoformat(),
),
)
conn.execute(
"UPDATE videos SET available=0,status='offline',updated_at=? WHERE source_id=? AND "
"(source_key=? OR source_key LIKE ?)",
(utcnow(), source_id, original, original.rstrip("/") + "/%"),
)
except Exception:
# The source move and the database transaction cannot share a real
# transaction. Compensate immediately so a database failure never
# strands a remote object in an untracked trash path.
try:
if native_mapping is not None and physical_trash_path and physical_original_path:
self.openlist_native.restore_object(
source_id,
physical_trash_path,
physical_original_path,
physical_size,
)
else:
self.move(source_id, trash_key, original)
self._cleanup_trash_container(source_id, trash_key)
except Exception as rollback_error:
raise RuntimeError("写入回收站记录失败,且源文件回滚失败,请检查存储连接") from rollback_error
raise
return trash_id
def list_trash(self, source_id: str | None = None) -> list[dict]:
with self.db.read() as conn:
if source_id:
rows = conn.execute(
"SELECT * FROM trash WHERE source_id=? ORDER BY deleted_at DESC", (source_id,)
).fetchall()
else:
rows = conn.execute("SELECT * FROM trash ORDER BY deleted_at DESC").fetchall()
return [dict(row) for row in rows]
def restore(self, trash_id: str) -> str:
with self.db.read() as conn:
row = conn.execute("SELECT * FROM trash WHERE id=?", (trash_id,)).fetchone()
if not row:
raise KeyError(trash_id)
item = dict(row)
with self._write_lock(item["source_id"]):
return self._restore(item)
def _restore(self, item: dict) -> str:
source = self.require_writable(item["source_id"])
target = item["original_key"]
if item.get("storage_backend") == "openlist_native":
if (
self.openlist_native is None
or not item.get("physical_trash_path")
or not item.get("physical_original_path")
):
raise RuntimeError("OpenList 回收站记录缺少物理路径,无法安全恢复")
expected_size = int(item.get("physical_size_bytes") or 0)
if not expected_size:
with self.db.read() as conn:
row = conn.execute(
"SELECT physical_size_bytes FROM videos WHERE source_id=? AND source_key=?",
(item["source_id"], item["original_key"]),
).fetchone()
expected_size = int(row[0] or 0) if row else 0
if not expected_size:
obj = self.openlist_native.client(item["source_id"]).object_info(item["physical_trash_path"])
expected_size = int((obj or {}).get("size") or 0)
self.openlist_native.restore_object(
item["source_id"],
item["physical_trash_path"],
item["physical_original_path"],
expected_size,
)
try:
with self.db.transaction() as conn:
conn.execute("DELETE FROM trash WHERE id=?", (item["id"],))
conn.execute(
"DELETE FROM video_tombstones WHERE source_id=? AND source_key=?",
(item["source_id"], item["original_key"]),
)
except Exception:
# Put the object back into the same isolated trash id if the
# catalog transaction fails after the physical restore.
restored_trash = self.openlist_native.trash_object(
item["source_id"],
item["id"],
item["physical_original_path"],
expected_size,
)
if restored_trash != item["physical_trash_path"]:
raise RuntimeError("恢复记录提交失败,且物理回收站路径发生变化") from None
raise
return target
if source["kind"] == "local":
resolved = self._local_conflict_key(source, target, "rename")
if resolved:
target = resolved
else:
connector = self.sources.connector(item["source_id"])
try:
target = self._remote_restore_target(connector, item)
finally:
connector.close()
self.move(item["source_id"], item["trash_key"], target)
with self.db.transaction() as conn:
conn.execute("DELETE FROM trash WHERE id=?", (item["id"],))
conn.execute(
"DELETE FROM video_tombstones WHERE source_id=? AND source_key=?",
(item["source_id"], item["original_key"]),
)
if target != item["original_key"]:
conn.execute(
"DELETE FROM video_tombstones WHERE source_id=? AND source_key=?",
(item["source_id"], target),
)
self._cleanup_trash_container(item["source_id"], item["trash_key"])
return target
def purge(self, trash_id: str) -> None:
with self.db.read() as conn:
row = conn.execute("SELECT * FROM trash WHERE id=?", (trash_id,)).fetchone()
if not row:
raise KeyError(trash_id)
item = dict(row)
with self._write_lock(item["source_id"]):
self._purge(item)
def _purge(self, item: dict) -> None:
source = self.require_writable(item["source_id"])
if item.get("storage_backend") == "openlist_native":
if self.openlist_native is None or not item.get("physical_trash_path"):
raise RuntimeError("OpenList 回收站记录缺少物理路径,无法安全永久删除")
self.openlist_native.purge_object(item["source_id"], item["physical_trash_path"])
elif source["kind"] == "local":
path = self._local_path(source, item["trash_key"], must_exist=True)
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
else:
connector = self.sources.connector(item["source_id"])
try:
response = connector.client.delete(connector.url_for(item["trash_key"]))
if response.status_code not in {200, 204, 404}:
raise RuntimeError(f"远程删除失败:HTTP {response.status_code}")
finally:
connector.close()
with self.db.transaction() as conn:
conn.execute("DELETE FROM trash WHERE id=?", (item["id"],))
if item.get("storage_backend") != "openlist_native":
self._cleanup_trash_container(item["source_id"], item["trash_key"])
def purge_expired(self) -> int:
with self.db.read() as conn:
ids = [
row["id"]
for row in conn.execute("SELECT id FROM trash WHERE expires_at<=? ORDER BY expires_at", (utcnow(),))
]
removed = 0
for trash_id in ids:
try:
self.purge(trash_id)
except (OSError, RuntimeError, PermissionError, KeyError):
continue
removed += 1
return removed
def direct_url(self, source_id: str, key: str) -> str | None:
source = self.sources.get(source_id)
config = source["config"]
if config.get("driver") != "alist" or config.get("mode") != "direct":
return None
client = AlistClient(
config["base_url"],
config.get("username", ""),
source["secrets"].get("password", ""),
root_path=config.get("root_path", ""),
verify_tls=config.get("verify_tls", True),
timeout=self.settings.remote_timeout_seconds,
)
try:
return client.raw_url(key)
finally:
client.close()
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import re
from dataclasses import dataclass
@dataclass(slots=True)
class SubtitleCue:
start_ms: int
end_ms: int
text: str
_TIME = re.compile(r"(?:(\d+):)?(\d{1,2}):(\d{2})[,.](\d{1,3})")
_TAG = re.compile(r"<[^>]+>|\{\\[^}]+\}")
def timestamp_ms(value: str) -> int:
match = _TIME.search(value.strip())
if not match:
raise ValueError(value)
hours, minutes, seconds, millis = match.groups()
millis = (millis + "00")[:3]
return ((int(hours or 0) * 60 + int(minutes)) * 60 + int(seconds)) * 1000 + int(millis)
def parse_srt_or_vtt(content: str) -> list[SubtitleCue]:
content = content.replace("\r\n", "\n").replace("\r", "\n")
cues: list[SubtitleCue] = []
for block in re.split(r"\n\s*\n", content):
lines = [line.strip() for line in block.splitlines() if line.strip()]
timing_index = next((i for i, line in enumerate(lines) if "-->" in line), None)
if timing_index is None:
continue
try:
left, right = lines[timing_index].split("-->", 1)
start, end = timestamp_ms(left), timestamp_ms(right)
except ValueError:
continue
text = " ".join(lines[timing_index + 1 :])
text = re.sub(r"\s+", " ", _TAG.sub("", text)).strip()
if text:
cues.append(SubtitleCue(start, max(start, end), text))
return cues
def parse_ass(content: str) -> list[SubtitleCue]:
cues: list[SubtitleCue] = []
for line in content.splitlines():
if not line.startswith("Dialogue:"):
continue
parts = line.split(",", 9)
if len(parts) < 10:
continue
try:
start, end = timestamp_ms(parts[1]), timestamp_ms(parts[2])
except ValueError:
continue
text = re.sub(r"\\[Nn]", " ", parts[9])
text = re.sub(r"\s+", " ", _TAG.sub("", text)).strip()
if text:
cues.append(SubtitleCue(start, max(start, end), text))
return cues
def parse_subtitles(content: str, kind: str = "") -> list[SubtitleCue]:
return parse_ass(content) if kind.lower() in {"ass", "ssa"} else parse_srt_or_vtt(content)
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import asyncio
import json
import uuid
from collections import defaultdict
from .ai import EmbeddingService, ModelUnavailable
from .database import Database, utcnow
from .text import normalize_text
class TagSuggestionService:
def __init__(self, db: Database, embeddings: EmbeddingService, vectors, jobs):
self.db = db
self.embeddings = embeddings
self.vectors = vectors
self.jobs = jobs
async def handle(self, job_id: str, payload: dict) -> None:
await asyncio.to_thread(
self.analyze,
job_id,
list(dict.fromkeys(payload.get("video_ids") or [])),
bool(payload.get("force")),
)
def enqueue(self, video_ids: list[str], *, force: bool = False) -> str:
ids = list(dict.fromkeys(video_ids))
if not ids:
raise ValueError("至少选择一部影片")
key = "tag-suggestions:" + ",".join(sorted(ids))
return self.jobs.enqueue(
"suggest_tags",
{"video_ids": ids, "force": force},
dedupe_key=key,
)
def _video(self, video_id: str) -> dict | None:
with self.db.read() as conn:
row = conn.execute(
"SELECT v.id,v.display_name,v.source_key,v.fingerprint,v.status,vm.title,vm.catalog_code,"
"vm.studio,vm.series,vm.description FROM videos v LEFT JOIN video_metadata vm ON vm.video_id=v.id "
"WHERE v.id=? AND v.available=1",
(video_id,),
).fetchone()
return dict(row) if row else None
def _enabled_tags(self) -> list[dict]:
with self.db.read() as conn:
rows = conn.execute(
"SELECT t.*,g.name AS group_name,g.selection_mode FROM tags t JOIN tag_groups g ON g.id=t.group_id "
"WHERE t.ai_enabled=1 ORDER BY g.sort_order,g.name,t.name"
).fetchall()
result = []
for row in rows:
item = dict(row)
item["match_terms"] = json.loads(item.pop("match_terms_json") or "[]")
result.append(item)
return result
def _text_evidence(self, video_id: str, video: dict, terms: list[str]) -> tuple[float, list[dict]]:
normalized_terms = [normalize_text(term) for term in terms if normalize_text(term)]
if not normalized_terms:
return 0.0, []
evidence: list[dict] = []
metadata = {
"filename": video.get("display_name") or "",
"path": video.get("source_key") or "",
"title": video.get("title") or "",
"catalog_code": video.get("catalog_code") or "",
"studio": video.get("studio") or "",
"series": video.get("series") or "",
"description": video.get("description") or "",
}
for kind, value in metadata.items():
normalized = normalize_text(value)
matched = [term for term in normalized_terms if term in normalized]
if matched:
evidence.append({"kind": kind, "text": value[:240], "terms": matched})
with self.db.read() as conn:
rows = conn.execute(
"SELECT kind,raw_text,start_ms,end_ms,frame_id FROM text_entries WHERE video_id=?",
(video_id,),
).fetchall()
for row in rows:
normalized = normalize_text(row["raw_text"])
matched = [term for term in normalized_terms if term in normalized]
if matched:
evidence.append(
{
"kind": row["kind"],
"text": row["raw_text"][:240],
"terms": matched,
"start_ms": row["start_ms"],
"end_ms": row["end_ms"],
"frame_id": row["frame_id"],
}
)
if len(evidence) >= 3:
break
return (1.0, evidence[:3]) if evidence else (0.0, [])
def _visual_evidence(self, video_id: str, prompt: str) -> tuple[float, list[dict]]:
vector = self.embeddings.encode_text(prompt)
hits = self.vectors.search("frames", vector, 3, video_ids=[video_id])
if not hits:
return 0.0, []
ids = [hit.id for hit in hits]
placeholders = ",".join("?" for _ in ids)
with self.db.read() as conn:
rows = conn.execute(
f"SELECT id,timestamp_ms,segment_start_ms,segment_end_ms FROM frames WHERE id IN ({placeholders})",
ids,
).fetchall()
frames = {row["id"]: dict(row) for row in rows}
evidence = []
for hit in hits:
frame = frames.get(hit.id)
if frame:
evidence.append(
{
"kind": "visual",
"frame_id": hit.id,
"timestamp_ms": frame["timestamp_ms"],
"start_ms": frame["segment_start_ms"],
"end_ms": frame["segment_end_ms"],
"score": round(float(hit.score), 6),
}
)
return float(hits[0].score), evidence
def _store(self, video: dict, tag: dict, confidence: float, evidence: list[dict], force: bool) -> None:
now = utcnow()
with self.db.transaction() as conn:
assigned = conn.execute(
"SELECT 1 FROM video_tags WHERE video_id=? AND tag_id=?", (video["id"], tag["id"])
).fetchone()
existing = conn.execute(
"SELECT status,video_fingerprint,tag_revision FROM tag_suggestions WHERE video_id=? AND tag_id=?",
(video["id"], tag["id"]),
).fetchone()
if assigned:
return
unchanged_decision = (
existing
and existing["status"] in {"accepted", "rejected"}
and existing["video_fingerprint"] == video["fingerprint"]
and existing["tag_revision"] == tag["updated_at"]
and not force
)
if unchanged_decision:
return
conn.execute(
"INSERT INTO tag_suggestions(id,video_id,tag_id,confidence,evidence_json,status,"
"video_fingerprint,tag_revision,created_at,updated_at) VALUES(?,?,?,?,?,'pending',?,?,?,?) "
"ON CONFLICT(video_id,tag_id) DO UPDATE SET confidence=excluded.confidence,"
"evidence_json=excluded.evidence_json,status='pending',video_fingerprint=excluded.video_fingerprint,"
"tag_revision=excluded.tag_revision,updated_at=excluded.updated_at",
(
str(uuid.uuid4()),
video["id"],
tag["id"],
confidence,
json.dumps(evidence, ensure_ascii=False),
video["fingerprint"],
tag["updated_at"],
now,
now,
),
)
def analyze(self, job_id: str, video_ids: list[str], force: bool = False) -> None:
tags = self._enabled_tags()
if not tags:
self.jobs.update(job_id, 1, "没有启用 AI 的标签")
return
total = max(1, len(video_ids))
visual_unavailable = False
for position, video_id in enumerate(video_ids, 1):
self.jobs.checkpoint(job_id, f"准备分析标签 {position}/{total}")
video = self._video(video_id)
if not video:
continue
candidates: dict[str, list[tuple[dict, float, list[dict]]]] = defaultdict(list)
for tag in tags:
self.jobs.checkpoint(job_id)
if tag["ai_method"] == "text":
score, evidence = self._text_evidence(
video_id,
video,
tag["match_terms"] or [tag["name"]],
)
else:
prompt = "".join(
value for value in (tag["group_name"], tag["name"], tag["ai_description"].strip()) if value
)
try:
score, evidence = self._visual_evidence(video_id, prompt)
except ModelUnavailable:
visual_unavailable = True
continue
if score >= float(tag["ai_threshold"]):
candidates[tag["group_id"]].append((tag, score, evidence))
for items in candidates.values():
items.sort(key=lambda value: value[1], reverse=True)
selected = items[:1] if items[0][0]["selection_mode"] == "single" else items
for tag, score, evidence in selected:
self._store(video, tag, score, evidence, force)
self.jobs.update(job_id, position / total, f"分析标签 {position}/{total}")
suffix = ";视觉模型未安装,已跳过画面标签" if visual_unavailable else ""
self.jobs.update(job_id, 1, f"标签建议分析完成{suffix}")
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import re
import unicodedata
try:
from zhconv import convert as _zh_convert
except ImportError: # pragma: no cover - source checkouts may omit release-only wheels
_zh_convert = None
_WORD = re.compile(r"[a-z0-9]+(?:['_-][a-z0-9]+)*", re.IGNORECASE)
_CJK = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]+")
def normalize_text(value: str) -> str:
return unicodedata.normalize("NFKC", value).strip().lower()
def search_tokens(value: str) -> list[str]:
"""Generate stable English tokens and overlapping CJK 1-3 grams.
FTS5's stock tokenizer does not segment Chinese. Overlapping grams keep the
package self-contained and make names/OCR searchable without a native tokenizer.
"""
normalized = normalize_text(value)
tokens: list[str] = []
tokens.extend(match.group(0) for match in _WORD.finditer(normalized))
for match in _CJK.finditer(normalized):
chunk = match.group(0)
if len(chunk) == 1:
tokens.append(chunk)
continue
for width in (2, 3):
tokens.extend(chunk[i : i + width] for i in range(len(chunk) - width + 1))
return list(dict.fromkeys(tokens))
def chinese_variants(value: str) -> list[str]:
"""Return normalized simplified/traditional query variants.
Conversion happens at query time so existing transcripts become searchable
immediately after an upgrade and never need an expensive AI re-index.
"""
normalized = normalize_text(value)
variants = [normalized]
if normalized and _zh_convert is not None:
variants.extend((_zh_convert(normalized, "zh-cn"), _zh_convert(normalized, "zh-tw")))
return list(dict.fromkeys(item for item in variants if item))
def fts_query(value: str) -> str:
tokens = search_tokens(value)
return " OR ".join(f'"{token.replace(chr(34), chr(34) * 2)}"' for token in tokens)
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
from __future__ import annotations
import json
import os
import shutil
import threading
import time
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from pathlib import Path
from .config import Settings
from .database import Database, utcnow
def _path_size(path: Path) -> int:
"""Return logical file sizes without following directory symlinks."""
try:
if path.is_symlink():
return 0
if path.is_file():
return path.stat().st_size
if not path.is_dir():
return 0
except OSError:
return 0
total = 0
stack = [path]
while stack:
current = stack.pop()
try:
with os.scandir(current) as entries:
for entry in entries:
try:
if entry.is_symlink():
continue
if entry.is_dir(follow_symlinks=False):
stack.append(Path(entry.path))
elif entry.is_file(follow_symlinks=False):
total += entry.stat(follow_symlinks=False).st_size
except OSError:
continue
except OSError:
continue
return total
def _root_size(path: Path, *, missing_available: bool = False) -> tuple[int, bool, str | None]:
try:
if path.is_symlink():
return 0, False, "符号链接目录不会被统计"
if not path.exists():
if missing_available:
return 0, True, None
return 0, False, "目录不存在"
if not os.access(path, os.R_OK):
return 0, False, "目录不可读"
return _path_size(path), True, None
except OSError as exc:
return 0, False, f"无法读取目录:{exc}"
def _entry(bytes_value: int, *, available: bool = True, reason: str | None = None, **extra) -> dict:
value = {"bytes": int(bytes_value), "available": bool(available)}
if reason:
value["reason"] = reason
value.update(extra)
return value
def _dedupe_roots(paths: list[Path]) -> list[Path]:
roots: list[Path] = []
for path in sorted({str(item) for item in paths}):
candidate = Path(path)
if any(candidate == root or root in candidate.parents for root in roots):
continue
roots = [root for root in roots if root not in candidate.parents]
roots.append(candidate)
return roots
class StorageUsageService:
def __init__(
self,
db: Database,
settings: Settings,
rclone=None,
remote_cache=None,
sources=None,
cache_seconds: float = 30.0,
):
self.db = db
self.settings = settings
self.rclone = rclone
self.remote_cache = remote_cache
self.sources = sources
self.cache_seconds = cache_seconds
self._lock = threading.Lock()
self._cached_at = 0.0
self._cached: dict | None = None
self._capacity_cache: dict[str, tuple[float, dict]] = {}
@staticmethod
def _capacity_result(
source_id: str,
*,
scope: str,
status: str,
provider: str,
total: int = 0,
used: int = 0,
available: int = 0,
reason: str | None = None,
cache_seconds: float = 30,
) -> dict:
result = {
"source_id": source_id,
"scope": scope,
"status": status,
"total_bytes": max(0, int(total)),
"used_bytes": max(0, int(used)),
"available_bytes": max(0, int(available)),
"provider": provider,
"collected_at": datetime.now(UTC).isoformat(),
"cached_seconds": int(cache_seconds),
}
if reason:
result["reason"] = reason
return result
def _webdav_capacity(self, source: dict) -> dict | None:
connector = self.sources.connector(source["id"])
try:
response = connector.client.request(
"PROPFIND",
connector.base_url,
headers={"Depth": "0", "Content-Type": "application/xml; charset=utf-8"},
content=(
"<?xml version='1.0' encoding='utf-8'?>"
"<d:propfind xmlns:d='DAV:'><d:prop><d:quota-used-bytes/>"
"<d:quota-available-bytes/></d:prop></d:propfind>"
),
)
if response.status_code != 207:
raise RuntimeError(f"WebDAV 配额查询失败:HTTP {response.status_code}")
root = ET.fromstring(response.content)
used_text = root.findtext(".//{DAV:}quota-used-bytes")
free_text = root.findtext(".//{DAV:}quota-available-bytes")
if used_text is None or free_text is None:
return None
used = max(0, int(used_text))
free = max(0, int(free_text))
return {"total_bytes": used + free, "used_bytes": used, "available_bytes": free}
finally:
connector.close()
def _calculate_capacity(self, source_id: str) -> dict:
if self.sources is None:
raise RuntimeError("媒体来源服务尚未配置")
source = self.sources.get(source_id)
if source["kind"] == "local":
try:
disk = shutil.disk_usage(Path(source["config"]["path"]))
except OSError as exc:
return self._capacity_result(
source_id,
scope="local",
status="unreachable",
provider="local",
reason=f"本地来源不可访问:{type(exc).__name__}",
cache_seconds=self.cache_seconds,
)
return self._capacity_result(
source_id,
scope="local",
status="available",
provider="local",
total=disk.total,
used=disk.used,
available=disk.free,
cache_seconds=self.cache_seconds,
)
config = source.get("config", {})
override_total = config.get("capacity_override_total_bytes")
override_available = config.get("capacity_override_available_bytes")
try:
override_total = int(override_total) if override_total is not None else None
override_available = int(override_available) if override_available is not None else None
except (TypeError, ValueError):
override_total = override_available = None
if (
override_total is not None
and override_available is not None
and override_total > 0
and 0 <= override_available <= override_total
):
return self._capacity_result(
source_id,
scope="remote",
status="available",
provider="manual",
total=override_total,
used=override_total - override_available,
available=override_available,
reason="管理员手工设置;不会自动随云盘变化",
cache_seconds=self.cache_seconds,
)
native_or_alist = bool(
config.get("storage_backend") == "openlist_native"
or config.get("mode") == "encrypted"
or config.get("driver") == "alist"
)
try:
values = self.rclone.about(source) if native_or_alist and self.rclone is not None else None
provider = "rclone" if values is not None else "webdav"
if values is None:
values = self._webdav_capacity(source)
except NotImplementedError:
values = None
provider = "rclone" if native_or_alist else "webdav"
except Exception as exc:
return self._capacity_result(
source_id,
scope="remote",
status="unreachable",
provider="rclone" if native_or_alist else "webdav",
reason=f"无法连接远端配额接口:{type(exc).__name__}",
cache_seconds=self.cache_seconds,
)
if values is None:
return self._capacity_result(
source_id,
scope="remote",
status="unsupported",
provider=provider,
reason="此媒体来源未提供容量配额",
cache_seconds=self.cache_seconds,
)
return self._capacity_result(
source_id,
scope="remote",
status="available",
provider=provider,
total=values["total_bytes"],
used=values["used_bytes"],
available=values["available_bytes"],
cache_seconds=self.cache_seconds,
)
def configure_capacity_override(
self,
source_id: str,
*,
total_bytes: int | None,
available_bytes: int | None,
) -> dict:
source = self.sources.get(source_id)
if source["kind"] == "local":
raise ValueError("本地媒体来源使用文件系统真实容量,不能手工覆盖")
if (total_bytes is None) != (available_bytes is None):
raise ValueError("总容量与可用容量必须同时填写或同时清除")
if total_bytes is not None and (total_bytes <= 0 or not 0 <= available_bytes <= total_bytes):
raise ValueError("可用容量必须大于等于 0 且不能超过总容量")
config = dict(source.get("config") or {})
if total_bytes is None:
config.pop("capacity_override_total_bytes", None)
config.pop("capacity_override_available_bytes", None)
else:
config["capacity_override_total_bytes"] = int(total_bytes)
config["capacity_override_available_bytes"] = int(available_bytes)
with self.db.transaction() as conn:
conn.execute(
"UPDATE sources SET config_json=?,updated_at=? WHERE id=?",
(json.dumps(config), utcnow(), source_id),
)
with self._lock:
self._capacity_cache.pop(source_id, None)
return self.capacity(source_id, refresh=True)
def capacity(self, source_id: str, *, refresh: bool = False) -> dict:
now = time.monotonic()
with self._lock:
cached = self._capacity_cache.get(source_id)
if not refresh and cached is not None and now - cached[0] < self.cache_seconds:
return dict(cached[1])
result = self._calculate_capacity(source_id)
with self._lock:
self._capacity_cache[source_id] = (time.monotonic(), dict(result))
return result
def _postgres_relation_bytes(self) -> tuple[int, int]:
with self.db.read() as conn:
database = conn.execute("SELECT pg_database_size(current_database())").fetchone()
vectors = conn.execute(
"SELECT coalesce(pg_total_relation_size('frames'),0)+"
"coalesce(pg_total_relation_size('faces'),0)+coalesce(pg_total_relation_size('people'),0)"
).fetchone()
return int(database[0] or 0), int(vectors[0] or 0)
def _openlist_staging_paths(self) -> list[dict]:
rows: list[dict] = []
try:
with self.db.read() as conn:
sources = conn.execute("SELECT id,name,config_json FROM sources WHERE kind='webdav'").fetchall()
except Exception:
return rows
for row in sources:
try:
config = json.loads(row["config_json"] or "{}")
except (TypeError, ValueError, json.JSONDecodeError):
continue
value = str(config.get("openlist_local_staging_path") or "").strip()
if value:
rows.append({"source_id": str(row["id"]), "name": str(row["name"]), "path": Path(value)})
return rows
@staticmethod
def _child_total(children: dict[str, dict]) -> tuple[int, int, int]:
total = active = reclaimable = 0
for value in children.values():
total += int(value.get("bytes") or 0)
active += int(value.get("active_bytes") or 0)
reclaimable += int(value.get("reclaimable_bytes") or 0)
return total, active, reclaimable
def _calculate(self) -> dict:
disk = shutil.disk_usage(self.settings.data_dir)
data_total = _path_size(self.settings.data_dir)
postgres_total, ai_index_bytes = self._postgres_relation_bytes()
database_bytes = max(0, postgres_total - ai_index_bytes)
models, models_ok, models_reason = _root_size(self.settings.models_dir, missing_available=True)
thumbnails, thumbnails_ok, thumbnails_reason = _root_size(
self.settings.thumbnails_dir, missing_available=True
)
preview, preview_ok, preview_reason = _root_size(self.settings.preview_dir, missing_available=True)
query_images, query_ok, query_reason = _root_size(
self.settings.uploads_dir, missing_available=True
)
remote_media, remote_media_ok, remote_media_reason = _root_size(
self.settings.remote_media_cache_dir, missing_available=True
)
upload_receive, upload_ok, upload_reason = _root_size(
self.settings.upload_staging_dir, missing_available=True
)
download_staging, download_ok, download_reason = _root_size(
self.settings.download_staging_dir, missing_available=True
)
encryption_work, encryption_ok, encryption_reason = _root_size(
self.settings.rclone_dir / "local-encryption", missing_available=True
)
rclone_status = self.rclone.status() if self.rclone is not None else {}
remote_status = self.remote_cache.status() if self.remote_cache is not None else {}
remote_children = {
"remote_media_cache": _entry(
remote_media,
available=remote_media_ok,
reason=remote_media_reason,
active_bytes=int(remote_status.get("active_bytes") or 0),
reclaimable_bytes=int(remote_status.get("evictable_bytes") or 0),
entries=int(remote_status.get("entries") or 0),
),
"rclone_vfs_cache": _entry(
int(rclone_status.get("vfs_cache_bytes") or 0),
active_bytes=int(rclone_status.get("active_cache_bytes") or 0),
reclaimable_bytes=int(rclone_status.get("reclaimable_cache_bytes") or 0),
instances=rclone_status.get("instances") or [],
),
"rclone_runtime": _entry(int(rclone_status.get("runtime_bytes") or 0)),
}
upload_children = {
"upload_receive": _entry(upload_receive, available=upload_ok, reason=upload_reason),
"download_staging": _entry(download_staging, available=download_ok, reason=download_reason),
"encryption_work": _entry(encryption_work, available=encryption_ok, reason=encryption_reason),
}
staging_rows = self._openlist_staging_paths()
staging_roots: list[Path] = []
staging_details: list[dict] = []
for item in staging_rows:
size, available, reason = _root_size(item["path"])
staging_details.append(
{
"source_id": item["source_id"],
"name": item["name"],
"path": str(item["path"]),
**_entry(size, available=available, reason=reason),
}
)
if available and not item["path"].is_symlink():
staging_roots.append(item["path"].resolve())
unique_staging = _dedupe_roots(staging_roots)
staging_size = sum(_path_size(path) for path in unique_staging)
upload_children["openlist_local_staging"] = _entry(
staging_size,
available=all(item.get("available", False) for item in staging_details)
if staging_details
else True,
reason="部分 OpenList 中转目录当前不可访问"
if any(not item.get("available", False) for item in staging_details)
else None,
locations=staging_details,
)
preview_total, preview_active, preview_reclaimable = self._child_total(
{
"preview_cache": _entry(preview, available=preview_ok, reason=preview_reason),
"query_images": _entry(query_images, available=query_ok, reason=query_reason),
}
)
remote_total, remote_active, remote_reclaimable = self._child_total(remote_children)
upload_total, upload_active, upload_reclaimable = self._child_total(upload_children)
categories: dict[str, dict] = {
"database": _entry(database_bytes),
"ai_index": _entry(ai_index_bytes, mode="pgvector"),
"models": _entry(models, available=models_ok, reason=models_reason),
"thumbnails": _entry(thumbnails, available=thumbnails_ok, reason=thumbnails_reason),
"preview_cache": _entry(
preview_total,
available=preview_ok and query_ok,
active_bytes=preview_active,
reclaimable_bytes=preview_reclaimable,
children={
"preview_cache": _entry(preview, available=preview_ok, reason=preview_reason),
"query_images": _entry(query_images, available=query_ok, reason=query_reason),
},
),
"remote_cache": _entry(
remote_total,
active_bytes=remote_active,
reclaimable_bytes=remote_reclaimable,
children=remote_children,
),
"upload_staging": _entry(
upload_total,
available=all(value.get("available", True) for value in upload_children.values()),
active_bytes=upload_active,
reclaimable_bytes=upload_reclaimable,
children=upload_children,
),
}
managed_roots = _dedupe_roots(
[
self.settings.models_dir,
self.settings.thumbnails_dir,
self.settings.preview_dir,
self.settings.uploads_dir,
self.settings.remote_media_cache_dir,
self.settings.rclone_dir,
self.settings.upload_staging_dir,
self.settings.download_staging_dir,
]
)
known_in_data = sum(
_path_size(path)
for path in managed_roots
if self.settings.data_dir in path.parents or path == self.settings.data_dir
)
categories["other"] = _entry(max(0, data_total - known_in_data))
external_staging = sum(
_path_size(path)
for path in unique_staging
if self.settings.data_dir not in path.parents and path != self.settings.data_dir
)
app_bytes = data_total + postgres_total + external_staging
return {
"schema_version": 2,
"collected_at": datetime.now(UTC).isoformat(),
"disk": {"total_bytes": disk.total, "used_bytes": disk.used, "available_bytes": disk.free},
"app_bytes": app_bytes,
"categories": categories,
"cached_seconds": self.cache_seconds,
}
def usage(self, *, refresh: bool = False) -> dict:
now = time.monotonic()
with self._lock:
if not refresh and self._cached is not None and now - self._cached_at < self.cache_seconds:
return self._cached
self._cached = self._calculate()
self._cached_at = now
return self._cached
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import array
import math
from dataclasses import dataclass
from typing import Literal
from .database import Database
Collection = Literal["frames", "faces"]
def pack_vector(vector: list[float]) -> bytes:
return array.array("f", vector).tobytes()
def unpack_vector(blob: bytes | None) -> list[float]:
if not blob:
return []
values = array.array("f")
values.frombytes(blob)
return values.tolist()
def normalize(vector: list[float]) -> list[float]:
norm = math.sqrt(sum(value * value for value in vector))
return [value / norm for value in vector] if norm else vector
def cosine(left: list[float], right: list[float]) -> float:
if len(left) != len(right) or not left:
return -1.0
return sum(a * b for a, b in zip(left, right, strict=True))
@dataclass(slots=True)
class VectorHit:
id: str
score: float
payload: dict
class PgVectorStore:
"""Store semantic vectors in PostgreSQL via pgvector."""
def __init__(self, db: Database):
self.db = db
def ensure(self, collection: Collection, dimensions: int) -> None:
return None
def upsert(self, collection: Collection, item_id: str, vector: list[float], payload: dict) -> None:
table = "frames" if collection == "frames" else "faces"
literal = "[" + ",".join(f"{value:.9g}" for value in vector) + "]"
with self.db.transaction() as conn:
conn.execute(
f"UPDATE {table} SET vector_blob=?, embedding=?::vector WHERE id=?",
(pack_vector(vector), literal, item_id),
)
def delete_video(self, video_id: str) -> None:
return None
def search(
self,
collection: Collection,
vector: list[float],
limit: int,
source_ids: list[str] | None = None,
video_ids: list[str] | None = None,
) -> list[VectorHit]:
table = "frames" if collection == "frames" else "faces"
literal = "[" + ",".join(f"{value:.9g}" for value in vector) + "]"
filter_args: list[object] = []
where = "t.embedding IS NOT NULL AND v.available=1"
if source_ids:
placeholders = ",".join("?" for _ in source_ids)
where += f" AND v.source_id IN ({placeholders})"
filter_args.extend(source_ids)
if video_ids:
placeholders = ",".join("?" for _ in video_ids)
where += f" AND t.video_id IN ({placeholders})"
filter_args.extend(video_ids)
# The first vector placeholder is in SELECT, before all WHERE filter
# placeholders. Preserve SQL placeholder order for psycopg.
args: list[object] = [literal, *filter_args, literal, limit]
with self.db.read() as conn:
rows = conn.execute(
f"SELECT t.id,t.video_id,1 - (t.embedding <=> ?::vector) AS score "
f"FROM {table} t JOIN videos v ON v.id=t.video_id WHERE {where} "
"ORDER BY t.embedding <=> ?::vector LIMIT ?",
args,
).fetchall()
return [VectorHit(row["id"], float(row["score"] or 0), {"video_id": row["video_id"]}) for row in rows]
def make_vector_store(db: Database):
return PgVectorStore(db)
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
".url": {
"imagefind.Application": {
"title": "ImageFind",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "",
"url": "/app/imagefind/",
"gatewayPrefix": "/app/imagefind",
"gatewaySocket": "imagefind.sock",
"allUsers": false,
"control": {
"accessPerm": "readonly"
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -eu
DATA_ROOT="${TRIM_PKGVAR:-}"
ENABLED="${wizard_direct_access:-false}"
PORT="${wizard_direct_port:-8765}"
unset wizard_direct_access wizard_direct_port
fail() {
MESSAGE="$1"
printf '%s\n' "$MESSAGE" >&2
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
printf '%s\n' "$MESSAGE" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
fi
exit 1
}
[ -n "$DATA_ROOT" ] || fail "ImageFind 配置失败:fnOS 未提供应用数据目录。"
case "$ENABLED" in
true|false) ;;
*) fail "ImageFind 配置失败:直接访问开关无效。" ;;
esac
case "$PORT" in
''|*[!0-9]*) fail "ImageFind 配置失败:端口必须是数字。" ;;
esac
[ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || fail "ImageFind 配置失败:端口必须在 165535 之间。"
mkdir -p "$DATA_ROOT"
ACCESS_TEMP="$DATA_ROOT/access.json.tmp.$$"
trap 'rm -f "$ACCESS_TEMP"' EXIT
printf '{"direct_access":{"enabled":%s,"port":%s}}\n' "$ENABLED" "$PORT" >"$ACCESS_TEMP"
chmod 0600 "$ACCESS_TEMP"
mv "$ACCESS_TEMP" "$DATA_ROOT/access.json"
trap - EXIT
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
set -eu
DATA_ROOT="${TRIM_PKGVAR:-}"
APP_ROOT="${TRIM_APPDEST:-}"
COMMAND_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PASSWORD="${wizard_admin_password:-}"
PASSWORD_CONFIRM="${wizard_admin_password_confirm:-}"
POSTGRES_TOKEN="${wizard_postgres_enrollment_token:-}"
unset wizard_admin_password wizard_admin_password_confirm wizard_postgres_enrollment_token
fail() {
MESSAGE="$1"
printf '%s\n' "$MESSAGE" >&2
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
printf '%s\n' "$MESSAGE" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
fi
exit 1
}
[ -n "$DATA_ROOT" ] || fail "ImageFind 初始化失败:fnOS 未提供应用数据目录。"
[ -n "$APP_ROOT" ] || fail "ImageFind 初始化失败:fnOS 未提供应用安装目录。"
[ "$PASSWORD" = "$PASSWORD_CONFIRM" ] || fail "ImageFind 初始化失败:两次输入的管理员密码不一致。"
[ "${#PASSWORD}" -ge 10 ] || fail "ImageFind 初始化失败:管理员密码至少需要 10 个字符。"
[ "${#PASSWORD}" -le 256 ] || fail "ImageFind 初始化失败:管理员密码不能超过 256 个字符。"
[ "${#POSTGRES_TOKEN}" -ge 20 ] || fail "ImageFind 初始化失败:PostgreSQL 应用接入令牌至少需要 20 个字符。"
[ "${#POSTGRES_TOKEN}" -le 256 ] || fail "ImageFind 初始化失败:PostgreSQL 应用接入令牌不能超过 256 个字符。"
case "$POSTGRES_TOKEN" in
*'
'*) fail "ImageFind 初始化失败:PostgreSQL 应用接入令牌不能包含换行。" ;;
esac
[ -x "$COMMAND_ROOT/runtime_bootstrap" ] || fail "ImageFind 初始化失败:找不到运行环境初始化程序。"
mkdir -p "$DATA_ROOT/data" "$DATA_ROOT/cache"
printf '%s\n' 'imagefind-data-v1' >"$DATA_ROOT/.imagefind-data-root"
if [ ! -f "$DATA_ROOT/access.json" ]; then
ACCESS_TEMP="$DATA_ROOT/access.json.tmp.$$"
printf '%s\n' '{"direct_access":{"enabled":false,"port":8765}}' >"$ACCESS_TEMP"
chmod 0600 "$ACCESS_TEMP"
mv "$ACCESS_TEMP" "$DATA_ROOT/access.json"
fi
export IMAGEFIND_DATA_DIR="$DATA_ROOT/data"
export XDG_CACHE_HOME="$DATA_ROOT/cache"
if ! TRIM_APPDEST="$APP_ROOT" TRIM_PKGVAR="$DATA_ROOT" "$COMMAND_ROOT/runtime_bootstrap" \
>>"${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1; then
fail "ImageFind 初始化失败:Python 3.12 核心运行环境安装失败。"
fi
PYTHON="$DATA_ROOT/runtime/current/bin/python"
TOKEN_SEED="$DATA_ROOT/postgres-enrollment-token.seed"
TOKEN_TEMP="$DATA_ROOT/.postgres-enrollment-token.seed.tmp.$$"
printf '%s' "$POSTGRES_TOKEN" >"$TOKEN_TEMP"
chmod 0600 "$TOKEN_TEMP"
mv "$TOKEN_TEMP" "$TOKEN_SEED"
if ! "$PYTHON" -m imagefind.main postgres-enroll \
>>"${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1; then
fail "ImageFind 初始化失败:无法接入 PostgreSQL 共享服务,请确认 nxsir.postgresql 已启动且接入令牌正确。"
fi
if ! printf '%s' "$PASSWORD" | "$PYTHON" -m imagefind.main admin-password --stdin --replace \
>>"${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1; then
fail "ImageFind 初始化失败:无法保存管理员密码。"
fi
unset PASSWORD PASSWORD_CONFIRM POSTGRES_TOKEN
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Pre-install runs before fnOS has guaranteed that TRIM_PKGVAR exists and is
# writable by the package account. Runtime startup creates its own data tree.
exit 0
Executable
+120
View File
@@ -0,0 +1,120 @@
#!/bin/bash
set -eu
PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT/app}"
DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}"
RUN_ROOT="$DATA_ROOT/run"
LOG_ROOT="$DATA_ROOT/log"
PID_FILE="$RUN_ROOT/imagefind.pid"
LOG_FILE="$LOG_ROOT/imagefind.log"
ACCESS_CONFIG="$DATA_ROOT/access.json"
GATEWAY_SOCKET="$APP_ROOT/imagefind.sock"
mkdir -p "$RUN_ROOT" "$LOG_ROOT" "$DATA_ROOT/data" "$DATA_ROOT/tmp"
is_running() {
[ -f "$PID_FILE" ] || return 1
PID=$(sed -n '1p' "$PID_FILE")
[ -n "$PID" ] && kill -0 "$PID" 2>/dev/null
}
report_start_failure() {
MESSAGE="ImageFind failed to start. Runtime log: $LOG_FILE"
printf '%s\n' "$MESSAGE" >&2
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
{
printf '%s\n' "$MESSAGE"
printf '%s\n' '--- imagefind.log (last 100 lines) ---'
tail -n 100 "$LOG_FILE" 2>/dev/null || true
} >>"$TRIM_TEMP_LOGFILE" 2>&1 || true
fi
}
start_app() {
if is_running; then
return 0
fi
rm -f "$PID_FILE"
if ! TRIM_APPDEST="$APP_ROOT" TRIM_PKGVAR="$DATA_ROOT" "$PACKAGE_ROOT/cmd/runtime_bootstrap" \
>>"$LOG_FILE" 2>&1; then
report_start_failure
return 1
fi
SERVER="$DATA_ROOT/runtime/current/bin/python"
export IMAGEFIND_DATA_DIR="$DATA_ROOT/data"
export IMAGEFIND_FRONTEND_DIR="$APP_ROOT/frontend"
export IMAGEFIND_BUNDLED_AI_RUNTIME=false
export IMAGEFIND_RUNTIME_REQUIREMENTS_DIR="$APP_ROOT/runtime/ai"
export IMAGEFIND_BUNDLED_TOOLS_DIR="$APP_ROOT/bin"
export IMAGEFIND_POSTGRES_CONF="$DATA_ROOT/postgres-client.conf"
export IMAGEFIND_POSTGRES_ENROLLMENT_TOKEN_SEED="$DATA_ROOT/postgres-enrollment-token.seed"
export PYTHONPATH="$DATA_ROOT/runtime/ai-current${PYTHONPATH:+:$PYTHONPATH}"
export XDG_CACHE_HOME="$DATA_ROOT/cache"
# Model conversion can temporarily exceed the small system /tmp volume.
# Keep all large, recoverable conversion files on the application data volume.
export TMPDIR="$DATA_ROOT/tmp"
DIRECT_ENABLED=true
DIRECT_PORT=8765
if [ -f "$ACCESS_CONFIG" ]; then
CONFIG_ENABLED=$(sed -n 's/.*"enabled"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p' "$ACCESS_CONFIG" | head -n 1)
CONFIG_PORT=$(sed -n 's/.*"port"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$ACCESS_CONFIG" | head -n 1)
[ -z "$CONFIG_ENABLED" ] || DIRECT_ENABLED="$CONFIG_ENABLED"
[ -z "$CONFIG_PORT" ] || DIRECT_PORT="$CONFIG_PORT"
fi
export IMAGEFIND_HOST="0.0.0.0"
export IMAGEFIND_PORT="$DIRECT_PORT"
export IMAGEFIND_DIRECT_ACCESS="$DIRECT_ENABLED"
export IMAGEFIND_GATEWAY_PREFIX="/app/imagefind"
export IMAGEFIND_GATEWAY_SOCKET="$GATEWAY_SOCKET"
# Keep CPU fallback responsive on a NAS. Administrators can override any
# value before launch, while the default avoids saturating every core.
export IMAGEFIND_AI_CPU_THREADS="${IMAGEFIND_AI_CPU_THREADS:-1}"
export OMP_NUM_THREADS="${OMP_NUM_THREADS:-$IMAGEFIND_AI_CPU_THREADS}"
export OPENBLAS_NUM_THREADS="${OPENBLAS_NUM_THREADS:-$IMAGEFIND_AI_CPU_THREADS}"
export MKL_NUM_THREADS="${MKL_NUM_THREADS:-$IMAGEFIND_AI_CPU_THREADS}"
export LD_LIBRARY_PATH="$APP_ROOT/bin${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
if [ ! -f "$IMAGEFIND_POSTGRES_CONF" ] && [ -f "$IMAGEFIND_POSTGRES_ENROLLMENT_TOKEN_SEED" ]; then
if ! "$SERVER" -m imagefind.main postgres-enroll >>"$LOG_FILE" 2>&1; then
report_start_failure
return 1
fi
fi
nohup "$SERVER" -m imagefind.main >>"$LOG_FILE" 2>&1 &
printf '%s\n' "$!" > "$PID_FILE"
sleep 1
if ! is_running; then
report_start_failure
return 1
fi
}
stop_pid() {
FILE="$1"
[ -f "$FILE" ] || return 0
PID=$(sed -n '1p' "$FILE")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
kill "$PID" 2>/dev/null || true
COUNT=0
while kill -0 "$PID" 2>/dev/null && [ "$COUNT" -lt 20 ]; do
sleep 1
COUNT=$((COUNT + 1))
done
kill -9 "$PID" 2>/dev/null || true
fi
rm -f "$FILE"
}
case "${1:-status}" in
start) start_app ;;
stop) stop_pid "$PID_FILE"; rm -f "$GATEWAY_SOCKET" ;;
restart) "$0" stop; "$0" start ;;
status)
if is_running; then
exit 0
fi
# fnOS reserves 3 for a valid status check whose service is stopped.
exit 3
;;
*) printf 'usage: %s {start|stop|restart|status}\n' "$0" >&2; exit 2 ;;
esac
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
set -eu
PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT/app}"
DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}"
VERSION_FILE="$APP_ROOT/runtime/VERSION"
WHEEL_ROOT="$APP_ROOT/runtime/wheels"
CORE_REQUIREMENTS="$APP_ROOT/runtime/runtime-core.txt"
RUNTIME_ROOT="$DATA_ROOT/runtime"
fail() {
printf '%s\n' "$1" >&2
exit 1
}
[ -f "$VERSION_FILE" ] || fail "ImageFind 运行环境初始化失败:安装包缺少版本信息。"
[ -d "$WHEEL_ROOT" ] || fail "ImageFind 运行环境初始化失败:安装包缺少核心 wheelhouse。"
[ -f "$CORE_REQUIREMENTS" ] || fail "ImageFind 运行环境初始化失败:安装包缺少核心依赖锁文件。"
VERSION=$(sed -n '1p' "$VERSION_FILE")
case "$VERSION" in
''|*[!0-9.]* ) fail "ImageFind 运行环境初始化失败:版本信息无效。" ;;
esac
PACKAGE_ID=$(sed -n '2p' "$VERSION_FILE")
case "$PACKAGE_ID" in
''|*[!0-9a-f]* ) fail "ImageFind 运行环境初始化失败:安装包指纹无效。" ;;
esac
[ "${#PACKAGE_ID}" -eq 64 ] || fail "ImageFind 运行环境初始化失败:安装包指纹长度无效。"
if [ -n "${IMAGEFIND_PYTHON_PATH:-}" ]; then
SYSTEM_PYTHON="$IMAGEFIND_PYTHON_PATH"
else
SYSTEM_PYTHON="/var/apps/python312/target/bin/python3"
fi
[ -x "$SYSTEM_PYTHON" ] || fail "ImageFind 需要 fnOS Python 3.12 依赖,请先确认 python312 已安装。"
PYTHON_VERSION=$($SYSTEM_PYTHON -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) \
|| fail "ImageFind 无法运行 fnOS Python 3.12。"
[ "$PYTHON_VERSION" = "3.12" ] || fail "ImageFind 需要 Python 3.12,当前为 $PYTHON_VERSION。"
mkdir -p "$RUNTIME_ROOT"
TARGET="$RUNTIME_ROOT/core-$VERSION"
STAGE="$RUNTIME_ROOT/.core-$VERSION.tmp.$$"
CURRENT="$RUNTIME_ROOT/current"
runtime_valid() {
[ -x "$1/bin/python" ] || return 1
[ -f "$1/.imagefind-package-id" ] || return 1
[ "$(sed -n '1p' "$1/.imagefind-package-id")" = "$PACKAGE_ID" ] || return 1
RUNTIME_VERSION=$(
IMAGEFIND_DATA_DIR="$DATA_ROOT/data" "$1/bin/python" -c \
'import imagefind; print(imagefind.__version__)' 2>"$RUNTIME_ROOT/version-check.err"
) || return 1
[ "$RUNTIME_VERSION" = "$VERSION" ]
}
if ! runtime_valid "$TARGET"; then
rm -rf "$STAGE"
"$SYSTEM_PYTHON" -m venv "$STAGE" \
|| { rm -rf "$STAGE"; fail "ImageFind 无法创建 Python 3.12 核心运行环境。"; }
APP_WHEEL=""
for candidate in "$APP_ROOT"/runtime/imagefind-*.whl; do
if [ -f "$candidate" ]; then
APP_WHEEL="$candidate"
break
fi
done
[ -n "$APP_WHEEL" ] \
|| { rm -rf "$STAGE"; fail "ImageFind 运行环境初始化失败:安装包缺少应用 wheel。"; }
if ! "$STAGE/bin/python" -m pip install \
--disable-pip-version-check --no-input --no-index \
--find-links "$WHEEL_ROOT" --requirement "$CORE_REQUIREMENTS" "$APP_WHEEL"; then
rm -rf "$STAGE"
fail "ImageFind 核心运行环境离线安装失败,请重新安装应用。"
fi
printf '%s\n' "$PACKAGE_ID" >"$STAGE/.imagefind-package-id"
runtime_valid "$STAGE" \
|| {
if [ -s "$RUNTIME_ROOT/version-check.err" ]; then
cat "$RUNTIME_ROOT/version-check.err" >&2 || true
fi
rm -rf "$STAGE"
fail "ImageFind 核心运行环境校验失败。"
}
if [ -e "$TARGET" ]; then
BACKUP="$RUNTIME_ROOT/core-$VERSION.invalid.$(date +%s)"
mv "$TARGET" "$BACKUP"
fi
mv "$STAGE" "$TARGET"
fi
LINK="$RUNTIME_ROOT/.current.$$"
rm -f "$LINK"
ln -s "$TARGET" "$LINK"
if [ -d "$CURRENT" ] && [ ! -L "$CURRENT" ]; then
mv "$CURRENT" "$RUNTIME_ROOT/current.previous.$(date +%s)"
fi
# mv follows a destination symlink that points at a directory and would place
# LINK *inside* the old runtime. Replace the directory entry itself so an
# upgrade atomically switches current even when it is a valid or dangling
# symlink left by an earlier release.
if ! "$SYSTEM_PYTHON" -c \
'import os,sys; os.replace(sys.argv[1], sys.argv[2])' \
"$LINK" "$CURRENT"; then
rm -f "$LINK"
fail "ImageFind 核心运行环境切换失败:无法原子更新 current。"
fi
if ! runtime_valid "$CURRENT"; then
if [ -L "$CURRENT" ]; then
CURRENT_LINK=$(readlink "$CURRENT" 2>/dev/null || printf '%s' '无法读取')
elif [ -e "$CURRENT" ]; then
CURRENT_LINK='不是符号链接'
else
CURRENT_LINK='不存在'
fi
if [ -x "$CURRENT/bin/python" ]; then
ACTUAL_VERSION=$(
IMAGEFIND_DATA_DIR="$DATA_ROOT/data" "$CURRENT/bin/python" -c \
'import imagefind; print(imagefind.__version__)' 2>"$RUNTIME_ROOT/version-check.err" \
|| printf '%s' '无法读取'
)
else
ACTUAL_VERSION='Python 不可执行'
fi
fail "ImageFind 核心运行环境切换失败:期望版本 $VERSIONcurrent=$CURRENT_LINK,实际版本=$ACTUAL_VERSION。"
fi
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
set -eu
fail() {
MESSAGE="$1"
printf '%s\n' "$MESSAGE" >&2
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
printf '%s\n' "$MESSAGE" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
fi
exit 1
}
case "${wizard_clear_data:-false}" in
1|true|TRUE|yes|YES|on|ON) ;;
*) exit 0 ;;
esac
DATA_ROOT="${TRIM_PKGVAR:-}"
[ -n "$DATA_ROOT" ] || fail "ImageFind 数据清理失败:fnOS 未提供应用数据目录。"
[ -e "$DATA_ROOT" ] || exit 0
[ -d "$DATA_ROOT" ] || fail "ImageFind 数据清理失败:应用数据路径不是目录。"
DATA_ROOT_REAL=$(readlink -f -- "$DATA_ROOT") || fail "ImageFind 数据清理失败:无法解析应用数据路径。"
case "$DATA_ROOT_REAL" in
""|/|/home|/var|/var/apps) fail "ImageFind 数据清理失败:拒绝清理不安全的路径。" ;;
esac
[ "$(dirname -- "$DATA_ROOT_REAL")" != "/" ] || fail "ImageFind 数据清理失败:拒绝清理顶层目录。"
[ -f "$DATA_ROOT_REAL/.imagefind-data-root" ] || fail "ImageFind 数据清理失败:应用数据目录缺少安全标记。"
[ "$(sed -n '1p' "$DATA_ROOT_REAL/.imagefind-data-root")" = "imagefind-data-v1" ] || \
fail "ImageFind 数据清理失败:应用数据目录安全标记无效。"
SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
"$SCRIPT_ROOT/main" stop || fail "ImageFind 数据清理失败:无法停止应用服务。"
# Only clear TRIM_PKGVAR. Shared video directories and user-authorized paths
# live outside this directory and are deliberately never traversed here.
find "$DATA_ROOT_REAL" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || \
fail "ImageFind 数据清理失败:部分内部数据无法删除。"
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
set -eu
DATA_ROOT="${TRIM_PKGVAR:-}"
APP_ROOT="${TRIM_APPDEST:-}"
COMMAND_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# Password changes belong to ImageFind's authenticated account page. Older
# upgrade wizards exported these variables, sometimes as placeholder strings
# when both fields were left blank. Deliberately ignore them so a package
# update can never fail after files have already been replaced.
unset wizard_admin_password wizard_admin_password_confirm
fail() {
MESSAGE="$1"
printf '%s\n' "$MESSAGE" >&2
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
printf '%s\n' "$MESSAGE" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
fi
exit 1
}
[ -n "$DATA_ROOT" ] || fail "ImageFind 升级失败:fnOS 未提供应用数据目录。"
[ -n "$APP_ROOT" ] || fail "ImageFind 升级失败:fnOS 未提供应用安装目录。"
umask 077
mkdir -p "$DATA_ROOT/data" "$DATA_ROOT/cache"
if [ ! -f "$DATA_ROOT/.imagefind-data-root" ]; then
printf '%s\n' 'imagefind-data-v1' >"$DATA_ROOT/.imagefind-data-root"
fi
if [ ! -f "$DATA_ROOT/access.json" ]; then
ACCESS_TEMP="$DATA_ROOT/access.json.tmp.$$"
printf '%s\n' '{"direct_access":{"enabled":true,"port":8765}}' >"$ACCESS_TEMP"
chmod 0600 "$ACCESS_TEMP"
mv "$ACCESS_TEMP" "$DATA_ROOT/access.json"
fi
if ! TRIM_APPDEST="$APP_ROOT" TRIM_PKGVAR="$DATA_ROOT" "$COMMAND_ROOT/runtime_bootstrap" \
>>"${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1; then
fail "ImageFind 升级失败:Python 3.12 核心运行环境安装失败。"
fi
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
printf '%s\n' "ImageFind 升级数据检查完成,现有数据库、配置和管理员密码均已保留。" \
>>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
fi
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -eu
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
printf '%s\n' "ImageFind 0.5.45 使用 PostgreSQL 数据层;本版本不执行旧数据迁移(包括 SQLite)。" \
>>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
fi
exit 0
+6
View File
@@ -0,0 +1,6 @@
{
"defaults": {
"run-as": "package"
},
"join-groups": ["video", "render"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"data-share": {
"shares": [
{
"name": "imagefind/videos"
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
appname=imagefind
version=0.5.45
display_name=ImageFind
desc=支持本地、AList 加密与直连的私有 AI 媒体库
platform=x86
source=thirdparty
maintainer=ImageFind Contributors
os_min_version=1.2.0
desktop_uidir=ui
desktop_applaunchname=imagefind.Application
checkport=false
ctl_stop=true
disable_authorization_path=false
install_dep_apps=python312,nxsir.postgresql
+33
View File
@@ -0,0 +1,33 @@
[
{
"stepTitle": "ImageFind 访问方式",
"items": [
{
"type": "tips",
"helpText": "飞牛桌面始终通过统一网关访问。需要继续使用 http://NAS_IP:8765 或 REST API 客户端时,可额外开启直接访问。"
},
{
"type": "switch",
"field": "wizard_direct_access",
"label": "启用直接 Web/API 访问",
"initValue": "false"
},
{
"type": "text",
"field": "wizard_direct_port",
"label": "直接访问端口",
"initValue": "8765",
"rules": [
{
"required": true,
"message": "请输入端口"
},
{
"pattern": "^(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$",
"message": "请输入 165535 之间的端口"
}
]
}
]
}
]
+68
View File
@@ -0,0 +1,68 @@
[
{
"stepTitle": "设置 ImageFind 管理员密码",
"items": [
{
"type": "tips",
"helpText": "此密码用于登录 ImageFind,不是飞牛 fnOS 的系统密码。请先安装并启动 nxsir.postgresql 共享服务。"
},
{
"type": "password",
"field": "wizard_postgres_enrollment_token",
"label": "PostgreSQL 应用接入令牌",
"rules": [
{
"required": true,
"message": "请输入 PostgreSQL 共享服务的应用接入令牌"
},
{
"min": 20,
"message": "应用接入令牌至少需要 20 个字符"
},
{
"max": 256,
"message": "应用接入令牌不能超过 256 个字符"
}
]
},
{
"type": "password",
"field": "wizard_admin_password",
"label": "管理员密码",
"rules": [
{
"required": true,
"message": "请输入管理员密码"
},
{
"min": 10,
"message": "管理员密码至少需要 10 个字符"
},
{
"max": 256,
"message": "管理员密码不能超过 256 个字符"
}
]
},
{
"type": "password",
"field": "wizard_admin_password_confirm",
"label": "再次输入密码",
"rules": [
{
"required": true,
"message": "请再次输入管理员密码"
},
{
"min": 10,
"message": "管理员密码至少需要 10 个字符"
},
{
"max": 256,
"message": "管理员密码不能超过 256 个字符"
}
]
}
]
}
]
+18
View File
@@ -0,0 +1,18 @@
[
{
"stepTitle": "卸载 ImageFind",
"items": [
{
"type": "tips",
"helpText": "默认保留数据库、索引和模型,方便以后重新安装。无论如何选择,都不会删除共享目录或其他资料库中的源视频。"
},
{
"type": "switch",
"field": "wizard_clear_data",
"label": "清除应用内部数据",
"initValue": "false",
"helpText": "清除数据库、索引、缩略图、缓存、已下载模型、凭据和日志。"
}
]
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"stepTitle": "升级 ImageFind",
"items": [
{
"type": "tips",
"helpText": "升级会保留数据库、AI 索引、模型、访问配置和管理员密码。密码请在 ImageFind 个人中心内修改。"
}
]
}
]
+25
View File
@@ -0,0 +1,25 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**", "node_modules/**", "tests/**/*.spec.ts"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}", "*.config.ts"],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
plugins: { "react-hooks": reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off",
"react-hooks/purity": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"no-empty": ["error", { allowEmptyCatch: true }],
},
},
);
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f7f8fa" />
<meta name="referrer" content="no-referrer" />
<script>
(() => {
const saved = localStorage.getItem("imagefind:theme");
const preference = saved === "light" || saved === "dark" ? saved : "system";
const dark = preference === "dark" || (preference === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.themePreference = preference;
document.documentElement.dataset.theme = dark ? "dark" : "light";
document.documentElement.style.colorScheme = dark ? "dark" : "light";
document.querySelector('meta[name="theme-color"]').content = dark ? "#0f1115" : "#f7f8fa";
})();
</script>
<title>ImageFind · 私有媒体空间</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2775
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "imagefind-web",
"private": true,
"version": "0.5.45",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"test:ui": "playwright test",
"test:ui:fnos": "playwright test --config playwright.fnos.config.ts",
"lint": "eslint ."
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"hls.js": "latest",
"lucide-react": "latest",
"react": "latest",
"react-dom": "latest",
"typescript": "latest",
"vite": "latest"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.62.0",
"@types/react": "latest",
"@types/react-dom": "latest",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.8.0",
"typescript-eslint": "^8.65.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "@playwright/test";
const port = Number(process.env.PLAYWRIGHT_PORT || 4173);
export default defineConfig({
testDir: "./tests",
testIgnore: "**/fnos.spec.ts",
outputDir: "../.playwright-results",
// Chromium can take longer to create a fresh page on the low-memory fnOS
// release runner after visual-audit contexts have been reclaimed.
timeout: 90_000,
expect: { timeout: 30_000, toHaveScreenshot: { animations: "disabled", maxDiffPixelRatio: 0.01 } },
use: {
baseURL: `http://127.0.0.1:${port}`,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
colorScheme: "light",
},
webServer: {
command: `npm run dev -- --host 127.0.0.1 --port ${port}`,
url: `http://127.0.0.1:${port}`,
// CI and release acceptance must own the server process so a stale Vite
// instance from a previous batch cannot disappear midway through a run.
reuseExistingServer: !process.env.CI,
},
});
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "@playwright/test";
const baseURL = process.env.IMAGEFIND_FNOS_BASE_URL;
const storageState = process.env.IMAGEFIND_FNOS_STORAGE_STATE;
if (!baseURL || !storageState) {
throw new Error("真机测试需要 IMAGEFIND_FNOS_BASE_URL 和 IMAGEFIND_FNOS_STORAGE_STATE");
}
export default defineConfig({
testDir: "./tests",
testMatch: "**/fnos.spec.ts",
outputDir: "../.playwright-results/fnos",
timeout: 120_000,
expect: { timeout: 30_000 },
use: {
baseURL,
storageState,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
colorScheme: "light",
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
});
+1658
View File
File diff suppressed because one or more lines are too long
+178
View File
@@ -0,0 +1,178 @@
declare global {
interface Window {
__IMAGEFIND_BASE__?: string;
}
}
function normaliseBase(value: string | undefined) {
const base = value?.trim() || "/";
return `/${base.replace(/^\/+|\/+$/g, "")}${base === "/" ? "" : "/"}`;
}
export const appBase = normaliseBase(window.__IMAGEFIND_BASE__);
export function appUrl(value: string | undefined): string {
if (!value) return "";
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value;
const target = `${appBase}${value.replace(/^\/+/, "")}`;
const path = `/${value.replace(/^\/+/, "")}`;
const mediaPath = /^\/api\/v1\/(?:frames\/[^/]+\/thumbnail|faces\/[^/]+\/thumbnail|videos\/[^/]+\/(?:stream|download)|previews\/)/.test(path);
if (appBase === "/" || !gatewayMediaToken || !mediaPath) return target;
return `${target}${target.includes("?") ? "&" : "?"}media_token=${encodeURIComponent(gatewayMediaToken)}`;
}
let csrfToken = sessionStorage.getItem("imagefind:csrf") || "";
let gatewaySessionToken = sessionStorage.getItem("imagefind:gateway-session") || "";
let gatewayMediaToken = sessionStorage.getItem("imagefind:gateway-media-token") || "";
let gatewayRenewal: Promise<void> | null = null;
const inflightReads = new Map<string, Promise<unknown>>();
export function appMediaUrl(value: string | undefined): string {
if (!value) return "";
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value;
const target = appUrl(value);
if (appBase === "/" || !gatewayMediaToken) return target;
const hashAt = target.indexOf("#");
const body = hashAt >= 0 ? target.slice(0, hashAt) : target;
const hash = hashAt >= 0 ? target.slice(hashAt) : "";
if (/(?:^|[?&])media_token=/.test(body)) return target;
const separator = body.includes("?") ? "&" : "?";
return `${body}${separator}media_token=${encodeURIComponent(gatewayMediaToken)}${hash}`;
}
export function setCsrf(token: string) {
csrfToken = token;
if (token) sessionStorage.setItem("imagefind:csrf", token);
else sessionStorage.removeItem("imagefind:csrf");
}
export function setGatewaySession(token: string) {
gatewaySessionToken = token;
if (token) sessionStorage.setItem("imagefind:gateway-session", token);
else sessionStorage.removeItem("imagefind:gateway-session");
}
export function setGatewayMediaToken(token: string) {
gatewayMediaToken = token;
if (token) sessionStorage.setItem("imagefind:gateway-media-token", token);
else sessionStorage.removeItem("imagefind:gateway-media-token");
}
async function renewGatewaySession(): Promise<void> {
if (appBase === "/") throw new Error("需要管理员登录");
if (!gatewayRenewal) {
gatewayRenewal = fetch(appUrl("/api/v1/auth/gateway"), {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
}).then(async response => {
if (!response.ok) throw new Error("飞牛管理员会话需要重新建立");
const value = await response.json() as {
csrf_token: string;
gateway_session_token: string;
gateway_media_token: string;
};
setGatewaySession(value.gateway_session_token);
setGatewayMediaToken(value.gateway_media_token);
setCsrf(value.csrf_token);
window.dispatchEvent(new CustomEvent("imagefind-gateway-renewed"));
}).finally(() => { gatewayRenewal = null; });
}
return gatewayRenewal;
}
async function request(path: string, options: RequestInit = {}): Promise<Response> {
const send = () => {
const headers = new Headers(options.headers);
if (typeof options.body === "string" && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (options.method && !["GET", "HEAD"].includes(options.method.toUpperCase()) && csrfToken) {
headers.set("X-CSRF-Token", csrfToken);
}
if (appBase !== "/" && gatewaySessionToken) headers.set("X-ImageFind-Gateway-Session", gatewaySessionToken);
return fetch(appUrl(`/api/v1${path}`), { ...options, headers, credentials: "same-origin" });
};
let response = await send();
if (response.status === 401 && appBase !== "/" && path !== "/auth/gateway") {
await renewGatewaySession();
response = await send();
}
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = await response.json();
message = typeof payload.detail === "string" ? payload.detail : payload.detail?.message || message;
} catch { /* response is not JSON */ }
throw new Error(message);
}
return response;
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const method = String(options.method || "GET").toUpperCase();
if (method === "GET" && options.body === undefined) {
const existing = inflightReads.get(path);
if (existing) return existing as Promise<T>;
const pending = request(path, options).then(response =>
response.status === 204 ? undefined : response.json()
).finally(() => inflightReads.delete(path));
inflightReads.set(path, pending);
return pending as Promise<T>;
}
const response = await request(path, options);
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export async function downloadBackup(scope: "keys" | "full", password: string) {
const response = await request("/backups/export", {
method: "POST",
body: JSON.stringify({ scope, password }),
});
const disposition = response.headers.get("Content-Disposition") || "";
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
const filename = encoded ? decodeURIComponent(encoded) : plain || `imagefind-backup-${scope}.ifbackup`;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return { filename, size_bytes: blob.size };
}
export async function downloadGeneratedBackup(exportId: string) {
const response = await request(`/backups/${encodeURIComponent(exportId)}/download`);
const disposition = response.headers.get("Content-Disposition") || "";
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
const filename = encoded ? decodeURIComponent(encoded) : plain || `imagefind-backup-${exportId}.ifbackup`;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return { filename, size_bytes: blob.size };
}
export async function restoreBackup<T>(file: File, password: string, confirmed: boolean): Promise<T> {
const form = new FormData();
form.append("file", file);
form.append("password", password);
form.append("confirmed", String(confirmed));
return api<T>("/backups/restore", { method: "POST", body: form });
}
export async function uploadImage(file: File): Promise<{ id: string }> {
const form = new FormData();
form.append("file", file);
return api("/query-images", { method: "POST", body: form });
}
+20
View File
@@ -0,0 +1,20 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { appBase } from "./api";
import "./styles.css";
document.documentElement.dataset.imagefindAccess=appBase==="/"?"direct":"gateway";
function syncVisualViewport(){
const height=Math.round(window.visualViewport?.height||window.innerHeight);
document.documentElement.style.setProperty("--imagefind-visual-height",`${height}px`);
document.documentElement.style.setProperty("--imagefind-sheet-height",`${Math.round(height*.82)}px`);
}
syncVisualViewport();
window.addEventListener("resize",syncVisualViewport,{passive:true});
window.visualViewport?.addEventListener("resize",syncVisualViewport,{passive:true});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode><App /></React.StrictMode>
);
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+120
View File
@@ -0,0 +1,120 @@
import { expect, Page, test } from "@playwright/test";
const sourceName=process.env.IMAGEFIND_E2E_SOURCE_NAME;
async function appApi<T>(page:Page,path:string,options:RequestInit={}):Promise<T>{
return page.evaluate(async({path,options})=>{
const base=(window.__IMAGEFIND_BASE__||"/").replace(/\/$/,"");
const headers=new Headers(options.headers);const csrf=sessionStorage.getItem("imagefind:csrf");
if(csrf)headers.set("X-CSRF-Token",csrf);
if(typeof options.body==="string")headers.set("Content-Type","application/json");
const response=await fetch(`${base}/api/v1${path}`,{...options,headers,credentials:"same-origin"});
if(!response.ok)throw new Error(`${response.status} ${await response.text()}`);
return response.status===204?null:response.json();
},{path,options}) as Promise<T>;
}
test("fnOS deployment renders the app shell and system-history navigation",async({page})=>{
await page.setViewportSize({width:390,height:844});
await page.goto("");
await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible();
const homeLayout=await page.evaluate(()=>{const content=document.querySelector<HTMLElement>(".content")!;const shell=document.querySelector<HTMLElement>(".app-shell")!;return {access:document.documentElement.dataset.imagefindAccess,topbarTop:Math.round(document.querySelector<HTMLElement>(".topbar")!.getBoundingClientRect().top),contentOverflow:getComputedStyle(content).overflowY,shellOverflow:getComputedStyle(shell).overflow,documentHeight:document.documentElement.scrollHeight,viewportHeight:innerHeight}});
expect(["direct","gateway"]).toContain(homeLayout.access);
expect(homeLayout).toMatchObject({topbarTop:0,contentOverflow:"auto",shellOverflow:"hidden"});
expect(homeLayout.documentHeight).toBeLessThanOrEqual(homeLayout.viewportHeight+1);
await expect(page.locator(".sidebar nav > button:visible")).toHaveCount(5);
await page.getByRole("button",{name:"更多"}).click();
await expect(page.locator(".mobile-more-menu").getByText("退出登录",{exact:true})).toHaveCount(homeLayout.access==="gateway"?0:1);
await expect(page.getByText("返回飞牛桌面",{exact:true})).toHaveCount(0);
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"设置"}).click();
await expect(page.getByRole("heading",{name:"设置"})).toBeVisible();
await expect(page.locator(".topbar")).toBeHidden();
await expect(page.locator(".sidebar")).toBeHidden();
await expect(page.locator(".content")).not.toHaveClass(/sheet-locked/);
await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollHeight-element.clientHeight)).toBeGreaterThan(320);
await page.locator(".content").evaluate(element=>element.scrollTo({top:320,behavior:"instant"}));
await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBeGreaterThan(100);
await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
await page.goBack();
await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible();
});
test("live transfer, profile, resources and media surfaces remain usable",async({page})=>{
await page.setViewportSize({width:390,height:844});
await page.goto("");
const status=await appApi<{configured:boolean;version:string;access_mode:string}>(page,"/status");
expect(status).toMatchObject({configured:true,version:"0.5.45"});
await page.getByRole("button",{name:"更多"}).click();
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"上传中心"}).click();
await expect(page.getByRole("heading",{name:"传输中心"})).toBeVisible();
await expect(page.locator(".transfer-tabs>button")).toHaveCount(2);
await page.locator(".transfer-tabs").getByRole("button",{name:/后台下载/}).click();
const aria2=await appApi<{available:boolean;running:boolean}>(page,"/downloads/runtime");
expect(typeof aria2.available).toBe("boolean");
await expect(page.locator(".download-list")).toBeVisible();
await page.goBack();
await page.locator(".mobile-profile-button").click();
await expect(page.getByRole("button",{name:"个人资料"})).toBeVisible();
await expect(page.locator(".profile-stats article")).toHaveCount(4);
await page.getByRole("button",{name:"更多"}).click();
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"设置"}).click();
await page.getByRole("button",{name:"任务与偏好"}).click();
await expect(page.locator(".resource-panel")).toContainText("前台访问优先");
await expect(page.locator(".resource-lanes article")).toHaveCount(4);
const resources=await appApi<{profile:string;policy:{cpu_pause_percent:number;cpu_resume_percent:number}}>(page,"/system/resources");
expect(["conservative","balanced","turbo"]).toContain(resources.profile);
expect(resources.policy.cpu_resume_percent).toBeLessThan(resources.policy.cpu_pause_percent);
await page.goBack();
await page.locator(".nav-item-1").click();
const cards=page.locator(".video-card");
if(await cards.count()){
await cards.first().click();
await expect(page.locator(".player-stage video")).toBeVisible();
await expect(page.getByRole("button",{name:"删除视频"})).toBeVisible();
const source=await page.locator(".player-stage video").getAttribute("src");
expect(source).toContain("/api/v1/videos/");
}
});
for(const width of [320,390])test(`live mobile layout has no horizontal overflow at ${width}px`,async({page})=>{
await page.setViewportSize({width,height:780});
await page.goto("");
const geometry=await page.evaluate(()=>({viewport:innerWidth,document:document.documentElement.scrollWidth,content:document.querySelector<HTMLElement>(".content")!.scrollWidth}));
expect(geometry.document).toBeLessThanOrEqual(geometry.viewport+1);
expect(geometry.content).toBeLessThanOrEqual(geometry.viewport+1);
await expect(page.locator(".sidebar nav > button:visible")).toHaveCount(5);
});
test("fnOS isolated source supports upload and test-owned cleanup",async({page})=>{
test.skip(!sourceName,"设置 IMAGEFIND_E2E_SOURCE_NAME 后才运行写入测试");
await page.setViewportSize({width:390,height:844});
await page.goto("");
const filename=`E2E-${Date.now()}.mp4`;
let sourceId="";
try{
await page.locator(".sidebar nav > .upload-status").click();
const sourceSelect=page.getByLabel("目标媒体库");
const option=sourceSelect.locator("option").filter({hasText:sourceName!});
await expect(option).toHaveCount(1);
sourceId=await option.getAttribute("value")||"";
await sourceSelect.selectOption(sourceId);
await page.locator('.drop-zone input[type="file"]').setInputFiles({name:filename,mimeType:"video/mp4",buffer:Buffer.from("imagefind isolated e2e fixture")});
await page.getByRole("button",{name:"开始上传"}).click();
await expect(page.getByText(/已进入后台传输队列|1 个文件已排队/)).toBeVisible();
await page.waitForTimeout(1500);
const uploads=await appApi<Array<{id:string;filename:string}>>(page,"/uploads?limit=100");
expect(uploads.some(item=>item.filename===filename)).toBe(true);
}finally{
if(sourceId){
const videos=await appApi<Array<{source_id:string;source_key:string;display_name:string}>>(page,"/videos?limit=500").catch(()=>[]);
const owned=videos.filter(item=>item.source_id===sourceId&&(item.display_name===filename||item.source_key.endsWith(`/${filename}`)||item.source_key===filename));
if(owned.length)await appApi(page,"/files/trash",{method:"POST",body:JSON.stringify({source_id:sourceId,keys:owned.map(item=>item.source_key)})}).catch(()=>null);
const trash=await appApi<Array<{id:string;display_name:string}>>(page,`/trash?source_id=${encodeURIComponent(sourceId)}`).catch(()=>[]);
for(const item of trash.filter(value=>value.display_name===filename))await appApi(page,`/trash/${item.id}`,{method:"DELETE"}).catch(()=>null);
}
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
{
"viewport": {
"width": 1440,
"height": 900
},
"document": {
"width": 1440,
"height": 1464
},
"topbar": {
"x": 0,
"y": 0,
"width": 1440,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 1216,
"height": 1464
},
"page": {
"x": 224,
"y": 64,
"width": 1216,
"height": 1400
},
"videoGrid": {
"x": 256,
"y": 259,
"width": 1152,
"height": 220
},
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -0,0 +1,42 @@
{
"viewport": {
"width": 1440,
"height": 900
},
"document": {
"width": 1440,
"height": 1094
},
"topbar": {
"x": 0,
"y": 0,
"width": 1440,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 1216,
"height": 1094
},
"page": {
"x": 224,
"y": 64,
"width": 1216,
"height": 1030
},
"videoGrid": null,
"tagGroups": {
"x": 256,
"y": 198,
"width": 1152,
"height": 236
},
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 394
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,42 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 390,
"height": 58
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 58,
"width": 390,
"height": 1210
},
"videoGrid": {
"x": 12,
"y": 268,
"width": 378,
"height": 175
},
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 298
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,47 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 390,
"height": 58
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 58,
"width": 390,
"height": 1212
},
"videoGrid": {
"x": 12,
"y": 270,
"width": 378,
"height": 175
},
"tagGroups": null,
"mobileMore": {
"x": 0,
"y": 508,
"width": 390,
"height": 336
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Some files were not shown because too many files have changed in this diff Show More