+ {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