Compare commits

..

1 Commits

Author SHA1 Message Date
Jonathan Jogenfors
21c54c740a migrate library e2e tests 2026-03-25 22:24:22 +01:00
9 changed files with 466 additions and 559 deletions

View File

@@ -1,5 +1,5 @@
import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk';
import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs';
import { cpSync, existsSync } from 'node:fs';
import { Socket } from 'socket.io-client';
import { userDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@@ -768,553 +768,6 @@ describe('/libraries', () => {
utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
});
it('should set an asset offline if its file is missing', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline`],
});
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id);
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(trashedAsset.isOffline).toEqual(true);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([]);
});
it('should set an asset offline if its file is not in any import path', async () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline`],
});
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
utils.createDirectory(`${testAssetDir}/temp/another-path/`);
await utils.updateLibrary(admin.accessToken, library.id, {
importPaths: [`${testAssetDirInternal}/temp/another-path/`],
});
await utils.scan(admin.accessToken, library.id);
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(trashedAsset.isOffline).toBe(true);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([]);
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
utils.removeDirectory(`${testAssetDir}/temp/another-path/`);
});
it('should set an asset offline if its file is covered by an exclusion pattern', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`],
});
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, {
libraryId: library.id,
originalFileName: 'assetB.png',
});
expect(assets.count).toBe(1);
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] });
await utils.scan(admin.accessToken, library.id);
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(trashedAsset.isTrashed).toBe(true);
expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`);
expect(trashedAsset.isOffline).toBe(true);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'assetA.png',
}),
]);
});
it('should not set an asset offline if its file exists, is in an import path, and not covered by an exclusion pattern', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`],
});
await utils.scan(admin.accessToken, library.id);
const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assetsBefore.count).toBeGreaterThan(1);
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets).toEqual(assetsBefore);
});
describe('xmp metadata', async () => {
it('should import metadata from file.xmp', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2000-09-27T12:35:33.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should import metadata from file.ext.xmp', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2000-09-27T12:35:33.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2000-09-27T12:35:33.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file.xmp to file.ext.xmp when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id);
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2010-09-27T12:35:33.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id);
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2000-09-27T12:35:33.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id);
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2000-09-27T12:35:33.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file.ext.xmp to file.xmp when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id);
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2010-09-27T12:35:33.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file.ext.xmp to file metadata', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id);
unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2010-07-20T17:27:12.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file.xmp to file metadata', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],
});
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id);
unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id);
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'glarus.nef',
fileCreatedAt: '2010-07-20T17:27:12.000Z',
}),
]);
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
});
it('should set an offline asset to online if its file exists, is in an import path, and not covered by an exclusion pattern', async () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline`],
});
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
await utils.scan(admin.accessToken, library.id);
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(offlineAsset.isTrashed).toBe(true);
expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(offlineAsset.isOffline).toBe(true);
{
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
expect(assets.count).toBe(1);
}
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id);
const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(backOnlineAsset.isTrashed).toBe(false);
expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(backOnlineAsset.isOffline).toBe(false);
{
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
}
});
it('should set a trashed offline asset to online but keep it in trash', async () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline`],
});
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
await utils.deleteAssets(admin.accessToken, [assets.items[0].id]);
{
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(trashedAsset.isTrashed).toBe(true);
}
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
await utils.scan(admin.accessToken, library.id);
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(offlineAsset.isTrashed).toBe(true);
expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(offlineAsset.isOffline).toBe(true);
{
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
expect(assets.count).toBe(1);
}
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id);
const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(backOnlineAsset.isOffline).toBe(false);
expect(backOnlineAsset.isTrashed).toBe(true);
{
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
expect(assets.count).toBe(1);
}
});
it('should not set an offline asset to online if its file exists, is not covered by an exclusion pattern, but is outside of all import paths', async () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline`],
});
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
await utils.scan(admin.accessToken, library.id);
{
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
expect(assets.count).toBe(1);
}
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(offlineAsset.isTrashed).toBe(true);
expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(offlineAsset.isOffline).toBe(true);
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
utils.createDirectory(`${testAssetDir}/temp/another-path/`);
await utils.updateLibrary(admin.accessToken, library.id, {
importPaths: [`${testAssetDirInternal}/temp/another-path`],
});
await utils.scan(admin.accessToken, library.id);
const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(stillOfflineAsset.isTrashed).toBe(true);
expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(stillOfflineAsset.isOffline).toBe(true);
{
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
expect(assets.count).toBe(1);
}
utils.removeDirectory(`${testAssetDir}/temp/another-path/`);
});
it('should not set an offline asset to online if its file exists, is in an import path, but is covered by an exclusion pattern', async () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline`],
});
await utils.scan(admin.accessToken, library.id);
{
const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assetsBefore.count).toBe(1);
}
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
expect(assets.count).toBe(1);
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(offlineAsset.isTrashed).toBe(true);
expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(offlineAsset.isOffline).toBe(true);
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await utils.scan(admin.accessToken, library.id);
const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(stillOfflineAsset.isTrashed).toBe(true);
expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
expect(stillOfflineAsset.isOffline).toBe(true);
{
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
expect(assets.count).toBe(1);
}
});
});
describe('POST /libraries/:id/validate', () => {

View File

@@ -866,7 +866,6 @@
"crop_aspect_ratio_fixed": "Fixed",
"crop_aspect_ratio_free": "Free",
"crop_aspect_ratio_original": "Original",
"crop_aspect_ratio_square": "Square",
"curated_object_page_title": "Things",
"current_device": "Current device",
"current_pin_code": "Current PIN code",

View File

@@ -27,10 +27,6 @@
"matchUpdateTypes": ["major"],
"enabled": false
},
{
"matchPackageNames": ["ghcr.io/immich-app/base-server-*"],
"maxMajorIncrement": 0
},
{
"matchPackageNames": ["ruby"],
"groupName": "ruby",

View File

@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS builder
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202603251709@sha256:17de30977ff87aa06758a56ad7f10d6b5c97bf9dab76e4ec4177a2a8d1b2b5f3
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View File

@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS dev
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \

View File

@@ -30,6 +30,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MapRepository } from 'src/repositories/map.repository';
@@ -406,6 +407,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case AssetEditRepository:
case AssetJobRepository:
case MemoryRepository:
case LibraryRepository:
case NotificationRepository:
case OcrRepository:
case PartnerRepository:
@@ -468,6 +470,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case AssetJobRepository:
case ConfigRepository:
case CryptoRepository:
case LibraryRepository:
case MemoryRepository:
case NotificationRepository:
case OcrRepository:

View File

@@ -0,0 +1,456 @@
import { Kysely } from 'kysely';
import { Stats } from 'node:fs';
import { join } from 'node:path';
import { AssetStatus, JobName, JobStatus } from 'src/enum';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
import { LibraryService } from 'src/services/library.service';
import { newMediumService, testAssetsDir } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const createFileStats = (mtimeMs: number): Stats => {
return { mtime: new Date(mtimeMs) } as Stats;
};
const setup = (db?: Kysely<DB>) => {
const context = newMediumService(LibraryService, {
database: db || defaultDatabase,
real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository],
mock: [StorageRepository, JobRepository, LoggingRepository],
});
const jobs = context.ctx.getMock(JobRepository);
jobs.queue.mockResolvedValue();
jobs.queueAll.mockResolvedValue();
return context;
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(LibraryService.name, () => {
const importRoot = '/libraries/offline';
const importPath = `${importRoot}/in-path`;
const excludedPath = `${importRoot}/excluded`;
const outsidePath = '/libraries/outside';
const createLibrary = async (
ctx: ReturnType<typeof setup>['ctx'],
options: { importPaths?: string[]; exclusionPatterns?: string[] } = {},
) => {
const { user } = await ctx.newUser();
return ctx.get(LibraryRepository).create({
ownerId: user.id,
name: 'Medium test library',
importPaths: options.importPaths ?? [importPath],
exclusionPatterns: options.exclusionPatterns ?? [],
});
};
describe('offline asset handling', () => {
it('should set an asset offline if its file is missing', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx);
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${importPath}/offline.png`,
isExternal: true,
isOffline: false,
status: AssetStatus.Active,
});
storage.stat.mockRejectedValue(new Error('ENOENT'));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
expect(updated?.deletedAt).toBeInstanceOf(Date);
});
it('should set an asset offline if its file is not in any import path', async () => {
const { sut, ctx } = setup();
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx, { importPaths: [importPath] });
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${outsidePath}/offline.png`,
isExternal: true,
isOffline: false,
status: AssetStatus.Active,
});
await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
expect(updated?.deletedAt).toBeInstanceOf(Date);
});
it('should set an asset offline if its file is covered by an exclusion pattern', async () => {
const { sut, ctx } = setup();
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx, {
importPaths: [importRoot],
exclusionPatterns: ['**/excluded/**'],
});
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${excludedPath}/offline.png`,
isExternal: true,
isOffline: false,
status: AssetStatus.Active,
});
await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
expect(updated?.deletedAt).toBeInstanceOf(Date);
});
it('should not set an asset offline if file exists in import path and is not excluded', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx, {
importPaths: [importRoot],
exclusionPatterns: ['**/excluded/**'],
});
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${importPath}/online.png`,
isExternal: true,
isOffline: false,
status: AssetStatus.Active,
});
storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: false }));
expect(updated?.deletedAt).toBeNull();
});
it('should set an offline asset to online if its file exists in an import path and is not excluded', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx, { importPaths: [importPath] });
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${importPath}/offline.png`,
isExternal: true,
isOffline: true,
deletedAt: new Date(),
status: AssetStatus.Active,
});
storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: false }));
expect(updated?.deletedAt).toBeNull();
});
it('should not set an offline asset to online if its file exists in an import path but is excluded', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx, {
importPaths: [importRoot],
exclusionPatterns: ['**/offline/**'],
});
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${importRoot}/offline/offline.png`,
isExternal: true,
isOffline: true,
deletedAt: new Date(),
status: AssetStatus.Active,
});
storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
expect(updated?.deletedAt).toBeInstanceOf(Date);
});
it('should keep an offline asset offline if it is outside import paths', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx, { importPaths: [importPath] });
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${outsidePath}/offline.png`,
isExternal: true,
isOffline: true,
deletedAt: new Date(),
status: AssetStatus.Active,
});
storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
expect(updated?.deletedAt).toBeInstanceOf(Date);
});
it('should set a trashed asset offline if its file is missing', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx, { importPaths: [importPath] });
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${importPath}/offline.png`,
isExternal: true,
isOffline: false,
deletedAt: new Date(),
status: AssetStatus.Trashed,
});
storage.stat.mockRejectedValue(new Error('ENOENT'));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
expect(updated?.deletedAt).toBeInstanceOf(Date);
});
it('should set a trashed offline asset to online but keep it in trash', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const assetRepo = ctx.get(AssetRepository);
const library = await createLibrary(ctx, { importPaths: [importPath] });
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: `${importPath}/offline.png`,
isExternal: true,
isOffline: true,
deletedAt: new Date(),
status: AssetStatus.Trashed,
});
storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
const updated = await assetRepo.getById(asset.id);
expect(updated).toEqual(expect.objectContaining({ isOffline: false }));
expect(updated?.deletedAt).toBeInstanceOf(Date);
});
});
describe('xmp scan behavior', () => {
it('should queue sidecar checks for newly imported assets', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const jobs = ctx.getMock(JobRepository);
jobs.queueAll.mockResolvedValue();
const library = await createLibrary(ctx, { importPaths: ['/libraries/xmp'] });
const rawPath = join(testAssetsDir, 'formats/raw/Nikon/D80/glarus.nef');
storage.stat.mockResolvedValue(createFileStats(1_700_000_000_000));
await expect(
sut.handleSyncFiles({
libraryId: library.id,
paths: [rawPath],
progressCounter: 1,
}),
).resolves.toBe(JobStatus.Success);
expect(jobs.queueAll).toHaveBeenCalledWith([
expect.objectContaining({
name: JobName.SidecarCheck,
data: expect.objectContaining({ id: expect.any(String) }),
}),
]);
});
it('should queue sidecar checks for assets whose file changed', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const jobs = ctx.getMock(JobRepository);
jobs.queueAll.mockResolvedValue();
const library = await createLibrary(ctx, { importPaths: ['/libraries/xmp'] });
const rawPath = join(testAssetsDir, 'formats/raw/Nikon/D80/glarus.nef');
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: rawPath,
fileModifiedAt: new Date(1_700_000_000_000),
isExternal: true,
isOffline: false,
status: AssetStatus.Active,
});
storage.stat.mockResolvedValue(createFileStats(1_700_000_000_001));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
expect(jobs.queueAll).toHaveBeenCalledWith([
{
name: JobName.SidecarCheck,
data: { id: asset.id, source: 'upload' },
},
]);
});
it('should not queue sidecar checks for unchanged assets', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const jobs = ctx.getMock(JobRepository);
jobs.queueAll.mockResolvedValue();
const library = await createLibrary(ctx, { importPaths: ['/libraries/xmp'] });
const rawPath = join(testAssetsDir, 'formats/raw/Nikon/D80/glarus.nef');
const mtimeMs = 1_700_000_000_000;
const { asset } = await ctx.newAsset({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: rawPath,
fileModifiedAt: new Date(mtimeMs),
isExternal: true,
isOffline: false,
status: AssetStatus.Active,
});
storage.stat.mockResolvedValue(createFileStats(mtimeMs));
await expect(
sut.handleSyncAssets({
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: [asset.id],
progressCounter: 1,
totalAssets: 1,
}),
).resolves.toBe(JobStatus.Success);
expect(jobs.queueAll).not.toHaveBeenCalled();
});
});
});

View File

@@ -120,10 +120,10 @@
<ActionButton action={Cast} />
<ActionButton action={Actions.Share} />
<ActionButton action={Actions.Offline} />
<ActionButton action={Actions.ZoomIn} />
<ActionButton action={Actions.ZoomOut} />
<ActionButton action={Actions.PlayMotionPhoto} />
<ActionButton action={Actions.StopMotionPhoto} />
<ActionButton action={Actions.ZoomIn} />
<ActionButton action={Actions.ZoomOut} />
<ActionButton action={Actions.Copy} />
<ActionButton action={Actions.SharedLinkDownload} />
<ActionButton action={Actions.Info} />

View File

@@ -23,7 +23,7 @@
{ label: '2:3', value: '2:3', width: 16, height: 24 },
{ label: '16:9', value: '16:9', width: 24, height: 14 },
{ label: '9:16', value: '9:16', width: 14, height: 24 },
{ label: $t('crop_aspect_ratio_square'), value: '1:1', width: 20, height: 20 },
{ label: 'Square', value: '1:1', width: 20, height: 20 },
];
let isRotated = $derived(transformManager.normalizedRotation % 180 !== 0);