Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/04-architecture-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Last updated: 2026-03-02
- 音乐模式可视化已升级为双运行时架构:全局 `musicVisualizerRuntimeMode` 控制 `legacy | plugin`,并保留自动回退到 legacy 的安全阀。
- Shader 参数入口已收口:音乐主区仅保留“打开设置”快捷入口,实际模式切换/输入映射/预览统一在设置面板 `Shader` 分页完成。
- 缩略图变体链路已落地:`resolveMediaResource` 支持 `original/thumbnail` 变体,thumbnail 由 Main 使用 Sharp 生成 WebP 并落盘缓存。
- 图片读链路已结构性分页:`readImageSidebarTree` 响应仅携带每源 `image_count` + 封面 locator(不再携带全库 `images[]`);渲染进程通过 `readSourceImages` 按访问到的源懒加载并在会话内缓存(`useSourceImageCache`),使单文件夹浏览代价与入库总量解耦。向量检索与 AdReview 等跨源视图会按需预加载其涉及的源。
- 运行时依赖预检已落地:Main 暴露依赖可用性与降级策略矩阵(`sharp/ffmpeg/ffprobe/archive-wasm/powershell`),Renderer 在降级时显示告警。
- `rar/7z` 归一化调度采用“双优先级队列”:默认低优先级(交互空闲后按路径排序执行),用户显式打开目标包时提升为高优先级后台处理。
- Main 通过 `libraryChanged + archiveLoadStatus` 向 Renderer 推送/暴露归一化进度状态,UI 可在不阻塞交互的前提下显示 pending/running。
Expand Down
8 changes: 7 additions & 1 deletion docs/06-backend-integration-guardrails.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 后端接入规避方案(强制执行)

Last updated: 2026-02-18
Last updated: 2026-05-30

## 适用范围

Expand Down Expand Up @@ -58,6 +58,12 @@ Last updated: 2026-02-18
- `App` 入口链路(`src/App.tsx`、`useAppController`、`useAppDataPipeline`)仅保留编排,不承载业务细节。
- 同一文件若同时出现跨层职责并持续膨胀,必须先拆分再继续叠加需求。

12. 图片读链路结构性分页(强制)
- `readImageSidebarTree` 响应禁止携带全库 `images[]`:侧边栏源仅含 `image_count` 与封面 `cover_media_locator`(对应 `imageSourceSidebarDtoSchema`)。
- 渲染进程通过 `readSourceImages(source_id)` 按“访问到的源”懒加载图片并在会话内缓存(`useSourceImageCache`),禁止恢复“整库 images 常驻渲染进程”的旧模型。
- 任何同时展示多源缩略图的视图(向量检索结果、AdReview 聚合结果等)必须把涉及的源 id 纳入按需加载集合(`useAppSidebarScopeState` 的 `neededSourceIds`),并把其有效 image id 纳入 `validImageIdSet`,否则会出现缩略图空白与管理选择被误剪(默认全选被清空)。
- 计数/枚举(页数、导航边界、ref 枚举)必须使用 `resolveSourceImageCount`(= `imageCount ?? images.length`),不得依赖已加载的 `images.length`。

## 新增能力实施顺序(建议按序)

1. 固化接口
Expand Down
1 change: 1 addition & 0 deletions electron/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const BACKEND_CHANNELS = {
readLibrarySnapshotLite: "backend:readLibrarySnapshotLite",
readImageSidebarTree: "backend:readImageSidebarTree",
readImagePage: "backend:readImagePage",
readSourceImages: "backend:readSourceImages",
readImageMetadata: "backend:readImageMetadata",
resolveMediaResource: "backend:resolveMediaResource",
updatePerformanceConfig: "backend:updatePerformanceConfig",
Expand Down
25 changes: 20 additions & 5 deletions electron/facade/FileSystemLibraryHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
type ReadImageMetadataResponseDto,
type ReadImagePageRequestDto,
type ReadImagePageResponseDto,
type ReadSourceImagesRequestDto,
type ReadSourceImagesResponseDto,
type ReadImageSidebarTreeRequestDto,
type ReadImageSidebarTreeResponseDto,
type ReadPlaylistResponseDto,
Expand Down Expand Up @@ -70,13 +72,22 @@ export class FileSystemLibraryHandlers {

async readLibrarySnapshotLite(): Promise<LibrarySnapshotLiteDto> {
this.context.markInteractiveRead()
const snapshot = await this.context.ensureSnapshotLoaded()
const dbResult = this.context.database.readSnapshotLite()
// DB 为空(扫描期间 replaceSnapshot 未完成)但缓存有数据时,从缓存构造 lite DTO
const dbHasContent =
dbResult.image_packages.length > 0 ||
dbResult.image_directories.length > 0 ||
dbResult.videos.length > 0 ||
(dbResult.audios?.length ?? 0) > 0
// DB 有数据时直接返回,避免触发整库 readSnapshot(含所有图片项)进内存。
if (dbHasContent) {
return dbResult
}
// DB 全空(首次启动或扫描尚未写入):触发快照加载/warmup,
// 并在缓存(含扫描预览)有图片时回退构造 lite,避免前端拿到空 sidebar。
const snapshot = await this.context.ensureSnapshotLoaded()
if (
dbResult.image_packages.length === 0 &&
dbResult.image_directories.length === 0 &&
(snapshot.image_packages.length > 0 || snapshot.image_directories.length > 0)
snapshot.image_packages.length > 0 ||
snapshot.image_directories.length > 0
) {
return buildLiteDtoFromSnapshot(snapshot)
}
Expand All @@ -94,6 +105,10 @@ export class FileSystemLibraryHandlers {
return this.context.libraryReadWriteService.readImagePage(request, signal)
}

async readSourceImages(request: ReadSourceImagesRequestDto): Promise<ReadSourceImagesResponseDto> {
return this.context.libraryReadWriteService.readSourceImages(request)
}

async readImageMetadata(
request: ReadImageMetadataRequestDto,
): Promise<ReadImageMetadataResponseDto> {
Expand Down
8 changes: 8 additions & 0 deletions electron/fileSystemReadFacade.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
type ReadArchiveLoadStatusResponseDto,
type ReadImagePageRequestDto,
type ReadImagePageResponseDto,
type ReadSourceImagesRequestDto,
type ReadSourceImagesResponseDto,
type ReadImageSidebarTreeRequestDto,
type ReadImageSidebarTreeResponseDto,
type ResolveMediaResourceRequestDto,
Expand Down Expand Up @@ -1314,6 +1316,12 @@ export class FileSystemMediaReadService {
return this.libraryHandlers.readImageMetadata(request);
}

async readSourceImages(
request: ReadSourceImagesRequestDto,
): Promise<ReadSourceImagesResponseDto> {
return this.libraryHandlers.readSourceImages(request);
}

async writePackageGrade(
request: WritePackageGradeRequestDto,
): Promise<WritePackageGradeResponseDto> {
Expand Down
5 changes: 4 additions & 1 deletion electron/fileSystemReadService.impl.management-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ describe("FileSystemMediaReadService", () => {
if (!source) {
throw new Error("source not found");
}
const firstImage = source.images[0];
const sourceImages = await service.readSourceImages({
source_id: source.id,
});
const firstImage = sourceImages.images[0];
expect(firstImage?.media_locator.kind).toBe("filesystem");
if (!firstImage || firstImage.media_locator.kind !== "filesystem") {
throw new Error("image locator not found");
Expand Down
4 changes: 2 additions & 2 deletions electron/fileSystemReadService.impl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@
expect(serializedTree).toContain("かな!@#");

const sourceId =
sidebar.image_packages.find((item) => item.images.length > 0)?.id ??
sidebar.image_directories.find((item) => item.images.length > 0)?.id;
sidebar.image_packages.find((item) => item.image_count > 0)?.id ??
sidebar.image_directories.find((item) => item.image_count > 0)?.id;
expect(sourceId).toBeTruthy();

const page = await service.readImagePage({
Expand Down Expand Up @@ -1009,7 +1009,7 @@
),
{ timeout: 5_000, interval: 100 },
)
.toBe(true);

Check failure on line 1012 in electron/fileSystemReadService.impl.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

electron/fileSystemReadService.test.ts > FileSystemMediaReadService > 外部删除导入源后会通过 watcher 自动清理并广播变更

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ electron/fileSystemReadService.impl.test.ts:1012:8 Caused by: Error: Matcher did not succeed in time. ❯ electron/fileSystemReadService.impl.test.ts:1004:5

Check failure on line 1012 in electron/fileSystemReadService.impl.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

electron/fileSystemReadService.impl.test.ts > FileSystemMediaReadService > 外部删除导入源后会通过 watcher 自动清理并广播变更

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ electron/fileSystemReadService.impl.test.ts:1012:8 Caused by: Caused by: Error: Matcher did not succeed in time. ❯ electron/fileSystemReadService.impl.test.ts:1004:5

const after = await service.readLibrarySnapshot();
unsubscribe();
Expand Down
18 changes: 3 additions & 15 deletions electron/mediaLibrarySnapshotStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,8 @@ export class MediaLibrarySnapshotStore {
videos,
audios,
};
const totalImageCount =
imagePackages.reduce((sum, item) => sum + item.images.length, 0) +
imageDirectories.reduce((sum, item) => sum + item.images.length, 0);
const shouldSkipDeepClone = totalImageCount >= 20_000;
if (!shouldSkipDeepClone) {
return librarySnapshotDtoSchema.parse(snapshot);
}
// 校验但不深拷贝:mappers 已填好各字段默认值,原对象形状完整,
// 直接返回原对象可避免整库对象图的 Zod clone 放大堆压力。
const validated = librarySnapshotDtoSchema.safeParse(snapshot);
if (!validated.success) {
throw validated.error;
Expand Down Expand Up @@ -108,14 +103,7 @@ export class MediaLibrarySnapshotStore {
videos,
audios,
};
const totalItems =
imagePackages.length +
imageDirectories.length +
videos.length +
audios.length;
if (totalItems < 5_000) {
return librarySnapshotLiteDtoSchema.parse(snapshot);
}
// 校验但不深拷贝,避免整库对象图的 Zod clone 放大堆压力。
const validated = librarySnapshotLiteDtoSchema.safeParse(snapshot);
if (!validated.success) {
throw validated.error;
Expand Down
10 changes: 10 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ import {
readImageMetadataResponseSchema,
readImagePageRequestSchema,
readImagePageResponseSchema,
readSourceImagesRequestSchema,
readSourceImagesResponseSchema,
readImageSidebarTreeRequestSchema,
readImageSidebarTreeResponseSchema,
resolveMediaResourceRequestSchema,
Expand Down Expand Up @@ -217,6 +219,14 @@ const backendApi = {
);
return readImagePageResponseSchema.parse(response);
},
readSourceImages: async (request: unknown) => {
const parsed = readSourceImagesRequestSchema.parse(request);
const response = await ipcRenderer.invoke(
BACKEND_CHANNELS.readSourceImages,
parsed,
);
return readSourceImagesResponseSchema.parse(response);
},
readImageMetadata: async (request: unknown) => {
const parsed = readImageMetadataRequestSchema.parse(request);
const response = await ipcRenderer.invoke(
Expand Down
18 changes: 17 additions & 1 deletion electron/registerBackendIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ import {
readImageMetadataResponseSchema,
readImagePageRequestSchema,
readImagePageResponseSchema,
readSourceImagesRequestSchema,
readSourceImagesResponseSchema,
readImageSidebarTreeRequestSchema,
readImageSidebarTreeResponseSchema,
setImageHiddenRequestSchema,
Expand Down Expand Up @@ -469,7 +471,10 @@ export function registerBackendIpcHandlers(): void {
requestSchema: ParseSchema<TRequest>,
responseSchema: ParseSchema<TResponse>,
action: (request: TRequest) => Promise<unknown> | unknown,
options?: { fallbackEmptyPayloadToObject?: boolean },
options?: {
fallbackEmptyPayloadToObject?: boolean;
skipResponseSchemaParse?: boolean;
},
): void => {
ipcMain.handle(channel, async (_event, payload: unknown) => {
const normalizedPayload =
Expand All @@ -479,6 +484,9 @@ export function registerBackendIpcHandlers(): void {
: payload;
const request = requestSchema.parse(normalizedPayload);
const response = await action(request);
if (options?.skipResponseSchemaParse) {
return response as TResponse;
}
return responseSchema.parse(response);
});
};
Expand Down Expand Up @@ -519,6 +527,7 @@ export function registerBackendIpcHandlers(): void {
readImageSidebarTreeRequestSchema,
readImageSidebarTreeResponseSchema,
(request) => ensureService().readImageSidebarTree(request),
{ skipResponseSchemaParse: true },
);

registerIpcCommand(
Expand All @@ -528,6 +537,13 @@ export function registerBackendIpcHandlers(): void {
(request) => ensureService().readImagePage(request),
);

registerIpcCommand(
BACKEND_CHANNELS.readSourceImages,
readSourceImagesRequestSchema,
readSourceImagesResponseSchema,
(request) => ensureService().readSourceImages(request),
);

registerIpcCommand(
BACKEND_CHANNELS.readImageMetadata,
readImageMetadataRequestSchema,
Expand Down
43 changes: 38 additions & 5 deletions electron/services/file-system-read/libraryReadWriteServiceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import {
readAppStateResponseSchema,
readImageMetadataResponseSchema,
readImagePageResponseSchema,
readImageSidebarTreeResponseSchema,
readPlaylistResponseSchema,
saveVideoCoverResponseSchema,
writeAppStateResponseSchema,
writePackageExternalMetadataResponseSchema,
writePackageGradeResponseSchema,
writePlaylistResponseSchema,
type LibrarySnapshotDto,
type ImagePackageDto,
type ImageSourceSidebarDto,
type ListVideoSubtitlesRequestDto,
type ListVideoSubtitlesResponseDto,
type MediaLocatorDto,
Expand All @@ -28,6 +29,8 @@ import {
type ReadImageMetadataResponseDto,
type ReadImagePageRequestDto,
type ReadImagePageResponseDto,
type ReadSourceImagesRequestDto,
type ReadSourceImagesResponseDto,
type ReadImageSidebarTreeRequestDto,
type ReadImageSidebarTreeResponseDto,
type ReadManageSubtitleCleanupTaskRequestDto,
Expand Down Expand Up @@ -203,6 +206,16 @@ function mergePreferenceMetricsState(
};
}

function toSidebarSource(source: ImagePackageDto): ImageSourceSidebarDto {
const { images, ...rest } = source;
const cover = images[0] ?? null;
return {
...rest,
image_count: images.length,
cover_media_locator: cover ? cover.media_locator : null,
};
}

export class LibraryReadWriteService {
private readonly subtitleCleanupTaskService: LibrarySubtitleCleanupTaskService;

Expand Down Expand Up @@ -275,11 +288,11 @@ export class LibraryReadWriteService {
);
ensureNotAborted(signal);

return readImageSidebarTreeResponseSchema.parse({
image_packages: filteredPackages,
image_directories: filteredDirectories,
return {
image_packages: filteredPackages.map(toSidebarSource),
image_directories: filteredDirectories.map(toSidebarSource),
tree: buildImageSidebarTree(filteredPackages, filteredDirectories),
});
};
}

async readImagePage(
Expand Down Expand Up @@ -419,6 +432,26 @@ export class LibraryReadWriteService {
);
}

async readSourceImages(
request: ReadSourceImagesRequestDto,
): Promise<ReadSourceImagesResponseDto> {
this.options.markInteractiveRead();
const includeHidden = request.include_hidden ?? false;
const snapshot = await this.options.ensureSnapshotLoaded();
const allSources = [
...snapshot.image_packages,
...snapshot.image_directories,
];
const source = allSources.find((item) => item.id === request.source_id);
const visibleSource = source
? filterHiddenImagesFromSource(source, includeHidden)
: null;
return {
source_id: request.source_id,
images: visibleSource ? visibleSource.images : [],
};
}

async writePackageGrade(
request: WritePackageGradeRequestDto,
): Promise<WritePackageGradeResponseDto> {
Expand Down
5 changes: 5 additions & 0 deletions src/backend-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import type {
ReadImageMetadataResponseDto,
ReadImagePageRequestDto,
ReadImagePageResponseDto,
ReadSourceImagesRequestDto,
ReadSourceImagesResponseDto,
ReadImageSidebarTreeRequestDto,
ReadImageSidebarTreeResponseDto,
MediaAccessAuditResponseDto,
Expand Down Expand Up @@ -190,6 +192,9 @@ interface MediaPlayerBackendApi {
readImagePage: (
request: ReadImagePageRequestDto,
) => Promise<ReadImagePageResponseDto>;
readSourceImages?: (
request: ReadSourceImagesRequestDto,
) => Promise<ReadSourceImagesResponseDto>;
readImageMetadata: (
request: ReadImageMetadataRequestDto,
) => Promise<ReadImageMetadataResponseDto>;
Expand Down
5 changes: 3 additions & 2 deletions src/components/ImageMainSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { ImageMainSectionProps } from "./ImageMainSection.types";
import { useManageImageSelectionInteractions } from "../features/management/useManageImageSelectionInteractions";
import { useI18n } from "../i18n/useI18n";
import type { FocusedImageRef } from "../types";
import { resolveSourceImageCount } from "../utils/mediaHelpers";
import {
type ThumbnailGridSession,
buildThumbnailGridSession,
Expand Down Expand Up @@ -765,11 +766,11 @@ function ImageMainSection({
}, [toggleImageConvertPanel]);

const activePackageImageProgress = (() => {
if (!activePackage || activePackage.images.length === 0) {
if (!activePackage || resolveSourceImageCount(activePackage) === 0) {
return null;
}

const total = activePackage.images.length;
const total = resolveSourceImageCount(activePackage);

if (focusedRef?.packageId === activePackage.id) {
const current = Math.max(1, Math.min(total, focusedRef.imageIndex + 1));
Expand Down
4 changes: 3 additions & 1 deletion src/components/metadata/MetadataImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import { useEffect, useRef, useState } from "react";
import type { ParsedExternalMetadata } from "../../features/metadata/parseExternalMetadata";
import type { ImageItem, ImagePackage } from "../../types";
import { resolveSourceImageCount } from "../../utils/mediaHelpers";
import { MetadataRatingGroup } from "./MetadataRatingGroup";
import { useI18n } from "../../i18n/useI18n";
import { useMetadataImageParsedDraft } from "./useMetadataImageParsedDraft";
Expand Down Expand Up @@ -186,7 +187,8 @@ export function MetadataImageEditor({
const imagePagesRead = Math.max(0, imagePreference?.pagesRead ?? 0);
const imageTotalPages = Math.max(
0,
imagePreference?.totalPages ?? focusedImagePackage?.images.length ?? 0,
imagePreference?.totalPages ??
(focusedImagePackage ? resolveSourceImageCount(focusedImagePackage) : 0),
);
const imageCompletionPercent = `${(Math.max(0, Math.min(1, imagePreference?.completionRatio ?? 0)) * 100).toFixed(1)}%`;
const imagePagesReadSummary = `${imagePagesRead} / ${imageTotalPages}`;
Expand Down
Loading
Loading