diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 5631644ee..6c4c04f16 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -78,18 +78,19 @@ jobs: org.opencontainers.image.description=Self-hosted cloud storage system built with Rust org.opencontainers.image.vendor=AsterCommunity - # Build full first so the slim target reuses the exact same feature/architecture binary. - - name: Build and push full Docker image - id: build-push-full + # Push slim first and full last so registry UIs do not treat the most + # recently pushed slim artifact as the default image. + - name: Build and push slim Docker image + id: build-push-slim uses: docker/build-push-action@v7 with: context: . - target: runtime-full + target: runtime-slim platforms: ${{ matrix.platform }} push: true tags: | - ${{ env.REGISTRY_IMAGE_GHCR }}:${{ env.TEMP_IMAGE_TAG }}-${{ matrix.variant }}-${{ matrix.arch }} - ${{ env.REGISTRY_IMAGE_DOCKERHUB }}:${{ env.TEMP_IMAGE_TAG }}-${{ matrix.variant }}-${{ matrix.arch }} + ${{ env.REGISTRY_IMAGE_GHCR }}:${{ env.TEMP_IMAGE_TAG }}-${{ matrix.variant }}-slim-${{ matrix.arch }} + ${{ env.REGISTRY_IMAGE_DOCKERHUB }}:${{ env.TEMP_IMAGE_TAG }}-${{ matrix.variant }}-slim-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} cache-from: | type=gha,scope=docker-image-${{ matrix.variant }}-${{ matrix.arch }} @@ -103,17 +104,17 @@ jobs: provenance: true sbom: true - - name: Build and push slim Docker image - id: build-push-slim + - name: Build and push full Docker image + id: build-push-full uses: docker/build-push-action@v7 with: context: . - target: runtime-slim + target: runtime-full platforms: ${{ matrix.platform }} push: true tags: | - ${{ env.REGISTRY_IMAGE_GHCR }}:${{ env.TEMP_IMAGE_TAG }}-${{ matrix.variant }}-slim-${{ matrix.arch }} - ${{ env.REGISTRY_IMAGE_DOCKERHUB }}:${{ env.TEMP_IMAGE_TAG }}-${{ matrix.variant }}-slim-${{ matrix.arch }} + ${{ env.REGISTRY_IMAGE_GHCR }}:${{ env.TEMP_IMAGE_TAG }}-${{ matrix.variant }}-${{ matrix.arch }} + ${{ env.REGISTRY_IMAGE_DOCKERHUB }}:${{ env.TEMP_IMAGE_TAG }}-${{ matrix.variant }}-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} cache-from: | type=gha,scope=docker-image-${{ matrix.variant }}-${{ matrix.arch }} @@ -124,7 +125,7 @@ jobs: provenance: true sbom: true - publish-manifest: + publish-manifest-slim: name: Publish ${{ matrix.variant }} multi-arch manifest runs-on: ubuntu-latest needs: build @@ -133,12 +134,8 @@ jobs: fail-fast: false matrix: include: - - variant: default - suffix: "" - variant: default-slim suffix: "-slim" - - variant: metrics - suffix: "-metrics" - variant: metrics-slim suffix: "-metrics-slim" @@ -247,3 +244,123 @@ jobs: run: | cosign sign --yes "${REGISTRY_IMAGE_GHCR}@${GHCR_DIGEST}" cosign sign --yes "${REGISTRY_IMAGE_DOCKERHUB}@${DOCKERHUB_DIGEST}" + + publish-manifest-full: + name: Publish ${{ matrix.variant }} multi-arch manifest + runs-on: ubuntu-latest + needs: publish-manifest-slim + if: github.event_name != 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - variant: default + suffix: "" + - variant: metrics + suffix: "-metrics" + + steps: + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Extract GHCR metadata + id: meta-ghcr + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY_IMAGE_GHCR }} + tags: | + type=ref,event=tag,suffix=${{ matrix.suffix }} + type=raw,value=latest${{ matrix.suffix }},enable=${{ !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') && !contains(github.ref_name, 'rc') }} + type=raw,value=stable${{ matrix.suffix }},enable=${{ !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') && !contains(github.ref_name, 'rc') }} + type=raw,value=edge${{ matrix.suffix }},enable=${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} + + - name: Extract Docker Hub metadata + id: meta-dockerhub + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY_IMAGE_DOCKERHUB }} + tags: | + type=ref,event=tag,suffix=${{ matrix.suffix }} + type=raw,value=latest${{ matrix.suffix }},enable=${{ !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') && !contains(github.ref_name, 'rc') }} + type=raw,value=stable${{ matrix.suffix }},enable=${{ !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') && !contains(github.ref_name, 'rc') }} + type=raw,value=edge${{ matrix.suffix }},enable=${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} + + - name: Create multi-arch manifests + id: manifests + env: + GHCR_TAGS: ${{ steps.meta-ghcr.outputs.tags }} + DOCKERHUB_TAGS: ${{ steps.meta-dockerhub.outputs.tags }} + MATRIX_VARIANT: ${{ matrix.variant }} + run: | + create_manifest() { + local tags="$1" + local image="$2" + local metadata_file="$3" + local first_tag="" + local tag + local -a args=() + + while IFS= read -r tag; do + if [ -n "$tag" ]; then + args+=("-t" "$tag") + if [ -z "$first_tag" ]; then + first_tag="$tag" + fi + fi + done <<< "$tags" + + if [ "${#args[@]}" -eq 0 ]; then + echo "No manifest tags generated for ${image}" >&2 + exit 1 + fi + + docker buildx imagetools create "${args[@]}" \ + --metadata-file "$metadata_file" \ + "${image}:${TEMP_IMAGE_TAG}-${MATRIX_VARIANT}-amd64" \ + "${image}:${TEMP_IMAGE_TAG}-${MATRIX_VARIANT}-arm64" >&2 + + printf '%s\n' "$first_tag" + } + + create_manifest "$GHCR_TAGS" "$REGISTRY_IMAGE_GHCR" /tmp/ghcr-manifest-metadata.json + create_manifest "$DOCKERHUB_TAGS" "$REGISTRY_IMAGE_DOCKERHUB" /tmp/dockerhub-manifest-metadata.json + + GHCR_DIGEST="$(jq -r '."containerimage.descriptor".digest' /tmp/ghcr-manifest-metadata.json)" + DOCKERHUB_DIGEST="$(jq -r '."containerimage.descriptor".digest' /tmp/dockerhub-manifest-metadata.json)" + + if [ -z "$GHCR_DIGEST" ] || [ "$GHCR_DIGEST" = "null" ]; then + echo "Failed to read GHCR manifest digest" >&2 + exit 1 + fi + + if [ -z "$DOCKERHUB_DIGEST" ] || [ "$DOCKERHUB_DIGEST" = "null" ]; then + echo "Failed to read Docker Hub manifest digest" >&2 + exit 1 + fi + + echo "ghcr_digest=${GHCR_DIGEST}" >> "$GITHUB_OUTPUT" + echo "dockerhub_digest=${DOCKERHUB_DIGEST}" >> "$GITHUB_OUTPUT" + + - name: Sign Docker images + env: + GHCR_DIGEST: ${{ steps.manifests.outputs.ghcr_digest }} + DOCKERHUB_DIGEST: ${{ steps.manifests.outputs.dockerhub_digest }} + run: | + cosign sign --yes "${REGISTRY_IMAGE_GHCR}@${GHCR_DIGEST}" + cosign sign --yes "${REGISTRY_IMAGE_DOCKERHUB}@${DOCKERHUB_DIGEST}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 807e3b473..57aa9d673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **内置登录方式控制** — 新增可热更新的密码登录开关,并继续与 Passkey 开关独立组合;关闭密码登录会同时关闭公开注册、激活重发、密码邀请接受、密码重置和外部身份密码绑定,未完成的密码第一因子 MFA flow 会在完成时重新检查策略,外部认证和 Passkey 登录不再被遗留的强制改密标记阻塞。后端仅在存在已启用外部认证 provider 时允许同时关闭密码与 Passkey,并阻止禁用或删除最后一个外部 provider,避免保存后失去全部登录入口。 - **远端节点连接生命周期审计** — reverse tunnel 连接、正常下线、异常断线和心跳超时现在会按 remote node / binding 聚合写入系统 audit;四条 streaming lane 的同时变化只产生一次节点级状态转换,并记录连接次数、中断次数、lane 数量、transport 和稳定 reason code,不包含 access key、secret、signature、URL 凭据或 token。 +### Fixed + +- **Slim 镜像媒体处理能力与派生缓存** — full 与 slim 镜像切换时保留已有媒体处理配置,管理端分别展示已配置、运行时可用和有效启用状态;公开缩略图能力只声明当前可生成的格式,并与媒体元数据能力独立。已有缩略图和图片预览缓存继续可读,缺少 `vips`、`ffmpeg` 或 `ffprobe` 时仅阻止新的相关派生并返回结构化处理器不可用错误;Docker 发布流程也保证所有 slim 变体先于 full 变体推送。 + ## [v0.5.0] - 2026-08-20 ### Changed diff --git a/developer-docs/en/api/admin.md b/developer-docs/en/api/admin.md index 5ccd085f8..387a54ee4 100644 --- a/developer-docs/en/api/admin.md +++ b/developer-docs/en/api/admin.md @@ -571,6 +571,7 @@ Admin team creation can create a team for another user and give that user the in | `GET` | `/admin/config` | List runtime config entries | | `GET` | `/admin/config/schema` | Read system config schema | | `GET` | `/admin/config/template-variables` | Read template variable catalog | +| `GET` | `/admin/config/media-processing-status` | Read configured, runtime-available, and effective media processor status | | `GET` | `/admin/config/{key}` | Read one runtime config entry | | `PUT` | `/admin/config/{key}` | Set runtime config entry | | `DELETE` | `/admin/config/{key}` | Delete custom runtime config entry | @@ -610,6 +611,8 @@ The field only applies to `source = "custom"` entries. Built-in system configura `GET /admin/config` now includes `visibility` in addition to `id`, `key`, `value`, `source`, `namespace`, `updated_at`, and `updated_by`. Sensitive values are still redacted as `***REDACTED***`. +`GET /admin/config/media-processing-status` does not modify persisted configuration. It probes the current instance and returns `configured_enabled`, `runtime_available`, `effective_enabled`, and an optional `unavailable_reason` for each media processor. `command_not_found` never includes the configured local command path, so administrators can explain a full/slim image mismatch without exposing host details. + The frontend custom-configuration read path is documented in [Public API](./public.md) under `GET /public/custom-config`. That endpoint only returns the key/value map visible to the current request identity and does not expose admin-only fields. Admin task APIs can see system tasks and blob-level cache tasks that ordinary users normally cannot see. diff --git a/developer-docs/en/api/public.md b/developer-docs/en/api/public.md index 5d9ca7911..e69770938 100644 --- a/developer-docs/en/api/public.md +++ b/developer-docs/en/api/public.md @@ -150,8 +150,7 @@ Returns the server's public thumbnail-generation support: }, "video_thumbnail": { "enabled": false - }, - "extensions": ["bmp", "gif", "jpe", "jpeg", "jpg", "png", "tif", "tiff", "webp"] + } } } ``` @@ -160,10 +159,10 @@ Notes: - extensions are normalized to lowercase without leading dots - `image_preview`, `image_thumbnail`, `audio_thumbnail`, and `video_thumbnail` are the current per-use capability fields -- top-level `extensions` is kept as a compatibility union for older clients - the built-in image processor exposes common image formats when enabled - the built-in `lofty` processor can expose audio suffixes for embedded cover thumbnails - `vips_cli` / `ffmpeg_cli` expose configured extensions only when the commands are available and the processors are enabled +- this endpoint describes the instance's **effective generation capability**, not the configured database capability; administrators can use `GET /api/v1/admin/config/media-processing-status` to inspect `configured_enabled`, `runtime_available`, and `effective_enabled` for each processor - the capability mainly comes from `media_processing_registry_json` - storage-native thumbnails and image previews can also contribute extensions when a storage policy opts in and the driver exposes that capability; built-in `tencent_cos` policies can expose it through COS CI, while built-in Local, S3-compatible, Azure Blob, OneDrive, and Remote policies do not @@ -200,7 +199,7 @@ Returns media metadata parsing support: } ``` -The top-level `enabled` maps to `media_metadata_enabled`. The per-kind entries are derived from the active media-processing registry and bounded by `media_metadata_max_source_bytes`. Storage-native media metadata extensions can also be merged into audio/video support when a policy opts in and the driver exposes that capability; built-in `tencent_cos` policies can expose it through COS CI. +The top-level `enabled` maps to `media_metadata_enabled`. The per-kind entries are derived from the active media-processing registry and bounded by `media_metadata_max_source_bytes`. Image metadata support is independent from thumbnail generation support: the built-in Rust image parser may advertise `heic` metadata while the thumbnail endpoint filters `heic` when `vips` is missing. Storage-native media metadata extensions can also be merged into audio/video support when a policy opts in and the driver exposes that capability; built-in `tencent_cos` policies can expose it through COS CI. ## `POST /public/remote-enrollment/redeem` diff --git a/developer-docs/zh-CN/api/admin.md b/developer-docs/zh-CN/api/admin.md index 3b15df2d2..bd3c28b90 100644 --- a/developer-docs/zh-CN/api/admin.md +++ b/developer-docs/zh-CN/api/admin.md @@ -715,6 +715,7 @@ POST /api/v1/admin/policies/action | `GET` | `/admin/config` | 列出全部运行时配置 | | `GET` | `/admin/config/schema` | 读取系统配置 schema | | `GET` | `/admin/config/template-variables` | 读取模板变量清单 | +| `GET` | `/admin/config/media-processing-status` | 读取媒体处理器的已配置、运行时可用和有效启用状态 | | `GET` | `/admin/config/{key}` | 获取单个配置项 | | `PUT` | `/admin/config/{key}` | 设置配置项 | | `DELETE` | `/admin/config/{key}` | 删除配置项 | @@ -897,6 +898,8 @@ POST /api/v1/admin/policies/action `GET /admin/config` 返回的是实际配置项分页,字段还会包含 `id`、`key`、`value`、`source`、`visibility`、`namespace`、`updated_at` 和 `updated_by`。敏感配置项的 `value` 会被脱敏成 `***REDACTED***`。 +`GET /admin/config/media-processing-status` 不修改数据库配置,只探测当前实例的运行时命令并返回每个处理器的 `configured_enabled`、`runtime_available`、`effective_enabled` 和可选 `unavailable_reason`。`command_not_found` 不会包含配置中的本地命令路径;full/slim 镜像切换后用这条接口解释“数据库仍启用、当前实例不可生成”的状态。 + 前端管理后台就是靠它动态渲染设置页,而不是写死每个配置项。 ### 配置分区 diff --git a/developer-docs/zh-CN/api/public.md b/developer-docs/zh-CN/api/public.md index 6c89ed076..efdd0f4af 100644 --- a/developer-docs/zh-CN/api/public.md +++ b/developer-docs/zh-CN/api/public.md @@ -152,8 +152,7 @@ }, "video_thumbnail": { "enabled": false - }, - "extensions": ["bmp", "gif", "jpe", "jpeg", "jpg", "png", "tif", "tiff", "webp"] + } } } ``` @@ -162,10 +161,10 @@ - `extensions` 已经做过规范化,统一是不带点的小写扩展名 - `image_preview`、`image_thumbnail`、`audio_thumbnail`、`video_thumbnail` 是当前按用途拆分的能力字段 -- 顶层 `extensions` 是给旧客户端保留的兼容并集字段 - 内置图片处理器启用时会暴露常见图片格式 - 内置 `lofty` 处理器启用 `thumbnail:audio` 时会暴露音频后缀,前端可通过同一条 thumbnail 接口请求音频内嵌封面 - `vips_cli` / `ffmpeg_cli` 只有在对应命令可用且处理器启用时,才会把配置里的扩展名暴露出去;因此它可能包含图片以外的文档或视频扩展名 +- 这条接口表达的是当前实例的 **effective generation capability**,不是数据库里的 configured capability;管理员可通过 `GET /api/v1/admin/config/media-processing-status` 查看每个处理器的 `configured_enabled`、`runtime_available` 和 `effective_enabled` - 这份能力主要来自运行时配置 `media_processing_registry_json` - 如果某条存储策略设置了 `storage_native_thumbnail_enabled = true`,且实际驱动暴露存储原生缩略图 / 图片预览能力,策略里的 `storage_native_thumbnail_extensions` 也会合并进公开能力列表;设置为 `false` 只停用原生候选,不影响全局缩略图处理器。内置 `tencent_cos` 策略可通过 COS CI 暴露这项能力,内置 Local、S3-compatible、Azure Blob、OneDrive 和 Remote 策略不暴露 @@ -207,6 +206,7 @@ - `enabled` 是媒体元数据总开关,对应运行时配置 `media_metadata_enabled` - `max_source_bytes` 会按服务端配置值返回,但会裁剪到 JavaScript 安全整数范围内 - `kinds.image` 来自内置 `images` 处理器的 `metadata:image` 用途 +- 图片元数据支持与缩略图生成支持是独立契约;例如内置 Rust 图片解析链可以继续暴露 `heic` 元数据,而缺少 `vips` 时缩略图接口仍会过滤 `heic` - `kinds.audio` 来自内置 `lofty` 处理器的 `metadata:audio` 用途 - `kinds.video` 来自 `ffprobe_cli` 处理器的 `metadata:video` 用途;命令不可用或处理器未启用时会返回 `enabled = false` - `match = "extensions"` 表示前端应按扩展名匹配;`match = "any"` 当前只会出现在启用 `ffprobe_cli` 且没有配置扩展名过滤时,表示视频元数据可尝试所有视频候选文件 diff --git a/docs/src/content/docs/deploy/docker.md b/docs/src/content/docs/deploy/docker.md index 9c26ef98d..dc4f88504 100644 --- a/docs/src/content/docs/deploy/docker.md +++ b/docs/src/content/docs/deploy/docker.md @@ -34,7 +34,7 @@ NAS、单机、小团队,或者已经在用容器编排的单实例部署。10 | Slim | `vX.Y.Z-slim` / `latest-slim` / `stable-slim` | `vX.Y.Z-metrics-slim` / `latest-metrics-slim` / `stable-metrics-slim` | | 预发布 | `edge` / `edge-slim` | `edge-metrics` / `edge-metrics-slim` | -新建 slim 实例时,`vips_cli`、`ffmpeg_cli` 和 `ffprobe_cli` 默认关闭。从完整镜像切换已有实例之前,先到 `管理 -> 系统设置 -> 文件处理 -> 媒体处理` 检查这三个处理器;已有数据库会保留原配置,但 slim 容器会将缺失命令报告为不可用,也不会在公开能力接口中继续声明对应格式。如果实例需要其中任一能力,继续使用完整镜像。 +新建 slim 实例时,`vips_cli`、`ffmpeg_cli` 和 `ffprobe_cli` 默认关闭。从完整镜像切换已有实例之前,先到 `管理 -> 系统设置 -> 文件处理 -> 媒体处理` 检查这三个处理器;已有数据库会保留原配置,管理端会分别显示“已配置”“运行时可用”和“有效启用”状态。Slim 容器会将缺失命令报告为不可用,也不会在公开缩略图能力接口中继续声明对应格式。已经生成的缩略图或图片预览缓存仍可读取,只有新派生内容生成会受影响;如果实例需要其中任一能力,继续使用完整镜像。 如果你把宿主机目录直接 bind mount 到 `/data`,**一定要先把目录创建好并把属主改成 `10001:10001`**,否则容器启动时生成 `config.toml`、SQLite 文件或临时目录都会直接报权限错误: diff --git a/docs/src/content/docs/en/deploy/docker.md b/docs/src/content/docs/en/deploy/docker.md index ff6137011..eeb27ac71 100644 --- a/docs/src/content/docs/en/deploy/docker.md +++ b/docs/src/content/docs/en/deploy/docker.md @@ -34,7 +34,7 @@ The default tags continue to publish the full image with `vips`, `ffmpeg`, and ` | Slim | `vX.Y.Z-slim` / `latest-slim` / `stable-slim` | `vX.Y.Z-metrics-slim` / `latest-metrics-slim` / `stable-metrics-slim` | | Prerelease | `edge` / `edge-slim` | `edge-metrics` / `edge-metrics-slim` | -Fresh slim instances disable the `vips_cli`, `ffmpeg_cli`, and `ffprobe_cli` processors by default. Before switching an existing instance from the full image, review these processors under `Admin -> System Settings -> File Processing -> Media Processing`. The existing database keeps its configuration, but a slim container reports the missing commands as unavailable and does not advertise their formats through public capability endpoints. Keep using the full image when the instance needs any of these processors. +Fresh slim instances disable the `vips_cli`, `ffmpeg_cli`, and `ffprobe_cli` processors by default. Before switching an existing instance from the full image, review these processors under `Admin -> System Settings -> File Processing -> Media Processing`. The existing database keeps its configuration, and the admin editor reports configured, runtime-available, and effective-enabled status separately. A slim container reports missing commands as unavailable and does not advertise their formats through the public thumbnail capability endpoint. Existing thumbnail and image-preview caches remain readable; only new derivative generation is affected. Keep using the full image when the instance needs any of these processors. If you bind mount a host directory directly to `/data`, **create the directory first and change its owner to `10001:10001`**. Otherwise, container startup will fail with permission errors when generating `config.toml`, creating the SQLite file, or creating temporary directories: diff --git a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx new file mode 100644 index 000000000..5ee4b2b5e --- /dev/null +++ b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MediaProcessingConfigEditor } from "@/components/admin/MediaProcessingConfigEditor"; + +const mockStatus = vi.hoisted(() => vi.fn()); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/services/adminService", () => ({ + adminConfigService: { + mediaProcessingStatus: mockStatus, + }, +})); + +const config = JSON.stringify({ + version: 2, + processors: [ + { + kind: "vips_cli", + enabled: true, + extensions: ["heic"], + uses: ["thumbnail:image"], + config: { command: "vips" }, + }, + ], +}); + +describe("MediaProcessingConfigEditor", () => { + beforeEach(() => { + mockStatus.mockReset(); + }); + + it("explains a saved enabled processor that is missing from the runtime", async () => { + mockStatus.mockResolvedValue({ + version: 1, + processors: [ + { + kind: "vips_cli", + configured_enabled: true, + runtime_available: false, + effective_enabled: false, + unavailable_reason: "command_not_found", + }, + ], + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText( + "media_processing_editor_processor_runtime_unavailable", + ), + ).toBeInTheDocument(); + }); + expect( + screen.getByText( + "media_processing_editor_processor_runtime_unavailable_hint", + ), + ).toBeInTheDocument(); + expect(mockStatus).toHaveBeenCalledTimes(1); + }); + + it("shows runtime availability without an unavailable hint", async () => { + mockStatus.mockResolvedValue({ + version: 1, + processors: [ + { + kind: "vips_cli", + configured_enabled: true, + runtime_available: true, + effective_enabled: true, + }, + ], + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText("media_processing_editor_processor_runtime_available"), + ).toBeInTheDocument(); + }); + expect( + screen.queryByText( + "media_processing_editor_processor_runtime_unavailable_hint", + ), + ).not.toBeInTheDocument(); + }); + + it("keeps the editor usable when runtime status probing fails", async () => { + mockStatus.mockRejectedValue(new Error("probe failed")); + + render(); + + expect( + await screen.findByText("media_processing_editor_title"), + ).toBeInTheDocument(); + }); + + it("ignores a runtime status result after the editor unmounts", async () => { + let resolveStatus!: (value: unknown) => void; + mockStatus.mockReturnValue( + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + + const view = render( + , + ); + view.unmount(); + resolveStatus({ version: 1, processors: [] }); + await Promise.resolve(); + + expect(mockStatus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx index 1c02130e3..b2dbd7557 100644 --- a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx +++ b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { DelimitedListInput } from "@/components/admin/DelimitedListInput"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { adminConfigService } from "@/services/adminService"; +import type { MediaProcessingRuntimeStatus } from "@/types/api"; import { formatMediaProcessingDelimitedInput, getMediaProcessingConfigIssues, @@ -165,11 +167,39 @@ export function MediaProcessingConfigEditor({ const [testingProcessorKind, setTestingProcessorKind] = useState< MediaProcessingEditorProcessor["kind"] | null >(null); + const [runtimeStatus, setRuntimeStatus] = + useState(null); useEffect(() => { setDraft(parseDraftValue(value)); }, [value]); + useEffect(() => { + let active = true; + void adminConfigService + .mediaProcessingStatus() + .then((status) => { + if (active) setRuntimeStatus(status); + }) + .catch(() => { + if (active) setRuntimeStatus(null); + }); + return () => { + active = false; + }; + }, []); + + const runtimeStatusByKind = useMemo( + () => + new Map( + (runtimeStatus?.processors ?? []).map((status) => [ + status.kind, + status, + ]), + ), + [runtimeStatus], + ); + const validationIssues = getMediaProcessingConfigIssues(draft); function updateProcessors( @@ -257,6 +287,9 @@ export function MediaProcessingConfigEditor({ {draft.processors.map((processor) => { const isBuiltinFallback = isBuiltinProcessor(processor.kind); const supportsCommand = processorSupportsCommand(processor.kind); + const processorRuntimeStatus = runtimeStatusByKind.get( + processor.kind, + ); const canTestCommand = (processor.kind === "vips_cli" && onTestVipsCliCommand) || (processor.kind === "ffmpeg_cli" && onTestFfmpegCliCommand) || @@ -290,6 +323,23 @@ export function MediaProcessingConfigEditor({ {t("media_processing_editor_processor_fallback")} ) : null} + {supportsCommand && processorRuntimeStatus ? ( + + {processorRuntimeStatus.runtime_available + ? t( + "media_processing_editor_processor_runtime_available", + ) + : t( + "media_processing_editor_processor_runtime_unavailable", + )} + + ) : null} {processor.uses.map((use) => ( {t(getProcessorUseLabelKey(use))} @@ -299,6 +349,15 @@ export function MediaProcessingConfigEditor({
+ {supportsCommand && + processor.enabled && + processorRuntimeStatus?.runtime_available === false ? ( +
+ {t( + "media_processing_editor_processor_runtime_unavailable_hint", + )} +
+ ) : null}
({ deleteConfig: vi.fn(), handleApiError: vi.fn(), listConfigs: vi.fn(), + mediaProcessingStatus: vi.fn(), navigate: vi.fn(), schema: vi.fn(), sendTestEmail: vi.fn(), @@ -571,6 +572,8 @@ vi.mock("@/services/adminService", () => ({ action: (...args: unknown[]) => mockState.actionConfig(...args), delete: (...args: unknown[]) => mockState.deleteConfig(...args), list: (...args: unknown[]) => mockState.listConfigs(...args), + mediaProcessingStatus: (...args: unknown[]) => + mockState.mediaProcessingStatus(...args), sendTestEmail: (...args: unknown[]) => mockState.sendTestEmail(...args), schema: (...args: unknown[]) => mockState.schema(...args), set: (...args: unknown[]) => mockState.setConfig(...args), @@ -706,6 +709,7 @@ describe("AdminSettingsPage", () => { mockState.deleteConfig.mockReset(); mockState.handleApiError.mockReset(); mockState.listConfigs.mockReset(); + mockState.mediaProcessingStatus.mockReset(); mockState.navigate.mockReset(); mockState.schema.mockReset(); mockState.sendTestEmail.mockReset(); @@ -756,6 +760,10 @@ describe("AdminSettingsPage", () => { mockState.actionConfig.mockResolvedValue({ message: "Imported WOPI discovery apps from 1 source", }); + mockState.mediaProcessingStatus.mockResolvedValue({ + version: 1, + processors: [], + }); mockState.templateVariables.mockResolvedValue([ createTemplateVariableGroup(), ]); diff --git a/frontend-panel/src/services/adminService.test.ts b/frontend-panel/src/services/adminService.test.ts index e4e5e3f91..877b8b1b2 100644 --- a/frontend-panel/src/services/adminService.test.ts +++ b/frontend-panel/src/services/adminService.test.ts @@ -42,6 +42,14 @@ describe("adminService", () => { mockState.put.mockReset(); }); + it("loads media processor runtime status separately from persisted config", () => { + adminConfigService.mediaProcessingStatus(); + + expect(mockState.get).toHaveBeenCalledWith( + "/admin/config/media-processing-status", + ); + }); + it("builds list endpoints with optional query strings", () => { adminUserService.list({ limit: 20, diff --git a/frontend-panel/src/services/adminService.ts b/frontend-panel/src/services/adminService.ts index cca036c0b..0d6740a43 100644 --- a/frontend-panel/src/services/adminService.ts +++ b/frontend-panel/src/services/adminService.ts @@ -56,6 +56,7 @@ import type { ExternalAuthProviderTestResult, FolderInfo, LockPage, + MediaProcessingRuntimeStatus, MigratePolicyGroupAssignmentsRequest, PolicyGroupAssignmentMigrationResult, PromoteStoragePolicyConnectorRequest, @@ -696,6 +697,11 @@ export const adminConfigService = { templateVariables: () => api.get("/admin/config/template-variables"), + mediaProcessingStatus: () => + api.get( + "/admin/config/media-processing-status", + ), + get: (key: string) => api.get(`/admin/config/${key}`), set: (key: string, value: string | string[], visibility?: ConfigVisibility) => diff --git a/frontend-panel/src/services/api.generated.ts b/frontend-panel/src/services/api.generated.ts index add9ae8b2..f517f77d6 100644 --- a/frontend-panel/src/services/api.generated.ts +++ b/frontend-panel/src/services/api.generated.ts @@ -36,6 +36,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/config/media-processing-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_media_processing_runtime_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/config/schema": { parameters: { query?: never; @@ -6104,6 +6120,20 @@ export interface components { }); /** @enum {string} */ MediaMetadataStatus: "ready" | "failed" | "unsupported"; + MediaProcessingProcessorRuntimeStatus: { + configured_enabled: boolean; + effective_enabled: boolean; + kind: components["schemas"]["MediaProcessorKind"]; + runtime_available: boolean; + unavailable_reason?: null | components["schemas"]["MediaProcessingUnavailableReason"]; + }; + MediaProcessingRuntimeStatus: { + processors: components["schemas"]["MediaProcessingProcessorRuntimeStatus"][]; + /** Format: int32 */ + version: number; + }; + /** @enum {string} */ + MediaProcessingUnavailableReason: "command_not_found"; /** * @description 统一媒体处理器类型(system_config / storage_policy.options) * @enum {string} @@ -9483,6 +9513,49 @@ export interface operations { }; }; }; + get_media_processing_runtime_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Configured and effective media processor status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + code: components["schemas"]["ApiErrorCode"]; + data?: { + processors: components["schemas"]["MediaProcessingProcessorRuntimeStatus"][]; + /** Format: int32 */ + version: number; + }; + error?: null | components["schemas"]["ApiErrorInfo"]; + msg: string; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; config_schema: { parameters: { query?: never; diff --git a/frontend-panel/src/types/api.ts b/frontend-panel/src/types/api.ts index 0c8aebf0a..81ea78581 100644 --- a/frontend-panel/src/types/api.ts +++ b/frontend-panel/src/types/api.ts @@ -507,6 +507,12 @@ export type PublicMediaDataSupportMatch = components["schemas"]["PublicMediaDataSupportMatch"]; export type PublicImagePreviewPreference = components["schemas"]["PublicImagePreviewPreference"]; +export type MediaProcessingProcessorRuntimeStatus = + components["schemas"]["MediaProcessingProcessorRuntimeStatus"]; +export type MediaProcessingRuntimeStatus = + components["schemas"]["MediaProcessingRuntimeStatus"]; +export type MediaProcessingUnavailableReason = + components["schemas"]["MediaProcessingUnavailableReason"]; export type PublicFrontendDownloadConfig = components["schemas"]["PublicFrontendDownloadConfig"]; export type PublicFrontendMediaConfig = diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 7a0f49e8a..6241398e8 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -359,6 +359,7 @@ use utoipa::{Modify, OpenApi}; crate::api::routes::admin::config::execute_config_action, crate::api::routes::admin::config::config_schema, crate::api::routes::admin::config::config_template_variables, + crate::api::routes::admin::config::get_media_processing_runtime_status, // admin::shares:后台对全站分享的审查和强制删除能力。 crate::api::routes::admin::shares::list_all_shares, @@ -672,6 +673,9 @@ use utoipa::{Modify, OpenApi}; crate::config::media_processing::PublicMediaDataSupport, crate::config::media_processing::PublicMediaDataSupportMatch, crate::config::media_processing::PublicThumbnailSupport, + crate::config::media_processing::MediaProcessingProcessorRuntimeStatus, + crate::config::media_processing::MediaProcessingRuntimeStatus, + crate::config::media_processing::MediaProcessingUnavailableReason, crate::services::preview::apps::PreviewAppProvider, crate::services::preview::apps::PreviewOpenMode, crate::services::preview::apps::PublicPreviewAppConfig, diff --git a/src/api/routes/admin/config.rs b/src/api/routes/admin/config.rs index 3ecc26f52..3e93ea3ef 100644 --- a/src/api/routes/admin/config.rs +++ b/src/api/routes/admin/config.rs @@ -66,6 +66,25 @@ pub async fn config_template_variables() -> Result { Ok(HttpResponse::Ok().json(ApiResponse::ok(groups))) } +#[aster_forge_api_docs_macros::path( + get, + path = "/api/v1/admin/config/media-processing-status", + tag = "admin", + operation_id = "get_media_processing_runtime_status", + responses( + (status = 200, description = "Configured and effective media processor status", body = inline(ApiResponse)), + (status = 401, description = crate::api::constants::OPENAPI_UNAUTHORIZED), + (status = 403, description = "Forbidden"), + ), + security(("bearer" = [])), +)] +pub async fn get_media_processing_runtime_status( + state: web::Data, +) -> Result { + let status = config::get_media_processing_runtime_status(state.get_ref()); + Ok(HttpResponse::Ok().json(ApiResponse::ok(status))) +} + #[aster_forge_api_docs_macros::path( get, path = "/api/v1/admin/config/{key}", diff --git a/src/api/routes/admin/mod.rs b/src/api/routes/admin/mod.rs index cd23fed68..db03cef54 100644 --- a/src/api/routes/admin/mod.rs +++ b/src/api/routes/admin/mod.rs @@ -41,7 +41,7 @@ pub(crate) mod users; pub use audit_logs::list_audit_logs; pub use config::{ config_schema, config_template_variables, delete_config, execute_config_action, get_config, - list_config, set_config, + get_media_processing_runtime_status, list_config, set_config, }; pub use external_auth::{ create_external_auth_provider, delete_external_auth_provider, get_external_auth_provider, @@ -305,6 +305,10 @@ pub fn routes( "/config/template-variables", web::get().to(config_template_variables), ) + .route( + "/config/media-processing-status", + web::get().to(get_media_processing_runtime_status), + ) .route("/config/{key}", web::get().to(get_config)) .route("/config/{key}", web::put().to(set_config)) .route("/config/{key}", web::delete().to(delete_config)) diff --git a/src/config/media_processing/mod.rs b/src/config/media_processing/mod.rs index d577cde26..397fb7dd3 100644 --- a/src/config/media_processing/mod.rs +++ b/src/config/media_processing/mod.rs @@ -13,19 +13,22 @@ pub use registry::{ default_media_processing_registry_json, default_uses_for_kind, ffmpeg_command_from_registry_value, ffprobe_command_from_registry, ffprobe_command_from_registry_value, file_extension, media_processing_registry, - normalize_existing_media_processing_registry_config_value, normalize_ffmpeg_command, - normalize_ffprobe_command, normalize_media_processing_registry_config_value, - normalize_vips_command, parse_media_processor_kind, processor_candidates_for_file_name, - processor_candidates_for_use, processor_config_for_kind, processor_supports_use, - public_media_data_support, public_thumbnail_support, vips_command_from_registry_value, + media_processing_runtime_status, normalize_existing_media_processing_registry_config_value, + normalize_ffmpeg_command, normalize_ffprobe_command, + normalize_media_processing_registry_config_value, normalize_vips_command, + parse_media_processor_kind, processor_candidates_for_file_name, processor_candidates_for_use, + processor_config_for_kind, processor_supports_use, public_media_data_support, + public_thumbnail_support, vips_command_from_registry_value, }; pub use types::{ BUILTIN_AUDIO_METADATA_EXTENSIONS, BUILTIN_AUDIO_THUMBNAIL_EXTENSIONS, BUILTIN_IMAGE_METADATA_EXTENSIONS, BUILTIN_IMAGES_SUPPORTED_EXTENSIONS, DEFAULT_FFMPEG_COMMAND, DEFAULT_FFMPEG_EXTENSIONS, DEFAULT_FFPROBE_COMMAND, DEFAULT_FFPROBE_EXTENSIONS, DEFAULT_VIPS_COMMAND, DEFAULT_VIPS_EXTENSIONS, MEDIA_PROCESSING_REGISTRY_VERSION, - MatchedMediaProcessor, MediaProcessingMatchKind, MediaProcessingProcessorConfig, - MediaProcessingProcessorRuntimeConfig, MediaProcessingRegistryConfig, MediaProcessingUse, + MEDIA_PROCESSING_RUNTIME_STATUS_VERSION, MatchedMediaProcessor, MediaProcessingMatchKind, + MediaProcessingProcessorConfig, MediaProcessingProcessorRuntimeConfig, + MediaProcessingProcessorRuntimeStatus, MediaProcessingRegistryConfig, + MediaProcessingRuntimeStatus, MediaProcessingUnavailableReason, MediaProcessingUse, PUBLIC_MEDIA_DATA_MAX_SAFE_SOURCE_BYTES, PUBLIC_MEDIA_DATA_SUPPORT_VERSION, PUBLIC_THUMBNAIL_SUPPORT_VERSION, PublicExtensionSupport, PublicImagePreviewPreference, PublicMediaDataKindSupport, PublicMediaDataKindsSupport, PublicMediaDataSupport, diff --git a/src/config/media_processing/registry.rs b/src/config/media_processing/registry.rs index b0fd88ead..d5226d85b 100644 --- a/src/config/media_processing/registry.rs +++ b/src/config/media_processing/registry.rs @@ -14,8 +14,10 @@ use super::types::{ BUILTIN_IMAGE_METADATA_EXTENSIONS, BUILTIN_IMAGES_SUPPORTED_EXTENSIONS, DEFAULT_FFMPEG_COMMAND, DEFAULT_FFMPEG_EXTENSIONS, DEFAULT_FFPROBE_COMMAND, DEFAULT_FFPROBE_EXTENSIONS, DEFAULT_VIPS_COMMAND, DEFAULT_VIPS_EXTENSIONS, MEDIA_PROCESSING_REGISTRY_VERSION, - MatchedMediaProcessor, MediaProcessingMatchKind, MediaProcessingProcessorConfig, - MediaProcessingProcessorRuntimeConfig, MediaProcessingRegistryConfig, MediaProcessingUse, + MEDIA_PROCESSING_RUNTIME_STATUS_VERSION, MatchedMediaProcessor, MediaProcessingMatchKind, + MediaProcessingProcessorConfig, MediaProcessingProcessorRuntimeConfig, + MediaProcessingProcessorRuntimeStatus, MediaProcessingRegistryConfig, + MediaProcessingRuntimeStatus, MediaProcessingUnavailableReason, MediaProcessingUse, PUBLIC_MEDIA_DATA_MAX_SAFE_SOURCE_BYTES, PUBLIC_MEDIA_DATA_SUPPORT_VERSION, PUBLIC_THUMBNAIL_SUPPORT_VERSION, PublicExtensionSupport, PublicMediaDataKindSupport, PublicMediaDataKindsSupport, PublicMediaDataSupport, PublicMediaDataSupportMatch, @@ -196,6 +198,58 @@ pub fn public_thumbnail_support(runtime_config: &RuntimeConfig) -> PublicThumbna } } +pub fn media_processing_runtime_status( + runtime_config: &RuntimeConfig, +) -> MediaProcessingRuntimeStatus { + let registry = media_processing_registry(runtime_config); + MediaProcessingRuntimeStatus { + version: MEDIA_PROCESSING_RUNTIME_STATUS_VERSION, + processors: registry + .processors + .iter() + .map(|processor| { + let runtime_available = processor_runtime_available(processor); + MediaProcessingProcessorRuntimeStatus { + kind: processor.kind, + configured_enabled: processor.enabled, + runtime_available, + effective_enabled: processor.enabled && runtime_available, + unavailable_reason: (processor.enabled && !runtime_available) + .then_some(MediaProcessingUnavailableReason::CommandNotFound), + } + }) + .collect(), + } +} + +fn processor_runtime_available(processor: &MediaProcessingProcessorConfig) -> bool { + match processor.kind { + MediaProcessorKind::Images | MediaProcessorKind::Lofty => true, + MediaProcessorKind::VipsCli => command_is_available( + processor + .config + .command + .as_deref() + .unwrap_or(DEFAULT_VIPS_COMMAND), + ), + MediaProcessorKind::FfmpegCli => command_is_available( + processor + .config + .command + .as_deref() + .unwrap_or(DEFAULT_FFMPEG_COMMAND), + ), + MediaProcessorKind::FfprobeCli => command_is_available( + processor + .config + .command + .as_deref() + .unwrap_or(DEFAULT_FFPROBE_COMMAND), + ), + MediaProcessorKind::StorageNative => false, + } +} + fn public_extension_support(extensions: BTreeSet) -> PublicExtensionSupport { PublicExtensionSupport { enabled: !extensions.is_empty(), diff --git a/src/config/media_processing/tests.rs b/src/config/media_processing/tests.rs index 9ccd7f52e..e1929781c 100644 --- a/src/config/media_processing/tests.rs +++ b/src/config/media_processing/tests.rs @@ -15,18 +15,19 @@ use super::{ DEFAULT_FFPROBE_COMMAND, DEFAULT_FFPROBE_EXTENSIONS, DEFAULT_VIPS_COMMAND, DEFAULT_VIPS_EXTENSIONS, MEDIA_PROCESSING_REGISTRY_JSON_KEY, MEDIA_PROCESSING_REGISTRY_VERSION, MatchedMediaProcessor, MediaProcessingMatchKind, MediaProcessingProcessorConfig, - MediaProcessingProcessorRuntimeConfig, MediaProcessingRegistryConfig, MediaProcessingUse, - PUBLIC_MEDIA_DATA_MAX_SAFE_SOURCE_BYTES, PUBLIC_MEDIA_DATA_SUPPORT_VERSION, - PublicExtensionSupport, PublicMediaDataKindSupport, PublicMediaDataSupport, - PublicMediaDataSupportMatch, PublicThumbnailSupport, builtin_audio_metadata_supports_extension, - builtin_image_metadata_supports_extension, command_is_available, - default_media_processing_registry, default_media_processing_registry_json, - default_uses_for_kind, ffmpeg_command_from_registry_value, ffprobe_command_from_registry_value, - file_extension, media_processing_registry, normalize_ffmpeg_command, normalize_ffprobe_command, - normalize_media_processing_registry_config_value, normalize_vips_command, - parse_media_processor_kind, processor_candidates_for_file_name, processor_candidates_for_use, - processor_config_for_kind, public_media_data_support, public_thumbnail_support, - vips_command_from_registry_value, + MediaProcessingProcessorRuntimeConfig, MediaProcessingRegistryConfig, + MediaProcessingUnavailableReason, MediaProcessingUse, PUBLIC_MEDIA_DATA_MAX_SAFE_SOURCE_BYTES, + PUBLIC_MEDIA_DATA_SUPPORT_VERSION, PublicExtensionSupport, PublicMediaDataKindSupport, + PublicMediaDataSupport, PublicMediaDataSupportMatch, PublicThumbnailSupport, + builtin_audio_metadata_supports_extension, builtin_image_metadata_supports_extension, + command_is_available, default_media_processing_registry, + default_media_processing_registry_json, default_uses_for_kind, + ffmpeg_command_from_registry_value, ffprobe_command_from_registry_value, file_extension, + media_processing_registry, media_processing_runtime_status, normalize_ffmpeg_command, + normalize_ffprobe_command, normalize_media_processing_registry_config_value, + normalize_vips_command, parse_media_processor_kind, processor_candidates_for_file_name, + processor_candidates_for_use, processor_config_for_kind, public_media_data_support, + public_thumbnail_support, vips_command_from_registry_value, }; fn config_model(key: &str, value: &str) -> system_config::Model { @@ -473,6 +474,96 @@ fn public_thumbnail_support_exposes_enabled_processor_capabilities() { ); } +#[test] +fn media_processing_runtime_status_separates_configured_available_and_effective_state() { + let runtime_config = RuntimeConfig::new(); + let available_command = available_test_command(); + runtime_config.apply(config_model( + MEDIA_PROCESSING_REGISTRY_JSON_KEY, + &serde_json::json!({ + "version": 2, + "processors": [ + { + "kind": "vips_cli", + "enabled": true, + "uses": ["thumbnail:image"], + "extensions": ["heic"], + "config": { "command": "/definitely-missing/aster-vips" }, + }, + { + "kind": "ffmpeg_cli", + "enabled": false, + "uses": ["thumbnail:video"], + "extensions": ["mp4"], + "config": { "command": available_command }, + }, + { + "kind": "ffprobe_cli", + "enabled": true, + "uses": ["metadata:video"], + "extensions": ["mp4"], + "config": { "command": "/definitely-missing/aster-ffprobe" }, + }, + { + "kind": "lofty", + "enabled": true, + "uses": ["thumbnail:audio", "metadata:audio"] + }, + { + "kind": "images", + "enabled": true, + "uses": ["thumbnail:image", "metadata:image"] + } + ] + }) + .to_string(), + )); + + let status = media_processing_runtime_status(&runtime_config); + let find = |kind| { + status + .processors + .iter() + .find(|processor| processor.kind == kind) + .expect("processor runtime status should exist") + }; + + let vips = find(MediaProcessorKind::VipsCli); + assert!(vips.configured_enabled); + assert!(!vips.runtime_available); + assert!(!vips.effective_enabled); + assert_eq!( + vips.unavailable_reason, + Some(MediaProcessingUnavailableReason::CommandNotFound) + ); + + let ffmpeg = find(MediaProcessorKind::FfmpegCli); + assert!(!ffmpeg.configured_enabled); + assert!(ffmpeg.runtime_available); + assert!(!ffmpeg.effective_enabled); + assert_eq!(ffmpeg.unavailable_reason, None); + + let ffprobe = find(MediaProcessorKind::FfprobeCli); + assert!(ffprobe.configured_enabled); + assert!(!ffprobe.runtime_available); + assert!(!ffprobe.effective_enabled); + + for kind in [MediaProcessorKind::Lofty, MediaProcessorKind::Images] { + let builtin = find(kind); + assert!(builtin.configured_enabled); + assert!(builtin.runtime_available); + assert!(builtin.effective_enabled); + assert_eq!(builtin.unavailable_reason, None); + } + + let serialized = serde_json::to_value(status).expect("runtime status should serialize"); + assert_eq!(serialized["version"], 1); + assert!( + !serialized.to_string().contains("definitely-missing"), + "admin runtime status must not expose configured command paths" + ); +} + #[test] fn public_thumbnail_support_keeps_builtin_extensions_when_images_are_enabled() { let support = public_thumbnail_support(&RuntimeConfig::new()); diff --git a/src/config/media_processing/types.rs b/src/config/media_processing/types.rs index 969bef67d..1aa441901 100644 --- a/src/config/media_processing/types.rs +++ b/src/config/media_processing/types.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; pub const MEDIA_PROCESSING_REGISTRY_VERSION: i32 = 2; +pub const MEDIA_PROCESSING_RUNTIME_STATUS_VERSION: i32 = 1; pub const PUBLIC_THUMBNAIL_SUPPORT_VERSION: i32 = 1; pub const PUBLIC_MEDIA_DATA_SUPPORT_VERSION: i32 = 1; pub const PUBLIC_MEDIA_DATA_MAX_SAFE_SOURCE_BYTES: i64 = 9_007_199_254_740_991; @@ -212,6 +213,31 @@ pub struct MediaProcessingProcessorConfig { pub config: MediaProcessingProcessorRuntimeConfig, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))] +pub enum MediaProcessingUnavailableReason { + CommandNotFound, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))] +pub struct MediaProcessingProcessorRuntimeStatus { + pub kind: MediaProcessorKind, + pub configured_enabled: bool, + pub runtime_available: bool, + pub effective_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))] +pub struct MediaProcessingRuntimeStatus { + pub version: i32, + pub processors: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MediaProcessingMatchKind { Policy, diff --git a/src/db/repository/config_repo.rs b/src/db/repository/config_repo.rs index 0a75fd983..e30b55264 100644 --- a/src/db/repository/config_repo.rs +++ b/src/db/repository/config_repo.rs @@ -550,6 +550,75 @@ mod tests { assert!(images.enabled); } + #[tokio::test] + async fn ensure_defaults_preserves_media_processor_config_across_full_slim_switches() { + let full_db = setup_db().await; + ensure_defaults_with_env(&full_db, &|name| match name { + BOOTSTRAP_ENABLE_VIPS_CLI_ENV + | BOOTSTRAP_ENABLE_FFMPEG_CLI_ENV + | BOOTSTRAP_ENABLE_FFPROBE_CLI_ENV => Some("true".to_string()), + _ => None, + }) + .await + .expect("fresh full-image defaults should succeed"); + + ensure_defaults_with_env(&full_db, &|name| match name { + BOOTSTRAP_ENABLE_VIPS_CLI_ENV + | BOOTSTRAP_ENABLE_FFMPEG_CLI_ENV + | BOOTSTRAP_ENABLE_FFPROBE_CLI_ENV => Some("false".to_string()), + _ => None, + }) + .await + .expect("full-to-slim default sync should succeed"); + let full_to_slim = media_processing_registry_config(&full_db).await; + for kind in [ + MediaProcessorKind::VipsCli, + MediaProcessorKind::FfmpegCli, + MediaProcessorKind::FfprobeCli, + ] { + assert!( + media_processing::processor_config_for_kind(&full_to_slim, kind) + .expect("external processor should remain configured") + .enabled, + "full-to-slim must preserve enabled state for {}", + kind.as_str() + ); + } + + let slim_db = setup_db().await; + ensure_defaults_with_env(&slim_db, &|name| match name { + BOOTSTRAP_ENABLE_VIPS_CLI_ENV + | BOOTSTRAP_ENABLE_FFMPEG_CLI_ENV + | BOOTSTRAP_ENABLE_FFPROBE_CLI_ENV => Some("false".to_string()), + _ => None, + }) + .await + .expect("fresh slim-image defaults should succeed"); + + ensure_defaults_with_env(&slim_db, &|name| match name { + BOOTSTRAP_ENABLE_VIPS_CLI_ENV + | BOOTSTRAP_ENABLE_FFMPEG_CLI_ENV + | BOOTSTRAP_ENABLE_FFPROBE_CLI_ENV => Some("true".to_string()), + _ => None, + }) + .await + .expect("slim-to-full default sync should succeed"); + let slim_to_full = media_processing_registry_config(&slim_db).await; + for kind in [ + MediaProcessorKind::VipsCli, + MediaProcessorKind::FfmpegCli, + MediaProcessorKind::FfprobeCli, + ] { + assert!( + !media_processing::processor_config_for_kind(&slim_to_full, kind) + .expect("external processor should remain configured") + .enabled, + "slim-to-full must preserve disabled state for {}", + kind.as_str() + ); + } + } + #[tokio::test] async fn ensure_defaults_migrates_legacy_cors_origin_values_to_string_arrays() { let db = setup_db().await; diff --git a/src/services/media/processing/resolve.rs b/src/services/media/processing/resolve.rs index 695af0769..1842a0a4a 100644 --- a/src/services/media/processing/resolve.rs +++ b/src/services/media/processing/resolve.rs @@ -49,6 +49,63 @@ pub(super) fn build_thumbnail_context( Ok(ThumbnailContext { driver, processor }) } +pub(super) fn build_persisted_thumbnail_context( + state: &PrimaryAppState, + blob: &file_blob::Model, + file_name: &str, + source_mime_type: &str, +) -> Result> { + let (Some(persisted_processor), Some(persisted_version), Some(_)) = ( + blob.thumbnail_processor.as_deref(), + blob.thumbnail_version.as_deref(), + blob.thumbnail_path.as_deref(), + ) else { + return Ok(None); + }; + + let processor = build_configured_thumbnail_contexts(state, blob, file_name, source_mime_type)? + .into_iter() + .map(|context| context.processor) + .find(|processor| { + processor.thumbnail_processor() == persisted_processor + && processor.thumbnail_version(state.runtime_config()) == persisted_version + }); + let Some(processor) = processor else { + return Ok(None); + }; + + let policy = state.policy_snapshot().get_policy_or_err(blob.policy_id)?; + let driver = state.driver_registry().get_driver(&policy)?; + Ok(Some(ThumbnailContext { driver, processor })) +} + +pub(super) fn build_configured_thumbnail_contexts( + state: &PrimaryAppState, + blob: &file_blob::Model, + file_name: &str, + source_mime_type: &str, +) -> Result> { + let policy = state.policy_snapshot().get_policy_or_err(blob.policy_id)?; + let candidates = collect_thumbnail_processor_candidates( + state.driver_registry().connectors(), + state.runtime_config(), + &policy, + file_name, + source_mime_type, + )?; + if candidates.is_empty() { + return Ok(Vec::new()); + } + let driver = state.driver_registry().get_driver(&policy)?; + Ok(candidates + .into_iter() + .map(|candidate| ThumbnailContext { + driver: driver.clone(), + processor: resolved_media_processor_from_config(&candidate.processor), + }) + .collect()) +} + pub(super) fn build_thumbnail_context_with_processor( state: &PrimaryAppState, policy: &storage_policy::Model, @@ -188,9 +245,10 @@ fn processor_unavailable_reason( .as_deref() .unwrap_or(media_processing_config::DEFAULT_VIPS_COMMAND); if !media_processing_config::command_is_available(command) { - return Ok(Some(format!( - "vips CLI command '{command}' is not available" - ))); + return Ok(Some( + "vips_cli processor command is unavailable; use the full image or disable vips_cli" + .to_string(), + )); } Ok(None) } @@ -201,9 +259,10 @@ fn processor_unavailable_reason( .as_deref() .unwrap_or(media_processing_config::DEFAULT_FFMPEG_COMMAND); if !media_processing_config::command_is_available(command) { - return Ok(Some(format!( - "ffmpeg CLI command '{command}' is not available" - ))); + return Ok(Some( + "ffmpeg_cli processor command is unavailable; use the full image or disable ffmpeg_cli" + .to_string(), + )); } Ok(None) } @@ -214,9 +273,10 @@ fn processor_unavailable_reason( .as_deref() .unwrap_or(media_processing_config::DEFAULT_FFPROBE_COMMAND); if !media_processing_config::command_is_available(command) { - return Ok(Some(format!( - "ffprobe CLI command '{command}' is not available" - ))); + return Ok(Some( + "ffprobe_cli processor command is unavailable; use the full image or disable ffprobe_cli" + .to_string(), + )); } Ok(None) } diff --git a/src/services/media/processing/thumbnail/mod.rs b/src/services/media/processing/thumbnail/mod.rs index e28df76d2..7083fd96f 100644 --- a/src/services/media/processing/thumbnail/mod.rs +++ b/src/services/media/processing/thumbnail/mod.rs @@ -13,7 +13,10 @@ use aster_drive_model::entities::file_blob; use aster_drive_model::types::MediaProcessorKind; use bytes::Bytes; -use super::resolve::{build_thumbnail_context, build_thumbnail_context_with_processor}; +use super::resolve::{ + build_persisted_thumbnail_context, build_thumbnail_context, + build_thumbnail_context_with_processor, +}; use super::shared::{StoredThumbnail, ThumbnailContext, ThumbnailData}; pub use cache::delete_thumbnail; @@ -28,6 +31,14 @@ pub async fn load_thumbnail_if_exists( file_name: &str, source_mime_type: &str, ) -> Result> { + if let Some(persisted_ctx) = + build_persisted_thumbnail_context(state, blob, file_name, source_mime_type)? + && let Some(data) = + cache::load_thumbnail_if_exists_with_context(state, blob, &persisted_ctx).await? + { + return Ok(Some(data)); + } + let ctx = build_thumbnail_context(state, blob, file_name, source_mime_type)?; cache::load_thumbnail_if_exists_with_context(state, blob, &ctx).await } diff --git a/src/services/media/processing/thumbnail/preview.rs b/src/services/media/processing/thumbnail/preview.rs index 66e03affa..0e8b7c654 100644 --- a/src/services/media/processing/thumbnail/preview.rs +++ b/src/services/media/processing/thumbnail/preview.rs @@ -3,6 +3,7 @@ use crate::runtime::{PrimaryAppState, SharedRuntimeState}; use aster_drive_model::entities::file_blob; use bytes::Bytes; +use crate::services::media::processing::resolve::build_configured_thumbnail_contexts; use crate::services::media::processing::resolve::build_thumbnail_context; use crate::services::media::processing::resolve::build_thumbnail_context_with_processor; use crate::services::media::processing::shared::{ @@ -19,6 +20,32 @@ pub async fn load_image_preview_if_exists( file_name: &str, source_mime_type: &str, ) -> Result> { + for ctx in build_configured_thumbnail_contexts(state, blob, file_name, source_mime_type)? { + let preview_path = ctx + .processor + .image_preview_cache_path(&blob.hash, state.runtime_config()); + let preview_processor = ctx.processor.image_preview_processor().to_string(); + let preview_version = ctx.processor.image_preview_version(state.runtime_config()); + if let Some(data) = + load_thumbnail_from_path(state, blob, &ctx.driver, &preview_path, false).await? + { + tracing::debug!( + blob_id = blob.id, + processor = ctx.processor.kind().as_str(), + image_preview_path = preview_path, + image_preview_processor = preview_processor, + image_preview_version = preview_version, + cache_source = "configured_processor", + "image preview cache hit" + ); + return Ok(Some(ImagePreviewData { + data, + image_preview_processor: preview_processor, + image_preview_version: preview_version, + })); + } + } + let ctx = build_thumbnail_context(state, blob, file_name, source_mime_type)?; let preview_path = ctx .processor diff --git a/src/services/ops/config/mod.rs b/src/services/ops/config/mod.rs index 2d80792d2..8e2b2d1bd 100644 --- a/src/services/ops/config/mod.rs +++ b/src/services/ops/config/mod.rs @@ -24,6 +24,6 @@ pub use schema::{ ConfigSchemaOption, get_schema, }; pub use system::{ - SystemConfig, delete, delete_with_audit, get_by_key, list_paginated, set, set_with_audit, - set_with_audit_and_visibility, set_with_visibility, + SystemConfig, delete, delete_with_audit, get_by_key, get_media_processing_runtime_status, + list_paginated, set, set_with_audit, set_with_audit_and_visibility, set_with_visibility, }; diff --git a/src/services/ops/config/system.rs b/src/services/ops/config/system.rs index bbd2895db..80cc87c03 100644 --- a/src/services/ops/config/system.rs +++ b/src/services/ops/config/system.rs @@ -85,6 +85,12 @@ pub async fn get_by_key(state: &impl SharedRuntimeState, key: &str) -> Result crate::config::media_processing::MediaProcessingRuntimeStatus { + crate::config::media_processing::media_processing_runtime_status(state.runtime_config()) +} + pub async fn set( state: &impl SharedRuntimeState, key: &str, diff --git a/tests/files/thumbnail.rs b/tests/files/thumbnail.rs index 5b45696fb..20e92f627 100644 --- a/tests/files/thumbnail.rs +++ b/tests/files/thumbnail.rs @@ -635,6 +635,178 @@ async fn test_thumbnail_heic_uses_vips_cli_processor_when_extension_matches() { ); } +#[cfg(unix)] +#[actix_web::test] +async fn test_full_to_slim_keeps_cached_heic_thumbnail_but_rejects_new_generation() { + let state = common::setup().await; + let (fake_vips, _) = write_fake_vips_thumbnail_command(); + state.runtime_config.apply(common::system_config_model( + "media_processing_registry_json", + &json!({ + "version": 2, + "processors": [ + { + "kind": "vips_cli", + "enabled": true, + "uses": ["thumbnail:image"], + "extensions": ["heic"], + "config": { "command": fake_vips } + }, + { + "kind": "images", + "enabled": true, + "uses": ["thumbnail:image", "metadata:image"] + } + ] + }) + .to_string(), + )); + let app = create_test_app!(state.clone()); + let (token, _) = register_and_login!(app); + let cached_file_id = upload_file_bytes!( + app, + token, + "cached.heic", + "image/heic", + b"cached-heic".to_vec() + ); + + let first = request_thumbnail!(app, token, cached_file_id); + assert_eq!(first.status(), 202); + aster_drive::services::task::drain(&state).await.unwrap(); + let generated = request_thumbnail!(app, token, cached_file_id); + assert_eq!(generated.status(), 200); + + state.runtime_config.apply(common::system_config_model( + "media_processing_registry_json", + &json!({ + "version": 2, + "processors": [ + { + "kind": "vips_cli", + "enabled": true, + "uses": ["thumbnail:image"], + "extensions": ["heic"], + "config": { "command": "/definitely-missing/aster-vips" } + }, + { + "kind": "images", + "enabled": true, + "uses": ["thumbnail:image", "metadata:image"] + } + ] + }) + .to_string(), + )); + + let cached_after_switch = request_thumbnail!(app, token, cached_file_id); + assert_eq!(cached_after_switch.status(), 200); + + let task_count_before = background_task_repo::list_recent(state.writer_db(), 64) + .await + .unwrap() + .len(); + let new_file_id = + upload_file_bytes!(app, token, "new.heic", "image/heic", b"new-heic".to_vec()); + let unavailable = request_thumbnail!(app, token, new_file_id); + assert_eq!(unavailable.status(), 412); + let body: Value = test::read_body_json(unavailable).await; + assert_eq!(body["code"], "thumbnail.processor_unavailable"); + assert!( + body["msg"] + .as_str() + .is_some_and(|message| message.contains("use the full image or disable vips_cli")) + ); + assert!(!body.to_string().contains("definitely-missing")); + + let task_count_after = background_task_repo::list_recent(state.writer_db(), 64) + .await + .unwrap() + .len(); + assert_eq!(task_count_after, task_count_before); +} + +#[cfg(unix)] +#[actix_web::test] +async fn test_full_to_slim_keeps_cached_heic_image_preview_readable() { + let state = common::setup().await; + let (fake_vips, _) = write_fake_vips_thumbnail_command(); + state.runtime_config.apply(common::system_config_model( + "media_processing_registry_json", + &json!({ + "version": 2, + "processors": [ + { + "kind": "vips_cli", + "enabled": true, + "uses": ["thumbnail:image"], + "extensions": ["heic"], + "config": { "command": fake_vips } + }, + { + "kind": "images", + "enabled": true, + "uses": ["thumbnail:image", "metadata:image"] + } + ] + }) + .to_string(), + )); + let app = create_test_app!(state.clone()); + let (token, _) = register_and_login!(app); + let file_id = upload_file_bytes!( + app, + token, + "preview.heic", + "image/heic", + b"preview-heic".to_vec() + ); + let file = file_repo::find_by_id(state.writer_db(), file_id) + .await + .unwrap(); + let blob = blob_for_file(&state, file_id).await; + aster_drive::services::media::processing::generate_and_store_image_preview( + &state, + &blob, + &file.name, + &file.mime_type, + ) + .await + .expect("full-image preview generation should succeed"); + + state.runtime_config.apply(common::system_config_model( + "media_processing_registry_json", + &json!({ + "version": 2, + "processors": [ + { + "kind": "vips_cli", + "enabled": true, + "uses": ["thumbnail:image"], + "extensions": ["heic"], + "config": { "command": "/definitely-missing/aster-vips" } + }, + { + "kind": "images", + "enabled": true, + "uses": ["thumbnail:image", "metadata:image"] + } + ] + }) + .to_string(), + )); + + let preview = aster_drive::services::media::processing::load_image_preview_if_exists( + &state, + &blob, + &file.name, + &file.mime_type, + ) + .await + .expect("slim-image preview cache read should succeed"); + assert!(preview.is_some()); +} + #[actix_web::test] async fn test_thumbnail_mp4_uses_ffmpeg_cli_processor_when_extension_matches() { let Some(ffmpeg_command) = ffmpeg_command_for_tests() else { diff --git a/tests/operations/admin.rs b/tests/operations/admin.rs index 61e72d0b0..f0a43c605 100644 --- a/tests/operations/admin.rs +++ b/tests/operations/admin.rs @@ -4018,6 +4018,100 @@ async fn test_admin_config() { assert_eq!(resp.status(), 200); } +#[actix_web::test] +async fn test_admin_media_processing_status_explains_persisted_cli_availability() { + let state = common::setup().await; + let available_command = std::env::current_exe() + .expect("current test executable should exist") + .to_string_lossy() + .into_owned(); + state.runtime_config.apply(common::system_config_model( + "media_processing_registry_json", + &serde_json::json!({ + "version": 2, + "processors": [ + { + "kind": "vips_cli", + "enabled": true, + "uses": ["thumbnail:image"], + "extensions": ["heic"], + "config": { "command": "/definitely-missing/aster-vips" } + }, + { + "kind": "ffmpeg_cli", + "enabled": true, + "uses": ["thumbnail:video"], + "extensions": ["mp4"], + "config": { "command": available_command } + }, + { + "kind": "ffprobe_cli", + "enabled": false, + "uses": ["metadata:video"], + "extensions": ["mp4"], + "config": { "command": "/definitely-missing/aster-ffprobe" } + }, + { + "kind": "lofty", + "enabled": true, + "uses": ["thumbnail:audio", "metadata:audio"] + }, + { + "kind": "images", + "enabled": true, + "uses": ["thumbnail:image", "metadata:image"] + } + ] + }) + .to_string(), + )); + let app = create_test_app!(state); + let (token, _) = register_and_login!(app); + + let req = + admin_get_request(&token, "/api/v1/admin/config/media-processing-status").to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["data"]["version"], 1); + let processors = body["data"]["processors"] + .as_array() + .expect("processor status list should be present"); + let find = |kind: &str| { + processors + .iter() + .find(|processor| processor["kind"] == kind) + .unwrap_or_else(|| panic!("missing processor status for {kind}")) + }; + + let vips = find("vips_cli"); + assert_eq!(vips["configured_enabled"], true); + assert_eq!(vips["runtime_available"], false); + assert_eq!(vips["effective_enabled"], false); + assert_eq!(vips["unavailable_reason"], "command_not_found"); + + let ffmpeg = find("ffmpeg_cli"); + assert_eq!(ffmpeg["configured_enabled"], true); + assert_eq!(ffmpeg["runtime_available"], true); + assert_eq!(ffmpeg["effective_enabled"], true); + assert!(ffmpeg.get("unavailable_reason").is_none()); + + let ffprobe = find("ffprobe_cli"); + assert_eq!(ffprobe["configured_enabled"], false); + assert_eq!(ffprobe["runtime_available"], false); + assert_eq!(ffprobe["effective_enabled"], false); + assert!(ffprobe.get("unavailable_reason").is_none()); + + assert!( + !body.to_string().contains("definitely-missing"), + "status response must not expose configured command paths" + ); + assert!( + !body.to_string().contains(&available_command), + "status response must not expose configured command paths" + ); +} + #[actix_web::test] async fn admin_config_rejects_disabling_last_builtin_login_method_without_external_provider() { let state = common::setup().await; diff --git a/tests/sharing/public_media_data_support.rs b/tests/sharing/public_media_data_support.rs index 938896ed6..96a6ccae9 100644 --- a/tests/sharing/public_media_data_support.rs +++ b/tests/sharing/public_media_data_support.rs @@ -139,6 +139,60 @@ async fn test_public_media_data_support_exposes_enabled_ffprobe_extensions() { ); } +#[actix_web::test] +async fn test_heic_metadata_support_is_independent_from_effective_thumbnail_support() { + let state = common::setup().await; + state.runtime_config.apply(common::system_config_model( + "media_processing_registry_json", + &json!({ + "version": 2, + "processors": [ + { + "kind": "vips_cli", + "enabled": true, + "uses": ["thumbnail:image"], + "extensions": ["heic"], + "config": { "command": "/definitely-missing/aster-vips" } + }, + { + "kind": "images", + "enabled": true, + "uses": ["thumbnail:image", "metadata:image"] + }, + { + "kind": "lofty", + "enabled": true, + "uses": ["thumbnail:audio", "metadata:audio"] + } + ] + }) + .to_string(), + )); + let app = create_test_app!(state); + + let media_req = test::TestRequest::get() + .uri("/api/v1/public/media-data-support") + .to_request(); + let media_resp = test::call_service(&app, media_req).await; + assert_eq!(media_resp.status(), 200); + let media_body: Value = test::read_body_json(media_resp).await; + let metadata_extensions = media_body["data"]["kinds"]["image"]["extensions"] + .as_array() + .expect("image metadata extensions should be present"); + assert!(metadata_extensions.iter().any(|value| value == "heic")); + + let thumbnail_req = test::TestRequest::get() + .uri("/api/v1/public/thumbnail-support") + .to_request(); + let thumbnail_resp = test::call_service(&app, thumbnail_req).await; + assert_eq!(thumbnail_resp.status(), 200); + let thumbnail_body: Value = test::read_body_json(thumbnail_resp).await; + let thumbnail_extensions = thumbnail_body["data"]["image_thumbnail"]["extensions"] + .as_array() + .expect("image thumbnail extensions should be present"); + assert!(!thumbnail_extensions.iter().any(|value| value == "heic")); +} + #[actix_web::test] async fn test_public_media_data_support_cache_is_invalidated_after_config_update() { let state = common::setup().await; diff --git a/tests/sharing/public_thumbnail_support.rs b/tests/sharing/public_thumbnail_support.rs index befb9b91a..23ae8bd4b 100644 --- a/tests/sharing/public_thumbnail_support.rs +++ b/tests/sharing/public_thumbnail_support.rs @@ -168,6 +168,47 @@ async fn test_public_thumbnail_support_merges_builtin_and_enabled_vips_extension ); } +#[actix_web::test] +async fn test_public_thumbnail_support_omits_configured_extensions_when_cli_is_unavailable() { + let state = common::setup().await; + state.runtime_config.apply(common::system_config_model( + "media_processing_registry_json", + &json!({ + "version": 2, + "processors": [ + { + "kind": "vips_cli", + "enabled": true, + "uses": ["thumbnail:image"], + "extensions": ["heic", "heif"], + "config": { "command": "/definitely-missing/aster-vips" } + }, + { + "kind": "images", + "enabled": true, + "uses": ["thumbnail:image", "metadata:image"] + } + ] + }) + .to_string(), + )); + let app = create_test_app!(state); + + let req = test::TestRequest::get() + .uri("/api/v1/public/thumbnail-support") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let body: Value = test::read_body_json(resp).await; + let extensions = body["data"]["image_thumbnail"]["extensions"] + .as_array() + .expect("effective image thumbnail extensions should be present"); + + assert!(extensions.iter().any(|value| value == "png")); + assert!(!extensions.iter().any(|value| value == "heic")); + assert!(!extensions.iter().any(|value| value == "heif")); +} + #[actix_web::test] async fn test_public_thumbnail_support_backfills_old_lofty_uses() { let state = common::setup().await;