Compare commits

..

9 Commits

Author SHA1 Message Date
renovate[bot]
2367401683 chore(deps): update dependency python-multipart to v0.0.22 [security] 2026-01-27 11:59:25 +00:00
renovate[bot]
e57739b641 chore(deps): update dependency @types/node to ^24.10.9 (#25548)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 12:56:57 +01:00
Alex
6587d45f1e chore: star rating letter casing (#25554) 2026-01-27 08:57:39 +00:00
Brandon Wees
da590995ab fix: use edited thumbs for widgets (#25550)
* fix(server): enforce crop is the first action

* chore: test

* fix: use edited thumbs for widgets

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-27 03:16:27 +00:00
Brandon Wees
e04d316203 fix(server): enforce crop is the first action (#25547)
* fix(server): enforce crop is the first action

* chore: test
2026-01-26 20:45:28 -06:00
Alex
6b2737bae3 chore: hide workflow path (#25539) 2026-01-26 22:47:24 +00:00
Brandon Wees
42b354c302 fix: always serve edited version if using shared link. (#25536)
* fix: always serve edited version if using shared link.

* chore: test

* chore: rename tests
2026-01-26 16:42:22 -06:00
Alex
cf6c7f9960 chore: use correct SDK version for Xcode build (#25542)
chore: use correct SDK version for Xcode
2026-01-26 16:07:17 -06:00
Mert
9506398153 refactor(server): add isProgressive column (#25537)
* add isProgressive column

* don't select isProgressive by default

* linting

* exclude sidecars
2026-01-26 17:05:25 -05:00
28 changed files with 525 additions and 184 deletions

View File

@@ -178,9 +178,12 @@ jobs:
contents: read
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: macos-latest
runs-on: macos-15
steps:
- name: Select Xcode 26
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.8",
"@types/node": "^24.10.9",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -27,7 +27,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.8",
"@types/node": "^24.10.9",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",

View File

@@ -45,20 +45,23 @@ RUN git apply /tmp/*.patch
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH}
ENV CCACHE_DIR="/ccache"
# Note: the `parallel` setting uses a substantial amount of RAM
RUN ./build.sh \
RUN --mount=type=cache,target=/ccache \
./build.sh \
--allow_running_as_root \
--config Release \
--build_wheel \
--update \
--build \
--parallel 21 \
--parallel 17 \
--cmake_extra_defines \
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
CMAKE_HIP_ARCHITECTURES="gfx900;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
--skip_tests \
--use_rocm \
--rocm_home=/opt/rocm \
--use_cache \
--compile_no_warning_as_error
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/

View File

@@ -2206,11 +2206,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.21"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]

View File

@@ -101,7 +101,7 @@ class ImmichAPI(cfg: ServerConfig) {
}
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)

View File

@@ -225,7 +225,7 @@ class ImmichAPI {
}
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
let thumbnailParams = [URLQueryItem(name: "size", value: "preview"), URLQueryItem(name: "edited", value: "true")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
guard

View File

@@ -298,11 +298,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
spacing: 8,
children: [
Text(
'rating'.t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
'rating'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.10.8",
"@types/node": "^24.10.9",
"typescript": "^5.3.3"
},
"repository": {

8
pnpm-lock.yaml generated
View File

@@ -63,7 +63,7 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^24.10.8
specifier: ^24.10.9
version: 24.10.9
'@vitest/coverage-v8':
specifier: ^3.0.0
@@ -220,7 +220,7 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^24.10.8
specifier: ^24.10.9
version: 24.10.9
'@types/pg':
specifier: ^8.15.1
@@ -320,7 +320,7 @@ importers:
version: 1.1.0
devDependencies:
'@types/node':
specifier: ^24.10.8
specifier: ^24.10.9
version: 24.10.9
typescript:
specifier: ^5.3.3
@@ -639,7 +639,7 @@ importers:
specifier: ^2.0.0
version: 2.0.0
'@types/node':
specifier: ^24.10.8
specifier: ^24.10.9
version: 24.10.9
'@types/nodemailer':
specifier: ^7.0.0

View File

@@ -135,7 +135,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.8",
"@types/node": "^24.10.9",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -34,6 +34,8 @@ export interface MoveRequest {
export type ThumbnailPathEntity = { id: string; ownerId: string };
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
let instance: StorageCore | null;
let mediaLocation: string | undefined;
@@ -110,14 +112,7 @@ export class StorageCore {
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
}
static getImagePath(
asset: ThumbnailPathEntity,
{
fileType,
format,
isEdited,
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
) {
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
return StorageCore.getNestedPath(
StorageFolder.Thumbnails,
asset.ownerId,

View File

@@ -346,6 +346,13 @@ export const columns = {
'asset.height',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
'asset_file.id',
'asset_file.path',
'asset_file.type',
'asset_file.isEdited',
'asset_file.isProgressive',
],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],

View File

@@ -165,11 +165,13 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" in ($1, $2, $3)
) as agg
) as "files",
(
@@ -191,7 +193,7 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."id" = $1
"asset"."id" = $4
-- AssetJobRepository.getForMetadataExtraction
select

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { Asset, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
@@ -104,7 +105,15 @@ export class AssetJobRepository {
'asset.thumbhash',
'asset.type',
])
.select(withFiles)
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('asset_file')
.select(columns.assetFilesForThumbnail)
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
).as('files'),
)
.select(withEdits)
.$call(withExifInner)
.where('asset.id', '=', id)

View File

@@ -904,11 +904,12 @@ export class AssetRepository {
.execute();
}
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
const value = { ...file, assetId: asUuid(file.assetId) };
async upsertFile(
file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>,
): Promise<void> {
await this.db
.insertInto('asset_file')
.values(value)
.values(file)
.onConflict((oc) =>
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'),
@@ -918,19 +919,19 @@ export class AssetRepository {
}
async upsertFiles(
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[],
): Promise<void> {
if (files.length === 0) {
return;
}
const values = files.map((row) => ({ ...row, assetId: asUuid(row.assetId) }));
await this.db
.insertInto('asset_file')
.values(values)
.values(files)
.onConflict((oc) =>
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'),
isProgressive: eb.ref('excluded.isProgressive'),
})),
)
.execute();

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_file" ADD "isProgressive" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_file" DROP COLUMN "isProgressive";`.execute(db);
}

View File

@@ -40,4 +40,7 @@ export class AssetFileTable {
@Column({ type: 'boolean', default: false })
isEdited!: Generated<boolean>;
@Column({ type: 'boolean', default: false })
isProgressive!: Generated<boolean>;
}

View File

@@ -572,6 +572,35 @@ describe(AssetMediaService.name, () => {
);
});
it('should not return the unedited version if requested using a shared link', async () => {
const editedAsset = {
...assetStub.withCropEdit,
files: [
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
isEdited: true,
},
],
};
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getForOriginal.mockResolvedValue({
...editedAsset,
editedPath: '/uploads/user-id/fullsize/edited.jpg',
});
await expect(sut.downloadOriginal(authStub.adminSharedLink, 'asset-id', { edited: false })).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/fullsize/edited.jpg',
fileName: 'asset-id.jpg',
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
}),
);
});
it('should download original file when edited=false', async () => {
const editedAsset = {
...assetStub.withCropEdit,
@@ -711,6 +740,28 @@ describe(AssetMediaService.name, () => {
);
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
});
it('should not return the unedited version if requested using a shared link', async () => {
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.asset.getForThumbnail.mockResolvedValue({
...assetStub.image,
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
});
await expect(
sut.viewThumbnail(authStub.adminSharedLink, assetStub.image.id, {
size: AssetMediaSize.THUMBNAIL,
edited: true,
}),
).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
fileName: 'asset-id_thumbnail.jpg',
}),
);
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
});
});
describe('playbackVideo', () => {

View File

@@ -196,6 +196,10 @@ export class AssetMediaService extends BaseService {
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
if (auth.sharedLink) {
dto.edited = true;
}
const { originalPath, originalFileName, editedPath } = await this.assetRepository.getForOriginal(
id,
dto.edited ?? false,
@@ -222,6 +226,10 @@ export class AssetMediaService extends BaseService {
throw new BadRequestException('May not request original file');
}
if (auth.sharedLink) {
dto.edited = true;
}
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
id,

View File

@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
@@ -813,4 +814,25 @@ describe(AssetService.name, () => {
expect(mocks.asset.upsertBulkMetadata).not.toHaveBeenCalled();
});
});
describe('editAsset', () => {
it('should enforce crop first', async () => {
await expect(
sut.editAsset(authStub.admin, 'asset-1', {
edits: [
{
action: AssetEditAction.Rotate,
parameters: { angle: 90 },
},
{
action: AssetEditAction.Crop,
parameters: { x: 0, y: 0, width: 100, height: 100 },
},
],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.assetEdit.replaceAll).not.toHaveBeenCalled();
});
});
});

View File

@@ -21,7 +21,7 @@ import {
mapStats,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditAction, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
import { AssetEditAction, AssetEditActionCrop, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import {
AssetFileType,
@@ -574,16 +574,21 @@ export class AssetService extends BaseService {
throw new BadRequestException('Editing SVG images is not supported');
}
// check that crop parameters will not go out of bounds
const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!);
if (!assetWidth || !assetHeight) {
throw new BadRequestException('Asset dimensions are not available for editing');
const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop);
if (cropIndex > 0) {
throw new BadRequestException('Crop action must be the first edit action');
}
const crop = dto.edits.find((e) => e.action === AssetEditAction.Crop)?.parameters;
const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop);
if (crop) {
const { x, y, width, height } = crop;
// check that crop parameters will not go out of bounds
const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!);
if (!assetWidth || !assetHeight) {
throw new BadRequestException('Asset dimensions are not available for editing');
}
const { x, y, width, height } = crop.parameters;
if (x + width > assetWidth || y + height > assetHeight) {
throw new BadRequestException('Crop parameters are out of bounds');
}

View File

@@ -388,12 +388,14 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
path: expect.any(String),
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: expect.any(String),
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
@@ -426,12 +428,14 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
path: expect.any(String),
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: expect.any(String),
isEdited: false,
isProgressive: false,
},
]);
});
@@ -463,12 +467,14 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
path: expect.any(String),
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: expect.any(String),
isEdited: false,
isProgressive: false,
},
]);
});
@@ -673,6 +679,16 @@ describe(MediaService.name, () => {
}),
expect.stringContaining('thumbnail.webp'),
);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: true,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: false,
}),
]);
});
it('should generate progressive JPEG for thumbnail when enabled', async () => {
@@ -699,6 +715,37 @@ describe(MediaService.name, () => {
}),
expect.stringContaining('thumbnail.jpeg'),
);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: false,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: true,
}),
]);
});
it('should never set isProgressive for videos', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: true }, thumbnail: { progressive: true } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: false,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: false,
}),
]);
});
it('should delete previous thumbnail if different path', async () => {
@@ -3353,14 +3400,38 @@ describe(MediaService.name, () => {
files: [],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
await sut['syncFiles'](asset.files, [
{
assetId: asset.id,
type: AssetFileType.Preview,
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/new/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
{
assetId: 'asset-id',
path: '/new/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
path: '/new/thumbnail.jpg',
type: AssetFileType.Thumbnail,
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled();
@@ -3376,6 +3447,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
id: 'file-2',
@@ -3383,18 +3455,43 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
await sut['syncFiles'](asset.files, [
{
assetId: asset.id,
type: AssetFileType.Preview,
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/new/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
{
assetId: 'asset-id',
path: '/new/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
path: '/new/thumbnail.jpg',
type: AssetFileType.Thumbnail,
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3413,6 +3510,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
id: 'file-2',
@@ -3420,24 +3518,30 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, isEdited: false },
{ type: AssetFileType.Thumbnail, isEdited: false },
]);
await sut['syncFiles'](asset.files, []);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false },
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3456,6 +3560,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
path: '/same/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
id: 'file-2',
@@ -3463,13 +3568,26 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail,
path: '/same/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
await sut['syncFiles'](asset.files, [
{
assetId: asset.id,
type: AssetFileType.Preview,
path: '/same/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/same/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
@@ -3487,6 +3605,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
id: 'file-2',
@@ -3494,19 +3613,43 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace
{ type: AssetFileType.Thumbnail, isEdited: false }, // delete
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new
await sut['syncFiles'](asset.files, [
{
assetId: asset.id,
type: AssetFileType.Preview,
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
}, // replace
{
assetId: asset.id,
type: AssetFileType.FullSize,
path: '/new/fullsize.jpg',
isEdited: false,
isProgressive: false,
}, // new
]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
{
assetId: 'asset-id',
path: '/new/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
path: '/new/fullsize.jpg',
type: AssetFileType.FullSize,
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{
@@ -3515,6 +3658,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3529,7 +3673,7 @@ describe(MediaService.name, () => {
files: [],
};
await sut['syncFiles'](asset, []);
await sut['syncFiles'](asset.files, []);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
@@ -3546,15 +3690,79 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
]);
await sut['syncFiles'](asset.files, []);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: ['/old/preview.jpg'] },
});
});
it('should update database when isProgressive changes', async () => {
const asset = {
id: 'asset-id',
files: [
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
],
};
await sut['syncFiles'](asset.files, [
{
assetId: asset.id,
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: true,
},
{
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{
assetId: 'asset-id',
path: '/old/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
isProgressive: true,
},
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled();
});

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
@@ -45,11 +45,13 @@ import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { getOutputDimensions } from 'src/utils/transform';
interface UpsertFileOptions {
assetId: string;
type: AssetFileType;
path: string;
isEdited: boolean;
isProgressive: boolean;
}
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
@@ -171,18 +173,22 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
const config = await this.getConfig({ withCache: true });
if (!asset) {
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
return JobStatus.Failed;
}
const generated = await this.generateEditedThumbnails(asset);
const generated = await this.generateEditedThumbnails(asset, config);
await this.syncFiles(
asset.files.filter((asset) => asset.isEdited),
generated?.files ?? [],
);
let thumbhash: Buffer | undefined = generated?.thumbhash;
if (!thumbhash) {
const { image } = await this.getConfig({ withCache: true });
const extractedImage = await this.extractOriginalImage(asset, image);
const extractedImage = await this.extractOriginalImage(asset, config.image);
const { info, data, colorspace } = extractedImage;
thumbhash = await this.mediaRepository.generateThumbhash(data, {
@@ -206,6 +212,7 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
const config = await this.getConfig({ withCache: true });
if (!asset) {
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
@@ -217,32 +224,25 @@ export class MediaService extends BaseService {
return JobStatus.Skipped;
}
let generated: {
previewPath: string;
thumbnailPath: string;
fullsizePath?: string;
thumbhash: Buffer;
fullsizeDimensions?: ImageDimensions;
};
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
generated = await this.generateVideoThumbnails(asset);
generated = await this.generateVideoThumbnails(asset, config);
} else if (asset.type === AssetType.Image) {
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
generated = await this.generateImageThumbnails(asset);
generated = await this.generateImageThumbnails(asset, config);
} else {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
return JobStatus.Skipped;
}
await this.syncFiles(asset, [
{ type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false },
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false },
]);
const editedGenerated = await this.generateEditedThumbnails(asset, config);
if (editedGenerated) {
generated.files.push(...editedGenerated.files);
}
const editiedGenerated = await this.generateEditedThumbnails(asset);
const thumbhash = editiedGenerated?.thumbhash || generated.thumbhash;
await this.syncFiles(asset.files, generated.files);
const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash });
@@ -274,11 +274,7 @@ export class MediaService extends BaseService {
return { info, data, colorspace };
}
private async extractOriginalImage(
asset: NonNullable<ThumbnailAsset>,
image: SystemConfig['image'],
useEdits = false,
) {
private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) {
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
const generateFullsize =
@@ -305,19 +301,21 @@ export class MediaService extends BaseService {
};
}
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) {
const { image } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, {
private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) {
const previewFile = this.getImageFile(asset, {
fileType: AssetFileType.Preview,
isEdited: useEdits,
format: image.preview.format,
});
const thumbnailPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Thumbnail,
isEdited: useEdits,
format: image.thumbnail.format,
isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp,
});
this.storageCore.ensureFolders(previewPath);
previewFile.isProgressive = !!image.preview.progressive && image.preview.format !== ImageFormat.Webp;
const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
isEdited: useEdits,
isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp,
});
this.storageCore.ensureFolders(previewFile.path);
// Handle embedded preview extraction for RAW files
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
@@ -327,26 +325,18 @@ export class MediaService extends BaseService {
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
const promises = [
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
this.mediaRepository.generateThumbnail(
data,
{ ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
thumbnailPath,
),
this.mediaRepository.generateThumbnail(
data,
{ ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
previewPath,
),
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path),
];
let fullsizePath: string | undefined;
let fullsizeFile: UpsertFileOptions | undefined;
if (convertFullsize) {
// convert a new fullsize image from the same source as the thumbnail
fullsizePath = StorageCore.getImagePath(asset, {
fullsizeFile = this.getImageFile(asset, {
fileType: AssetFileType.FullSize,
isEdited: useEdits,
format: image.fullsize.format,
isEdited: useEdits,
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
});
const fullsizeOptions = {
format: image.fullsize.format,
@@ -354,23 +344,25 @@ export class MediaService extends BaseService {
progressive: image.fullsize.progressive,
...thumbnailOptions,
};
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
fullsizePath = StorageCore.getImagePath(asset, {
fullsizeFile = this.getImageFile(asset, {
fileType: AssetFileType.FullSize,
format: extracted.format,
isEdited: false,
isEdited: useEdits,
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
});
this.storageCore.ensureFolders(fullsizePath);
fullsizeFile.isProgressive = !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp;
this.storageCore.ensureFolders(fullsizeFile.path);
// Write the buffer to disk with essential EXIF data
await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer);
await this.storageRepository.createOrOverwriteFile(fullsizeFile.path, extracted.buffer);
await this.mediaRepository.writeExif(
{
orientation: asset.exifInfo.orientation,
colorspace: asset.exifInfo.colorspace,
},
fullsizePath,
fullsizeFile.path,
);
}
@@ -378,9 +370,9 @@ export class MediaService extends BaseService {
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
const promises = [
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath),
fullsizePath
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath)
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path),
fullsizeFile
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path)
: Promise.resolve(),
];
await Promise.all(promises);
@@ -389,7 +381,11 @@ export class MediaService extends BaseService {
const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer, fullsizeDimensions };
return {
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
thumbhash: outputs[0] as Buffer,
fullsizeDimensions,
};
}
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
@@ -493,19 +489,23 @@ export class MediaService extends BaseService {
};
}
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
const { image, ffmpeg } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, {
private async generateVideoThumbnails(
asset: ThumbnailPathEntity & { originalPath: string },
{ ffmpeg, image }: SystemConfig,
) {
const previewFile = this.getImageFile(asset, {
fileType: AssetFileType.Preview,
format: image.preview.format,
isEdited: false,
isProgressive: false,
});
const thumbnailPath = StorageCore.getImagePath(asset, {
const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
isEdited: false,
isProgressive: false,
});
this.storageCore.ensureFolders(previewPath);
this.storageCore.ensureFolders(previewFile.path);
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
const mainVideoStream = this.getMainStream(videoStreams);
@@ -524,17 +524,16 @@ export class MediaService extends BaseService {
format,
);
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions);
const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, {
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
colorspace: image.colorspace,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
});
return {
previewPath,
thumbnailPath,
files: [previewFile, thumbnailFile],
thumbhash,
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
};
@@ -791,34 +790,28 @@ export class MediaService extends BaseService {
}
}
private async syncFiles(
asset: { id: string; files: AssetFile[] },
files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
) {
private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) {
const toUpsert: UpsertFileOptions[] = [];
const pathsToDelete: string[] = [];
const toDelete: AssetFile[] = [];
const toDelete = new Set(oldFiles);
for (const { type, newPath, isEdited } of files) {
const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
// upsert new file path
if (newPath && existingFile?.path !== newPath) {
toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
// delete old file from disk
if (existingFile) {
this.logger.debug(`Deleting old ${type} image for asset ${asset.id} in favor of a replacement`);
pathsToDelete.push(existingFile.path);
}
for (const newFile of newFiles) {
const existingFile = oldFiles.find((file) => file.type === newFile.type && file.isEdited === newFile.isEdited);
if (existingFile) {
toDelete.delete(existingFile);
}
// delete old file from disk and database
if (!newPath && existingFile) {
this.logger.debug(`Deleting old ${type} image for asset ${asset.id}`);
// upsert new file path
if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) {
toUpsert.push(newFile);
pathsToDelete.push(existingFile.path);
toDelete.push(existingFile);
// delete old file from disk
if (existingFile && existingFile.path !== newFile.path) {
this.logger.debug(
`Deleting old ${newFile.type} image for asset ${newFile.assetId} in favor of a replacement`,
);
pathsToDelete.push(existingFile.path);
}
}
}
@@ -826,8 +819,12 @@ export class MediaService extends BaseService {
await this.assetRepository.upsertFiles(toUpsert);
}
if (toDelete.length > 0) {
await this.assetRepository.deleteFiles(toDelete);
if (toDelete.size > 0) {
const toDeleteArray = [...toDelete];
for (const file of toDeleteArray) {
pathsToDelete.push(file.path);
}
await this.assetRepository.deleteFiles(toDeleteArray);
}
if (pathsToDelete.length > 0) {
@@ -835,18 +832,12 @@ export class MediaService extends BaseService {
}
}
private async generateEditedThumbnails(asset: ThumbnailAsset) {
if (asset.type !== AssetType.Image) {
private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
return;
}
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined;
await this.syncFiles(asset, [
{ type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true },
{ type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true },
{ type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true },
]);
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
const cropBox = crop
@@ -870,4 +861,15 @@ export class MediaService extends BaseService {
return generated;
}
private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) {
const path = StorageCore.getImagePath(asset, options);
return {
assetId: asset.id,
type: options.fileType,
path,
isEdited: options.isEdited,
isProgressive: options.isProgressive,
};
}
}

View File

@@ -48,9 +48,9 @@ const editedFullsizeFile = factory.assetFile({
isEdited: true,
});
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
const files = [fullsizeFile, previewFile, thumbnailFile];
const editedFiles: AssetFile[] = [
const editedFiles = [
fullsizeFile,
previewFile,
thumbnailFile,
@@ -624,14 +624,19 @@ export const assetStub = {
fileSizeInByte: 100_000,
timeZone: `America/New_York`,
},
files: [] as AssetFile[],
files: [],
libraryId: null,
visibility: AssetVisibility.Hidden,
width: null,
height: null,
edits: [] as AssetEditActionItem[],
isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
} as unknown as MapAsset & {
faces: AssetFace[];
files: (AssetFile & { isProgressive: boolean })[];
exifInfo: Exif;
edits: AssetEditActionItem[];
}),
livePhotoStillAsset: Object.freeze({
id: 'live-photo-still-asset',
@@ -653,7 +658,11 @@ export const assetStub = {
height: null,
edits: [] as AssetEditActionItem[],
isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
} as unknown as MapAsset & {
faces: AssetFace[];
files: (AssetFile & { isProgressive: boolean })[];
edits: AssetEditActionItem[];
}),
livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset',

View File

@@ -400,11 +400,12 @@ const assetOcrFactory = (
...ocr,
});
const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
const assetFileFactory = (file: Partial<AssetFile> = {}) => ({
id: newUuid(),
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
isEdited: false,
isProgressive: false,
...file,
});

View File

@@ -9,7 +9,6 @@
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -17,7 +16,7 @@
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
{ href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
// { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
];
</script>

View File

@@ -1,10 +1,17 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getPlugins, getWorkflows } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const isReady = false;
if (!isReady) {
redirect(307, '/utilities');
}
const [workflows, plugins] = await Promise.all([getWorkflows(), getPlugins()]);
const $t = await getFormatter();