From 3c0404b4dace7b6a892fe6bda6570bda2f35234c Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Thu, 20 Aug 2026 22:01:34 +0800 Subject: [PATCH 1/5] fix(media): reconcile slim runtime capability and derivative caches --- .github/workflows/docker-image.yml | 23 +-- developer-docs/en/api/admin.md | 3 + developer-docs/en/api/public.md | 3 +- developer-docs/zh-CN/api/admin.md | 3 + developer-docs/zh-CN/api/public.md | 2 + docs/src/content/docs/deploy/docker.md | 2 +- docs/src/content/docs/en/deploy/docker.md | 2 +- .../MediaProcessingConfigEditor.test.tsx | 65 +++++++ .../admin/MediaProcessingConfigEditor.tsx | 59 +++++- .../locales/en/admin/media-processing.json | 3 + .../locales/zh/admin/media-processing.json | 3 + .../src/services/adminService.test.ts | 8 + frontend-panel/src/services/adminService.ts | 6 + frontend-panel/src/services/api.generated.ts | 73 ++++++++ frontend-panel/src/types/api.ts | 6 + src/api/openapi.rs | 4 + src/api/routes/admin/config.rs | 19 ++ src/api/routes/admin/mod.rs | 6 +- src/config/media_processing/mod.rs | 17 +- src/config/media_processing/registry.rs | 58 +++++- src/config/media_processing/tests.rs | 115 ++++++++++-- src/config/media_processing/types.rs | 26 +++ src/db/repository/config_repo.rs | 69 +++++++ src/services/media/processing/resolve.rs | 78 +++++++- .../media/processing/thumbnail/mod.rs | 13 +- .../media/processing/thumbnail/preview.rs | 27 +++ src/services/ops/config/mod.rs | 4 +- src/services/ops/config/system.rs | 6 + tests/files/thumbnail.rs | 172 ++++++++++++++++++ tests/operations/admin.rs | 90 +++++++++ tests/sharing/public_media_data_support.rs | 54 ++++++ tests/sharing/public_thumbnail_support.rs | 41 +++++ 32 files changed, 1011 insertions(+), 49 deletions(-) create mode 100644 frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 5631644ee..1a9622ea8 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 }} diff --git a/developer-docs/en/api/admin.md b/developer-docs/en/api/admin.md index 7c482f034..529894ccf 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 | @@ -604,6 +605,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 5d0de53d4..b8e513d33 100644 --- a/developer-docs/en/api/public.md +++ b/developer-docs/en/api/public.md @@ -162,6 +162,7 @@ Notes: - 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 @@ -198,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 d34330478..c37603724 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}` | 删除配置项 | @@ -896,6 +897,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 98d17e5e9..717e23ca1 100644 --- a/developer-docs/zh-CN/api/public.md +++ b/developer-docs/zh-CN/api/public.md @@ -164,6 +164,7 @@ - 内置图片处理器启用时会暴露常见图片格式 - 内置 `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 策略不暴露 @@ -205,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..dac725f83 --- /dev/null +++ b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { 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", () => { + 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); + }); +}); diff --git a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx index 1c02130e3..05b5927d0 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,7 @@ 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 +321,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 +347,15 @@ export function MediaProcessingConfigEditor({
+ {supportsCommand && + processor.enabled && + processorRuntimeStatus?.runtime_available === false ? ( +
+ {t( + "media_processing_editor_processor_runtime_unavailable_hint", + )} +
+ ) : null}
{ 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..5aed7d885 100644 --- a/frontend-panel/src/services/adminService.ts +++ b/frontend-panel/src/services/adminService.ts @@ -57,6 +57,7 @@ import type { FolderInfo, LockPage, MigratePolicyGroupAssignmentsRequest, + MediaProcessingRuntimeStatus, PolicyGroupAssignmentMigrationResult, PromoteStoragePolicyConnectorRequest, RemoteCreateStorageTargetRequest, @@ -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 80adda448..f7e4edd36 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; @@ -6103,6 +6119,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} @@ -9481,6 +9511,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 5ea596d78..b7f11cc96 100644 --- a/src/services/ops/config/system.rs +++ b/src/services/ops/config/system.rs @@ -84,6 +84,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 c40ddf8b6..1f5c61fc1 100644 --- a/tests/operations/admin.rs +++ b/tests/operations/admin.rs @@ -3982,6 +3982,96 @@ 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" + ); +} + #[actix_web::test] async fn test_admin_config_validates_media_derivative_dimensions() { 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; From af5f843ae25d416e95166ecfee27961b21abea51 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 00:33:12 +0800 Subject: [PATCH 2/5] fix(frontend): satisfy biome checks for media status editor --- .../components/admin/MediaProcessingConfigEditor.test.tsx | 8 +++----- .../src/components/admin/MediaProcessingConfigEditor.tsx | 4 +++- frontend-panel/src/services/adminService.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx index dac725f83..ee653df19 100644 --- a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx +++ b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx @@ -44,9 +44,7 @@ describe("MediaProcessingConfigEditor", () => { ], }); - render( - , - ); + render(); await waitFor(() => { expect( @@ -57,8 +55,8 @@ describe("MediaProcessingConfigEditor", () => { }); expect( screen.getByText( - "media_processing_editor_processor_runtime_unavailable_hint", - ), + "media_processing_editor_processor_runtime_unavailable_hint", + ), ).toBeInTheDocument(); expect(mockStatus).toHaveBeenCalledTimes(1); }); diff --git a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx index 05b5927d0..b2dbd7557 100644 --- a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx +++ b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.tsx @@ -287,7 +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 processorRuntimeStatus = runtimeStatusByKind.get( + processor.kind, + ); const canTestCommand = (processor.kind === "vips_cli" && onTestVipsCliCommand) || (processor.kind === "ffmpeg_cli" && onTestFfmpegCliCommand) || diff --git a/frontend-panel/src/services/adminService.ts b/frontend-panel/src/services/adminService.ts index 5aed7d885..0d6740a43 100644 --- a/frontend-panel/src/services/adminService.ts +++ b/frontend-panel/src/services/adminService.ts @@ -56,8 +56,8 @@ import type { ExternalAuthProviderTestResult, FolderInfo, LockPage, - MigratePolicyGroupAssignmentsRequest, MediaProcessingRuntimeStatus, + MigratePolicyGroupAssignmentsRequest, PolicyGroupAssignmentMigrationResult, PromoteStoragePolicyConnectorRequest, RemoteCreateStorageTargetRequest, From 17c139575743e94135d5d366bab3a54ac62a7cc8 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 00:39:49 +0800 Subject: [PATCH 3/5] fix(review): address media PR feedback --- .github/workflows/docker-image.yml | 70 ++++++++++++++++++++++++++++-- developer-docs/en/api/public.md | 4 +- developer-docs/zh-CN/api/public.md | 4 +- tests/operations/admin.rs | 4 ++ 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 1a9622ea8..43e0b8753 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -17,8 +17,8 @@ env: TEMP_IMAGE_TAG: build-${{ github.run_id }} jobs: - build: - name: Build ${{ matrix.variant }} ${{ matrix.platform }} + build-slim: + name: Build slim ${{ matrix.variant }} ${{ matrix.platform }} runs-on: ${{ matrix.runner }} if: github.event_name != 'pull_request' strategy: @@ -104,6 +104,70 @@ jobs: provenance: true sbom: true + # This job depends on the complete slim matrix, so no full image can be + # pushed until every slim architecture and variant has finished pushing. + build-full: + name: Build full ${{ matrix.variant }} ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + needs: build-slim + if: github.event_name != 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - variant: default + features: server,cli + platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - variant: default + features: server,cli + platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + - variant: metrics + features: server,cli,metrics + platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - variant: metrics + features: server,cli,metrics + platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - 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: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY_IMAGE_GHCR }} + ${{ env.REGISTRY_IMAGE_DOCKERHUB }} + labels: | + org.opencontainers.image.title=AsterDrive + org.opencontainers.image.description=Self-hosted cloud storage system built with Rust + org.opencontainers.image.vendor=AsterCommunity + - name: Build and push full Docker image id: build-push-full uses: docker/build-push-action@v7 @@ -128,7 +192,7 @@ jobs: publish-manifest: name: Publish ${{ matrix.variant }} multi-arch manifest runs-on: ubuntu-latest - needs: build + needs: build-full if: github.event_name != 'pull_request' strategy: fail-fast: false diff --git a/developer-docs/en/api/public.md b/developer-docs/en/api/public.md index eaea92de4..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,7 +159,6 @@ 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 diff --git a/developer-docs/zh-CN/api/public.md b/developer-docs/zh-CN/api/public.md index 526cefdee..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,7 +161,6 @@ - `extensions` 已经做过规范化,统一是不带点的小写扩展名 - `image_preview`、`image_thumbnail`、`audio_thumbnail`、`video_thumbnail` 是当前按用途拆分的能力字段 -- 顶层 `extensions` 是给旧客户端保留的兼容并集字段 - 内置图片处理器启用时会暴露常见图片格式 - 内置 `lofty` 处理器启用 `thumbnail:audio` 时会暴露音频后缀,前端可通过同一条 thumbnail 接口请求音频内嵌封面 - `vips_cli` / `ffmpeg_cli` 只有在对应命令可用且处理器启用时,才会把配置里的扩展名暴露出去;因此它可能包含图片以外的文档或视频扩展名 diff --git a/tests/operations/admin.rs b/tests/operations/admin.rs index 28955f102..f0a43c605 100644 --- a/tests/operations/admin.rs +++ b/tests/operations/admin.rs @@ -4106,6 +4106,10 @@ async fn test_admin_media_processing_status_explains_persisted_cli_availability( !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] From b7f71191171ea9e38c14209ce777a55e41205876 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 00:45:33 +0800 Subject: [PATCH 4/5] docs(release): document slim media capability fix --- .github/workflows/docker-image.yml | 180 +++++++++++++++++++---------- CHANGELOG.md | 4 + 2 files changed, 120 insertions(+), 64 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 43e0b8753..6c4c04f16 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -17,8 +17,8 @@ env: TEMP_IMAGE_TAG: build-${{ github.run_id }} jobs: - build-slim: - name: Build slim ${{ matrix.variant }} ${{ matrix.platform }} + build: + name: Build ${{ matrix.variant }} ${{ matrix.platform }} runs-on: ${{ matrix.runner }} if: github.event_name != 'pull_request' strategy: @@ -104,42 +104,42 @@ jobs: provenance: true sbom: true - # This job depends on the complete slim matrix, so no full image can be - # pushed until every slim architecture and variant has finished pushing. - build-full: - name: Build full ${{ matrix.variant }} ${{ matrix.platform }} - runs-on: ${{ matrix.runner }} - needs: build-slim + - name: Build and push full Docker image + id: build-push-full + uses: docker/build-push-action@v7 + with: + context: . + target: runtime-full + 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 }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: | + type=gha,scope=docker-image-${{ matrix.variant }}-${{ matrix.arch }} + type=registry,ref=${{ env.REGISTRY_IMAGE_GHCR }}:buildcache-${{ matrix.variant }}-${{ matrix.arch }} + build-args: | + CARGO_FEATURES=${{ matrix.features }} + ASTER_BUILD_REVISION=${{ github.sha }} + provenance: true + sbom: true + + publish-manifest-slim: + name: Publish ${{ matrix.variant }} multi-arch manifest + runs-on: ubuntu-latest + needs: build if: github.event_name != 'pull_request' strategy: fail-fast: false matrix: include: - - variant: default - features: server,cli - platform: linux/amd64 - arch: amd64 - runner: ubuntu-latest - - variant: default - features: server,cli - platform: linux/arm64 - arch: arm64 - runner: ubuntu-24.04-arm - - variant: metrics - features: server,cli,metrics - platform: linux/amd64 - arch: amd64 - runner: ubuntu-latest - - variant: metrics - features: server,cli,metrics - platform: linux/arm64 - arch: arm64 - runner: ubuntu-24.04-arm + - variant: default-slim + suffix: "-slim" + - variant: metrics-slim + suffix: "-metrics-slim" steps: - - name: Checkout code - uses: actions/checkout@v6 - - name: Login to Docker Hub uses: docker/login-action@v4 with: @@ -156,43 +156,99 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Extract metadata for Docker - id: meta + - 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 }} - ${{ env.REGISTRY_IMAGE_DOCKERHUB }} - labels: | - org.opencontainers.image.title=AsterDrive - org.opencontainers.image.description=Self-hosted cloud storage system built with Rust - org.opencontainers.image.vendor=AsterCommunity + 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: Build and push full Docker image - id: build-push-full - uses: docker/build-push-action@v7 + - name: Extract Docker Hub metadata + id: meta-dockerhub + uses: docker/metadata-action@v6 with: - context: . - target: runtime-full - platforms: ${{ matrix.platform }} - push: true + images: ${{ env.REGISTRY_IMAGE_DOCKERHUB }} 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 }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: | - type=gha,scope=docker-image-${{ matrix.variant }}-${{ matrix.arch }} - type=registry,ref=${{ env.REGISTRY_IMAGE_GHCR }}:buildcache-${{ matrix.variant }}-${{ matrix.arch }} - build-args: | - CARGO_FEATURES=${{ matrix.features }} - ASTER_BUILD_REVISION=${{ github.sha }} - provenance: true - sbom: true + 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 - publish-manifest: + 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}" + + publish-manifest-full: name: Publish ${{ matrix.variant }} multi-arch manifest runs-on: ubuntu-latest - needs: build-full + needs: publish-manifest-slim if: github.event_name != 'pull_request' strategy: fail-fast: false @@ -200,12 +256,8 @@ jobs: include: - variant: default suffix: "" - - variant: default-slim - suffix: "-slim" - variant: metrics suffix: "-metrics" - - variant: metrics-slim - suffix: "-metrics-slim" steps: - name: Login to Docker Hub 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 From 015241bbe48ee760e3d0e22e8415d2989fac0a83 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 01:01:48 +0800 Subject: [PATCH 5/5] test(frontend): cover media processor runtime status --- .../MediaProcessingConfigEditor.test.tsx | 61 ++++++++++++++++++- .../pages/admin/AdminSettingsPage.test.tsx | 8 +++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx index ee653df19..5ee4b2b5e 100644 --- a/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx +++ b/frontend-panel/src/components/admin/MediaProcessingConfigEditor.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MediaProcessingConfigEditor } from "@/components/admin/MediaProcessingConfigEditor"; const mockStatus = vi.hoisted(() => vi.fn()); @@ -30,6 +30,10 @@ const config = JSON.stringify({ }); describe("MediaProcessingConfigEditor", () => { + beforeEach(() => { + mockStatus.mockReset(); + }); + it("explains a saved enabled processor that is missing from the runtime", async () => { mockStatus.mockResolvedValue({ version: 1, @@ -60,4 +64,59 @@ describe("MediaProcessingConfigEditor", () => { ).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/pages/admin/AdminSettingsPage.test.tsx b/frontend-panel/src/pages/admin/AdminSettingsPage.test.tsx index b04903ac0..c54459e64 100644 --- a/frontend-panel/src/pages/admin/AdminSettingsPage.test.tsx +++ b/frontend-panel/src/pages/admin/AdminSettingsPage.test.tsx @@ -25,6 +25,7 @@ const mockState = vi.hoisted(() => ({ 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(), ]);