feat: add ImageFind application and release pipelines
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32
@@ -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.5–10px 关键文字与低对比 `--subtle` token 对低视力用户不友好。
|
||||
- 平板导航只显示图标,人物/演员图片存在替代文本缺口。
|
||||
|
||||
### Casey(分心的单手手机用户)
|
||||
|
||||
- 中心上传和 44px 控件表现好,但进入二级页后熟悉的底栏消失。
|
||||
- 搜索模式、设置标签、播放器动作和合集条目依赖无提示横滑。
|
||||
- “新建合集”等主操作位于顶部,远离拇指热区。
|
||||
- 上传页固定“继续上传”比处理失败或打开刚上传内容更抢眼。
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- “上传中心”“传输中心”“上传”三个概念需要统一命名边界。
|
||||
- 黑色选中 chip 与全局蓝色品牌状态冲突。
|
||||
- `AI SEARCH`、`SYSTEM`、`ADMIN PROFILE` 等英文 eyebrow 使中文界面显得模板化。
|
||||
- 未知时长显示 `0:00` 容易被理解为真正的零长度视频。
|
||||
- “暂无封面”应区分缺失、排队、失败和隐私遮罩。
|
||||
- 401 控制台信息在五个尺寸都出现,但发生于登录前认证探测,当前没有用户可见影响。
|
||||
- 手机搜索容器比客户区宽 1px,但 document/body 未溢出,属于非阻塞几何误差。
|
||||
- 320px 时间点面板的报告值是时序误判;截图明确显示面板已经打开。
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
- ImageFind 的第一承诺究竟是“管理媒体文件”,还是“找到私有媒体中的任意时刻”?首页为什么更像前者?
|
||||
- 如果隐私是核心差异,为什么移动端最需要的封面遮罩快捷操作反而不可见?
|
||||
- 一次大文件上传完成后,用户最自然的下一步是播放、整理到合集,还是继续上传?
|
||||
- 移动端二级页是否真的需要沉浸到移除所有导航?
|
||||
- 如果原始文件名和缺省封面是边缘状态,首页的信息结构应怎样改变?
|
||||
+50
@@ -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
@@ -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
@@ -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% 缩放以及不依赖颜色单独表达状态。
|
||||
@@ -0,0 +1,284 @@
|
||||
# ImageFind
|
||||
|
||||
ImageFind 是面向 fnOS/NAS 的私有 AI 媒体库。它提供类似视频网站的浏览、在线播放、上传、
|
||||
下载和资料管理体验,可索引本地目录、通用 WebDAV、AList 直连库与 AList 加密库,并通过
|
||||
中英文关键词、番号/演员/标签、OCR/字幕/音频语音、查询图片和已命名人物定位到视频中的具体时间片段。
|
||||
|
||||
> 当前版本为可侧载验证的 `0.5.45`。应用声明 fnOS Python 3.12 和 PostgreSQL 依赖,核心运行环境离线安装,
|
||||
> PyTorch、OpenVINO 和各类 AI 依赖在安装对应模型时按需下载;AI 模型权重可在“设置”中
|
||||
> 按组件下载、通过离线脚本准备或上传完整模型包。
|
||||
> 上传、移动和回收站操作只会对明确启用写入的资料库生效。
|
||||
|
||||
## 本地开发
|
||||
|
||||
要求 Python 3.11–3.13、Node.js 20+、FFmpeg/FFprobe,以及启用 `vector`、`pg_trgm` 扩展的
|
||||
PostgreSQL 17。业务数据、全文索引和语义向量均由 PostgreSQL 保存,不再使用 SQLite 或 Qdrant
|
||||
作为运行数据库。启动前通过 `IMAGEFIND_POSTGRES_CONF` 指向 PostgreSQL 客户端配置文件。
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e '.[dev]'
|
||||
npm install --prefix frontend
|
||||
npm run build --prefix frontend
|
||||
IMAGEFIND_DATA_DIR="$PWD/.data" imagefind
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:8765`。本地开发时首次访问需创建不少于 10 个字符的管理员密码;
|
||||
fnOS 安装包会在安装向导中完成这一步。
|
||||
|
||||
UI 回归使用 Playwright。Linux 截图机必须安装 `fonts-noto-cjk`,缺少中文字体时测试会直接失败,
|
||||
避免把方框字保存为视觉基线:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
飞牛真机测试使用独立配置,不在仓库保存 NAS 密码或浏览器会话。先把已登录管理员浏览器的
|
||||
Playwright `storageState` 保存到仓库外,并准备一个只放测试文件的可写资料库:
|
||||
|
||||
```bash
|
||||
IMAGEFIND_FNOS_BASE_URL="https://NAS地址/app/imagefind/" \
|
||||
IMAGEFIND_FNOS_STORAGE_STATE="/安全目录/fnos-storage-state.json" \
|
||||
IMAGEFIND_E2E_SOURCE_NAME="ImageFind E2E" \
|
||||
npm run test:ui:fnos
|
||||
```
|
||||
|
||||
写入测试只清理本次创建且以 `E2E-` 开头的文件,不会操作其他媒体。
|
||||
|
||||
常用配置:
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `IMAGEFIND_DATA_DIR` | `./data` | 数据库、缩略图、模型和缓存目录 |
|
||||
| `IMAGEFIND_HOST` / `IMAGEFIND_PORT` | `127.0.0.1` / `8765` | 监听地址与端口 |
|
||||
| `IMAGEFIND_POSTGRES_CONF` | 数据目录上级的 `postgres-client.conf` | PostgreSQL 客户端配置文件 |
|
||||
| `IMAGEFIND_DB_POOL_MIN` / `IMAGEFIND_DB_POOL_MAX` | `1` / `8` | PostgreSQL 进程内连接池上下限 |
|
||||
| `IMAGEFIND_DB_POOL_TIMEOUT` | `5` | 等待可用数据库连接的最长秒数 |
|
||||
| `IMAGEFIND_EMBEDDING_BACKEND` | `auto` | `auto`、`openvino`、`torch` 或测试用 `hash` |
|
||||
| `IMAGEFIND_MODEL_BUNDLE_URL` | 空 | 可选:用自建模型包代替默认官方仓库 |
|
||||
| `IMAGEFIND_MODEL_BUNDLE_SHA256` | 空 | 自建模型包的 SHA-256 |
|
||||
| `IMAGEFIND_MODEL_HF_ENDPOINT` | `https://huggingface.co` | Hugging Face 地址,可改为可信镜像 |
|
||||
| `IMAGEFIND_PIP_INDEX_URL` | `https://pypi.tuna.tsinghua.edu.cn/simple` | AI 运行依赖使用的 PyPI 主镜像 |
|
||||
| `IMAGEFIND_PYTORCH_INDEX_URL` | `https://download.pytorch.org/whl/cpu` | PyTorch CPU 专用轮子源,可在设置页留空或修改 |
|
||||
| `IMAGEFIND_MODEL_UPLOAD_GB` | `10` | 手动上传模型包上限(GiB) |
|
||||
| `IMAGEFIND_FFMPEG_PATH` | `ffmpeg` | FFmpeg 可执行文件 |
|
||||
| `IMAGEFIND_REMOTE_MAX_CONNECTIONS` | `3` | WebDAV 扫描、索引和播放的全局并发上限 |
|
||||
| `IMAGEFIND_PREVIEW_CACHE_GB` | `5` | HLS 兼容预览缓存上限(GiB) |
|
||||
| `IMAGEFIND_UPLOAD_CHUNK_MB` | `16` | 浏览器上传分块大小(MiB) |
|
||||
| `IMAGEFIND_UPLOAD_STAGING_GB` | `100` | 未完成上传的暂存配额(GiB) |
|
||||
| `IMAGEFIND_UPLOAD_RESERVE_GB` | `5` | 创建上传任务后仍需保留的磁盘空间(GiB) |
|
||||
| `IMAGEFIND_TRASH_RETENTION_DAYS` | `30` | 回收站记录的默认保留天数 |
|
||||
|
||||
## 远程媒体库与加密
|
||||
|
||||
通用 WebDAV 和 AList 均在“资料库 → 添加资料库”中配置。扫描器使用递归 `Depth: 1
|
||||
PROPFIND`,以 ETag、大小和修改时间识别变更。服务端不支持 Range 时,索引或播放可能退化为
|
||||
完整文件传输,建议先在小目录验证带宽占用。
|
||||
|
||||
AList 有两种互斥模式:
|
||||
|
||||
- **AList 直连**:文件和文件名以明文保存在远端。播放接口向浏览器返回 AList 提供的 302
|
||||
临时直链,视频流量通常不经过 NAS;直链解析失败时自动回退到认证代理。
|
||||
- **AList 加密**:使用 `rclone crypt` 加密文件内容、文件名和目录名。应用优先使用 fnOS/系统
|
||||
已安装的兼容 rclone,缺失时再下载经过版本与 SHA-256 固定的私有副本。上传、
|
||||
播放、下载、抽帧和 AI 索引均由 NAS 即时解密,因此不能再使用 302 直链,带宽会经过 NAS。
|
||||
|
||||
ImageFind 每 60 秒安全核验上次异常退出遗留的 rclone 实例。只有进程身份和应用目录匹配、连续
|
||||
3 次没有活动连接或 I/O 且观察满 2 分钟时才会自动回收;身份不一致的进程只标记为“待处理”,
|
||||
不会强制终止。也可以在“设置 → 任务与偏好 → 资源保护”中执行一次立即安全核验。
|
||||
|
||||
创建加密库时浏览器会自动下载一次 `imagefind-vault-recovery-*.json`。必须把它存放在密码管理器
|
||||
或离线介质中;它含有解密所需密钥,但不包含 AList 登录密码。重装后选择“AList 加密 → 从恢复
|
||||
文件导入”,再输入当前 AList 密码,即可重新挂载原有密文。丢失恢复文件和应用内部数据后,
|
||||
远端密文无法恢复。
|
||||
|
||||
AI 对加密库仍完整可用:NAS 读取并解密所需片段后执行抽帧、OCR、人物与向量分析,远端不会
|
||||
得到明文索引。代价是首次扫描和播放都需要 NAS 与网盘之间的传输。
|
||||
|
||||
也可以先在 fnOS 中挂载 WebDAV,再作为本地目录添加。这种模式通常拥有更好的断线重连和
|
||||
系统级缓存能力。
|
||||
|
||||
## 上传、下载与文件管理
|
||||
|
||||
浏览器上传采用 16 MiB 分块,每块带 SHA-256 校验,单文件 API 上限为 200 GiB,前端按当前
|
||||
产品约束提示 100 GiB。文件会先完整暂存在应用私有数据目录,再由独立后台通道传输到目标库;
|
||||
上传过程中断后,服务端会保留已接收分块用于续传;上传中心分别显示“上传至 ImageFind”、
|
||||
“转存 WebDAV/AList”和“校验加入媒体库”三个阶段,失败的目标传输可直接重试。创建任务
|
||||
时若无法满足暂存配额或 5 GiB 安全余量,接口会拒绝任务而不是写满系统盘。
|
||||
|
||||
开启飞牛“直接 Web/API 访问”后,可在“设置 → 账户与 API”启用 WebDAV 后台上传。标准
|
||||
WebDAV 客户端必须连接 `http://NAS_IP:8765/webdav/`(端口以应用配置为准),用户名固定为
|
||||
`imagefind`,密码使用页面创建且只显示一次的专用 REST API Token。飞牛 5666 统一网页网关
|
||||
不转发标准 DAV 认证和方法,不能作为 WebDAV 服务器地址。上传路径的第一层目录会自动映射为
|
||||
同名合集,后续目录映射为可任意嵌套的章节或小节;已入库视频可浏览和读取。服务端兼容普通
|
||||
`PUT`、临时文件加 `MOVE`、`Content-Range` 与 `Upload-Offset` 续传。完整上传会在接收时增量
|
||||
校验且不再二次扫描暂存文件;客户端因响应丢失重传相同路径和内容时会幂等返回,不会再生成
|
||||
`(2)`、`(3)` 副本。文件接收完成到媒体入库之间,`HEAD`/`PROPFIND` 仍会返回已接收状态,
|
||||
避免客户端因短暂 404 重新上传。
|
||||
|
||||
本地目录默认只读,必须在资料库卡片上主动启用写入;AList 资料库默认可写。文件面板支持浏览、
|
||||
新建目录、移动/重命名和移入回收站。回收站内可恢复或永久删除;卸载应用不会删除共享目录、
|
||||
已授权目录或远端库中的源视频。播放接口支持 HTTP Range;AList 直连下载/播放可使用 302,
|
||||
其余来源经 ImageFind 认证代理读取,下载会保留原文件名。
|
||||
|
||||
后台任务按 AI 识别、上传转存、后台下载和来源扫描分为四条独立通道,各自保持单并发。这样
|
||||
大文件上云不会阻塞索引,同时避免低功耗 NAS 因多个 AI 或磁盘任务并发而耗尽内存。
|
||||
|
||||
## 自定义分类与 AI 标签
|
||||
|
||||
“分类与标签”支持创建任意分类组,例如类型、场景、服装、片商或自定义收藏维度。每个组可设置为
|
||||
单选或多选;影片资料编辑和批量赋值都会遵守该约束。标签可以移动、合并和删除,旧版字符串标签
|
||||
在升级时会自动迁移到“未分组”。
|
||||
|
||||
每个标签都可以独立启用一种 AI 建议方式:
|
||||
|
||||
- **画面语义**:使用已生成的 CLIP 画面向量与自定义描述做零样本匹配,适合能从画面判断的场景、
|
||||
服装和视觉类型。
|
||||
- **文本规则**:匹配文件名、路径、影片资料、OCR 和文本字幕,适合番号、片商、系列或明确关键词。
|
||||
|
||||
AI 结果只会进入“待确认建议”,不会直接覆盖人工标签。索引新视频完成后会自动排队分析已启用
|
||||
AI 的标签,也可从影片资料或分类页手动重跑。服务启动、状态检查和仅浏览页面都不会加载模型;
|
||||
模型仍然只在实际索引、图片/语义检索或手动分析需要时延迟初始化。自定义标签的准确度取决于描述、
|
||||
阈值和素材,不能可靠由画面判断的抽象标签应使用文本规则或保持手动。
|
||||
|
||||
## 系统备份与恢复
|
||||
|
||||
“设置 → 系统备份与恢复”可下载单一 `.ifbackup` 加密文件。每个备份都包含本地、WebDAV 与
|
||||
AList 来源配置、登录凭据和 rclone crypt 密钥;完整范围还包含影片身份、手工资料、分类与标签、
|
||||
演员、收藏、喜欢、播放进度、偏好和模型/运行依赖镜像配置。备份密码独立于管理员密码,长度为 10–256 个
|
||||
字符,不会持久化;忘记密码后无法解密。
|
||||
|
||||
恢复前需在新系统创建管理员并登录。只有没有来源、影片、AI 人物、演员或用户标签的空系统可以
|
||||
恢复。当前管理员、会话和 API Token 不会被导入或覆盖;媒体文件、模型、缩略图、OCR、人物聚类、
|
||||
向量与其他可重建索引也不进入备份。恢复后影片先处于不可播放的待扫描状态,启用的来源会自动排队
|
||||
扫描,并按来源 ID 与源文件键重新关联原影片 ID。
|
||||
|
||||
## fnOS 视频目录
|
||||
|
||||
安装或升级后,fnOS 会创建共享目录 `imagefind/videos`,应用运行用户会自动获得访问权限。
|
||||
可以把待索引视频放入该目录,再在 ImageFind 中将它添加为本地资料库。
|
||||
|
||||
如果视频已经位于其他 NAS 目录,无需搬动文件:在 fnOS 的 ImageFind 应用设置中授权这些
|
||||
目录,再在 ImageFind 中添加对应路径。`imagefind/videos` 是便于直接导入文件的应用共享目录,
|
||||
授权目录可用于只读索引已有媒体库;只有在 ImageFind 内明确启用写入后,文件管理和上传操作
|
||||
才允许修改该目录。AI 索引过程本身不会修改源视频。
|
||||
|
||||
fnOS 全新安装时必须输入两次 ImageFind 管理员密码。升级向导中的密码可以留空以保留
|
||||
现有密码,也可以填写两次来初始化或重置密码。卸载向导默认保留应用数据;勾选清除后会
|
||||
删除数据库、索引、缓存、模型、凭据和日志,但不会删除 `imagefind/videos`、用户授权目录
|
||||
或 WebDAV 上的任何源视频。
|
||||
|
||||
## 播放与 HLS 兼容预览
|
||||
|
||||
搜索结果优先通过认证后的 Range 接口直接播放,并跳转到命中时间。浏览器不支持源视频的
|
||||
封装格式或编码时,前端会自动请求命中点附近的 HLS 兼容预览。预览默认最长 180 秒,优先
|
||||
使用 Intel VAAPI,随后尝试 OpenH264 或 libx264;缓存达到上限后按最久未使用顺序清理。
|
||||
|
||||
生成兼容预览会消耗额外 CPU/GPU 和临时空间。远程源还会读取对应时间段;WebDAV 服务端若
|
||||
忽略 Range,可能退化为完整文件传输。
|
||||
|
||||
## 模型包约定
|
||||
|
||||
fnOS 安装包包含 Web 服务与 PostgreSQL 客户端运行依赖,数据库和 pgvector 由声明的
|
||||
`nxsir.postgresql` fnOS 依赖应用提供;OpenVINO、PyTorch 等**推理运行时**
|
||||
与体积更大的**模型权重**不随包发布,而是在首次安装对应 AI 组件时按需下载。设置页的在线安装
|
||||
会从 Hugging Face 下载画面/多语言模型及 Whisper small,从 Open Model Zoo 下载人物模型。Whisper
|
||||
会导出为 OpenVINO FP16;安装后以单任务串行方式自动补齐旧影片的带时间轴语音索引。应用启动
|
||||
和状态检查都不会加载模型到内存,只有搜索或索引实际需要时才延迟初始化。
|
||||
|
||||
语音识别默认采用“中文优先 + 准确率优先”:先读取音轨语言,再从最多 3 个高语音占比片段检测,
|
||||
无法判断时使用中文,并以 5-beam 解码。每个时间片段会经过字符损坏、异常文字脚本、重复输出、
|
||||
无语音幻觉和文本密度校验,低质量片段不会进入搜索。设置页可切换智能检测、固定中文和三档质量,
|
||||
也可扫描存量旧转写;明显低质量的视频会自动排队重新识别。
|
||||
|
||||
“设置 → AI 与网络”可分别配置 Hugging Face、PyPI 和 PyTorch CPU 下载源。PyPI 默认使用已直连
|
||||
验证的清华镜像;HTTP/HTTPS 代理有独立开关,关闭后保留加密凭据但所有 AI 下载直接连接,启用后
|
||||
才用于模型与运行依赖下载。pip 安装期间每隔约 5 秒更新阶段和等待时间,任务重启排队时会重置为
|
||||
0%,避免把等待中的组件误显示为卡住。
|
||||
|
||||
无法直接访问官方仓库时,可以在其他电脑准备下述 ImageFind 模型包,再通过设置页“上传模型包”
|
||||
手动安装。服务端以流式方式写入应用数据目录,不会先占用 fnOS 的 `/tmp`;安装前会检查压缩包
|
||||
路径、解压体积和必需目录,并以原子方式替换现有模型。
|
||||
|
||||
模型包是 `.tar.gz`,根目录必须包含 `manifest.json` 以及:
|
||||
|
||||
```text
|
||||
visual/image/ # clip-ViT-B-32 SentenceTransformer 模型
|
||||
visual/text/ # clip-ViT-B-32-multilingual-v1 文本模型
|
||||
ocr/ # 可选:det.onnx、rec.onnx、cls.onnx
|
||||
faces/ # 可选:detector.xml/.bin、reidentification.xml/.bin
|
||||
audio/ # 可选:Whisper small OpenVINO FP16 模型和处理器配置
|
||||
```
|
||||
|
||||
视觉模型变化后需要重建画面向量;应用不会静默切换向量空间。
|
||||
|
||||
生成模型包不会修改源模型目录:
|
||||
|
||||
```bash
|
||||
python3 scripts/build-model-bundle.py /path/to/models dist/imagefind-models.tar.gz --version 1
|
||||
```
|
||||
|
||||
## 构建 fnOS 安装包
|
||||
|
||||
发布构建只面向 x86_64 fnOS,需要官方 `fnpack`、Node.js、带 Hatchling/Pillow 的构建环境,
|
||||
以及 `.fnos-wheel-cache/python312` 中完整的 Python 3.12 核心 wheelhouse。fnOS 端声明依赖
|
||||
`python312`;AI 推理依赖不会进入 FPK,而是在安装对应模型时按锁文件下载。发布包只内置:
|
||||
|
||||
```text
|
||||
vendor/libOpenCL.so.1
|
||||
```
|
||||
|
||||
安装依赖并构建:
|
||||
|
||||
```bash
|
||||
python3 -m venv .release-venv
|
||||
.release-venv/bin/pip install -e '.[dev]'
|
||||
./scripts/build-fnos.sh
|
||||
```
|
||||
|
||||
默认输出为 `dist/imagefind-0.5.45-x86_64.fpk`。FFmpeg/FFprobe 与 rclone 在运行时优先使用
|
||||
兼容的系统版本,缺失时按固定版本和 SHA-256 动态下载。0.5.45 不读取或迁移旧 SQLite 数据库;
|
||||
安装时直接初始化 PostgreSQL schema,升级脚本不会擅自删除用户数据或旧 runtime 目录。
|
||||
当前仓库中的 manifest、权限和资源声明是
|
||||
侧载模板;正式发布前必须用目标 fnOS 版本配套的官方 `fnpack` 校验,并在真实 N100 设备上
|
||||
验证安装/升级向导、启停、GPU 权限、端口跳转和两种卸载数据行为。
|
||||
|
||||
解出安装包内的 `app.tgz` 后,可以在 x86_64 Linux 构建机上运行制品级冒烟:
|
||||
|
||||
```bash
|
||||
./scripts/smoke-fnos-package.sh /path/to/extracted/app /tmp/imagefind-smoke 18765
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
登录后在“设置”创建独立 API Token。请求使用 `Authorization: Bearer <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 图片字幕。
|
||||
@@ -0,0 +1,3 @@
|
||||
"""ImageFind application package."""
|
||||
|
||||
__version__ = "0.5.45"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
'''
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+34
@@ -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 配置失败:端口必须在 1–65535 之间。"
|
||||
|
||||
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
|
||||
Executable
+64
@@ -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
|
||||
Executable
+5
@@ -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
@@ -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
|
||||
Executable
+126
@@ -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 核心运行环境切换失败:期望版本 $VERSION,current=$CURRENT_LINK,实际版本=$ACTUAL_VERSION。"
|
||||
fi
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
Executable
+38
@@ -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 数据清理失败:部分内部数据无法删除。"
|
||||
Executable
+44
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"defaults": {
|
||||
"run-as": "package"
|
||||
},
|
||||
"join-groups": ["video", "render"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"data-share": {
|
||||
"shares": [
|
||||
{
|
||||
"name": "imagefind/videos"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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": "请输入 1–65535 之间的端口"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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 个字符"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "卸载 ImageFind",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "默认保留数据库、索引和模型,方便以后重新安装。无论如何选择,都不会删除共享目录或其他资料库中的源视频。"
|
||||
},
|
||||
{
|
||||
"type": "switch",
|
||||
"field": "wizard_clear_data",
|
||||
"label": "清除应用内部数据",
|
||||
"initValue": "false",
|
||||
"helpText": "清除数据库、索引、缩略图、缓存、已下载模型、凭据和日志。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "升级 ImageFind",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "升级会保留数据库、AI 索引、模型、访问配置和管理员密码。密码请在 ImageFind 个人中心内修改。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
Generated
+2775
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -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 });
|
||||
}
|
||||
@@ -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
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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
Reference in New Issue
Block a user