mirror of
https://github.com/immich-app/immich.git
synced 2026-06-01 11:45:22 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21c54c740a |
@@ -66,7 +66,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
|
||||
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
|
||||
|
||||
close_llm:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
|
||||
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
|
||||
|
||||
- name: Check for remaining auto-closed labels
|
||||
id: check_labels
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
|
||||
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
||||
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.3",
|
||||
"url": "https://docs.v2.6.3.archive.immich.app"
|
||||
"label": "v2.6.2",
|
||||
"url": "https://docs.v2.6.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.6.3"
|
||||
version = "2.6.2"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -898,7 +898,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.6.3"
|
||||
version = "2.6.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3041,
|
||||
"android.injected.version.name" => "2.6.3",
|
||||
"android.injected.version.code" => 3040,
|
||||
"android.injected.version.name" => "2.6.2",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.6.3</string>
|
||||
<string>2.6.2</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.6.3
|
||||
- API version: 2.6.2
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
+1
-1
@@ -379,7 +379,7 @@ class MetadataSearchDto {
|
||||
///
|
||||
bool? withExif;
|
||||
|
||||
/// Include people data in response
|
||||
/// Include assets with people
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ class RandomSearchDto {
|
||||
///
|
||||
bool? withExif;
|
||||
|
||||
/// Include people data in response
|
||||
/// Include assets with people
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.6.3+3041
|
||||
version: 2.6.2+3040
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -15166,7 +15166,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
@@ -19129,7 +19129,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"withPeople": {
|
||||
"description": "Include people data in response",
|
||||
"description": "Include assets with people",
|
||||
"type": "boolean"
|
||||
},
|
||||
"withStacked": {
|
||||
@@ -20868,7 +20868,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"withPeople": {
|
||||
"description": "Include people data in response",
|
||||
"description": "Include assets with people",
|
||||
"type": "boolean"
|
||||
},
|
||||
"withStacked": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.6.3
|
||||
* 2.6.2
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -1741,7 +1741,7 @@ export type MetadataSearchDto = {
|
||||
withDeleted?: boolean;
|
||||
/** Include EXIF data in response */
|
||||
withExif?: boolean;
|
||||
/** Include people data in response */
|
||||
/** Include assets with people */
|
||||
withPeople?: boolean;
|
||||
/** Include stacked assets */
|
||||
withStacked?: boolean;
|
||||
@@ -1855,7 +1855,7 @@ export type RandomSearchDto = {
|
||||
withDeleted?: boolean;
|
||||
/** Include EXIF data in response */
|
||||
withExif?: boolean;
|
||||
/** Include people data in response */
|
||||
/** Include assets with people */
|
||||
withPeople?: boolean;
|
||||
/** Include stacked assets */
|
||||
withStacked?: boolean;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
|
||||
@@ -27,10 +27,6 @@
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["ghcr.io/immich-app/base-server-*"],
|
||||
"maxMajorIncrement": 0
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["ruby"],
|
||||
"groupName": "ruby",
|
||||
|
||||
+2
-2
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -146,7 +146,7 @@ export class RandomSearchDto extends BaseSearchWithResultsDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Include stacked assets' })
|
||||
withStacked?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Include people data in response' })
|
||||
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
|
||||
withPeople?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,6 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "state" is not null
|
||||
and "state" != $3
|
||||
|
||||
-- SearchRepository.getCities
|
||||
select distinct
|
||||
@@ -267,7 +266,6 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "city" is not null
|
||||
and "city" != $3
|
||||
|
||||
-- SearchRepository.getCameraMakes
|
||||
select distinct
|
||||
@@ -280,7 +278,6 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "make" is not null
|
||||
and "make" != $3
|
||||
|
||||
-- SearchRepository.getCameraModels
|
||||
select distinct
|
||||
@@ -293,7 +290,6 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "model" is not null
|
||||
and "model" != $3
|
||||
|
||||
-- SearchRepository.getCameraLensModels
|
||||
select distinct
|
||||
@@ -306,4 +302,3 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "lensModel" is not null
|
||||
and "lensModel" != $3
|
||||
|
||||
@@ -582,6 +582,7 @@ where
|
||||
"asset_face"."updateId" < $1
|
||||
and "asset_face"."updateId" > $2
|
||||
and "asset"."ownerId" = $3
|
||||
and "asset_face"."isVisible" = $4
|
||||
order by
|
||||
"asset_face"."updateId" asc
|
||||
|
||||
|
||||
@@ -502,7 +502,10 @@ export class SearchRepository {
|
||||
return res.map((row) => row.lensModel!);
|
||||
}
|
||||
|
||||
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
|
||||
private getExifField<K extends 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel'>(
|
||||
field: K,
|
||||
userIds: string[],
|
||||
) {
|
||||
return this.db
|
||||
.selectFrom('asset_exif')
|
||||
.select(field)
|
||||
@@ -511,7 +514,6 @@ export class SearchRepository {
|
||||
.where('ownerId', '=', anyUuid(userIds))
|
||||
.where('visibility', '=', AssetVisibility.Timeline)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where(field, 'is not', null)
|
||||
.where(field, '!=', '');
|
||||
.where(field, 'is not', null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +487,7 @@ class AssetFaceSync extends BaseSync {
|
||||
])
|
||||
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
|
||||
.where('asset.ownerId', '=', options.userId)
|
||||
.where('asset_face.isVisible', '=', true)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Sync query for faces was incorrect on server <=2.6.2
|
||||
await sql`DELETE FROM session_sync_checkpoint WHERE type in ('AssetFaceV1', 'AssetFaceV2')`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Not implemented
|
||||
}
|
||||
@@ -356,7 +356,6 @@ export class AssetMediaService extends BaseService {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
@@ -109,25 +108,4 @@ describe(SearchService.name, () => {
|
||||
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchSuggestions', () => {
|
||||
it('should filter out empty search suggestions', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
|
||||
|
||||
const { asset: assetWithEmptyMake } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: assetWithEmptyMake.id, make: '' });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const suggestions = await sut.getSearchSuggestions(auth, {
|
||||
type: SearchSuggestionType.CAMERA_MAKE,
|
||||
includeNull: true,
|
||||
});
|
||||
|
||||
expect(suggestions).toEqual(['Canon', null]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
|
||||
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
|
||||
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { updateAsset } from '@immich/sdk';
|
||||
@@ -42,7 +41,6 @@ describe('AssetViewer', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
slideshowStore.slideshowState.set(SlideshowState.None);
|
||||
resetSavedUser();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -485,7 +485,7 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState != SlideshowState.None}
|
||||
<div class="absolute inset-s-0 top-0 flex w-full justify-start">
|
||||
<div class="absolute w-full flex justify-center">
|
||||
<SlideshowBar
|
||||
{isFullScreen}
|
||||
assetType={previewStackedAsset?.type ?? asset.type}
|
||||
@@ -580,16 +580,17 @@
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class={[
|
||||
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
|
||||
showDetailPanel ? 'w-90' : 'w-100',
|
||||
]}
|
||||
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
<div class="w-90 h-full">
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
</div>
|
||||
{:else if assetViewerManager.isShowEditor}
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
<div class="w-100 h-full">
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user