forked from Cutlery/immich
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fd54e3994 | |||
| db45ec7434 | |||
| 4f4bceec94 | |||
| 7a16233584 | |||
| fff12e3d78 | |||
| da750ed838 | |||
| e49512896f | |||
| 0075243ed5 | |||
| 29e47dd7c1 | |||
| 105a74caca | |||
| 55b9acca78 | |||
| 0d130b8957 | |||
| 0aa5d3daeb | |||
| ad9aafd484 | |||
| 8eca25007e | |||
| 86b200e870 | |||
| 5dcc4ddfc6 | |||
| 6b10994bd2 | |||
| 12ede06411 | |||
| 121e9f1f1c | |||
| c263607515 | |||
| 4cb2e16549 | |||
| 0f8fb8d38b | |||
| 107157b856 | |||
| 87cbcc02c3 | |||
| 62531a75eb | |||
| 29c7663caa |
@@ -6,6 +6,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Please use this form to request new feature for Immich
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
options:
|
||||
- label: I have searched the existing feature requests to make sure this is not a duplicate request.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.2.0
|
||||
uses: docker/setup-buildx-action@v3.3.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.2.0
|
||||
uses: docker/setup-buildx-action@v3.3.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
# Only push to Docker Hub when making a release
|
||||
|
||||
@@ -21,6 +21,7 @@ module.exports = {
|
||||
'unicorn/prefer-module': 'off',
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'unicorn/no-process-exit': 'off',
|
||||
'unicorn/import-style': 'off',
|
||||
curly: 2,
|
||||
'prettier/prettier': 0,
|
||||
},
|
||||
|
||||
Generated
+4
-4
@@ -30,7 +30,7 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"glob": "^10.3.1",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
@@ -2194,9 +2194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-unicorn": {
|
||||
"version": "51.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
|
||||
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
|
||||
"version": "52.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
|
||||
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"glob": "^10.3.1",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
|
||||
@@ -56,14 +56,13 @@ class UploadFile extends File {
|
||||
export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => {
|
||||
await authenticate(baseOptions);
|
||||
|
||||
const files = await scan(paths, options);
|
||||
if (files.length === 0) {
|
||||
const scanFiles = await scan(paths, options);
|
||||
if (scanFiles.length === 0) {
|
||||
console.log('No files found, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
const { newFiles, duplicates } = await checkForDuplicates(files, options);
|
||||
|
||||
const { newFiles, duplicates } = await checkForDuplicates(scanFiles, options);
|
||||
const newAssets = await uploadFiles(newFiles, options);
|
||||
await updateAlbums([...newAssets, ...duplicates], options);
|
||||
await deleteFiles(newFiles, options);
|
||||
@@ -84,7 +83,12 @@ const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
|
||||
return files;
|
||||
};
|
||||
|
||||
const checkForDuplicates = async (files: string[], { concurrency }: UploadOptionsDto) => {
|
||||
const checkForDuplicates = async (files: string[], { concurrency, skipHash }: UploadOptionsDto) => {
|
||||
if (skipHash) {
|
||||
console.log('Skipping hash check, assuming all files are new');
|
||||
return { newFiles: files, duplicates: [] };
|
||||
}
|
||||
|
||||
const progressBar = new SingleBar(
|
||||
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||
Presets.shades_classic,
|
||||
@@ -147,17 +151,32 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
|
||||
uploadProgress.start(totalSize, 0);
|
||||
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
|
||||
|
||||
let totalSizeUploaded = 0;
|
||||
let duplicateCount = 0;
|
||||
let duplicateSize = 0;
|
||||
let successCount = 0;
|
||||
let successSize = 0;
|
||||
|
||||
const newAssets: Asset[] = [];
|
||||
|
||||
try {
|
||||
for (const items of chunk(files, concurrency)) {
|
||||
await Promise.all(
|
||||
items.map(async (filepath) => {
|
||||
const stats = statsMap.get(filepath) as Stats;
|
||||
const response = await uploadFile(filepath, stats);
|
||||
totalSizeUploaded += stats.size ?? 0;
|
||||
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
|
||||
|
||||
newAssets.push({ id: response.id, filepath });
|
||||
|
||||
if (response.duplicate) {
|
||||
duplicateCount++;
|
||||
duplicateSize += stats.size ?? 0;
|
||||
} else {
|
||||
successCount++;
|
||||
successSize += stats.size ?? 0;
|
||||
}
|
||||
|
||||
uploadProgress.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) });
|
||||
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
@@ -166,7 +185,10 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
|
||||
uploadProgress.stop();
|
||||
}
|
||||
|
||||
console.log(`Successfully uploaded ${newAssets.length} asset${s(newAssets.length)} (${byteSize(totalSizeUploaded)})`);
|
||||
console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`);
|
||||
if (duplicateCount > 0) {
|
||||
console.log(`Skipped ${duplicateCount} duplicate asset${s(duplicateCount)} (${byteSize(duplicateSize)})`);
|
||||
}
|
||||
return newAssets;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ module.exports = {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'unicorn/prefer-module': 'off',
|
||||
'unicorn/import-style': 'off',
|
||||
curly: 2,
|
||||
'prettier/prettier': 0,
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
|
||||
Generated
+5
-5
@@ -23,7 +23,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.1",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"exiftool-vendored": "^24.5.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pg": "^8.11.3",
|
||||
@@ -63,7 +63,7 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"glob": "^10.3.1",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
@@ -2343,9 +2343,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-unicorn": {
|
||||
"version": "51.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
|
||||
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
|
||||
"version": "52.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
|
||||
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.1",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"exiftool-vendored": "^24.5.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pg": "^8.11.3",
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('/activity', () => {
|
||||
});
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
|
||||
const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
|
||||
|
||||
const response1 = await request(app)
|
||||
.get('/activity')
|
||||
@@ -250,8 +250,7 @@ describe('/activity', () => {
|
||||
});
|
||||
|
||||
it('should return a 200 for a duplicate like on the album', async () => {
|
||||
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
|
||||
|
||||
const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
@@ -261,13 +260,11 @@ describe('/activity', () => {
|
||||
});
|
||||
|
||||
it('should not confuse an album like with an asset like', async () => {
|
||||
const [reaction] = await Promise.all([
|
||||
createActivity({
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.Like,
|
||||
}),
|
||||
]);
|
||||
const reaction = await createActivity({
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.Like,
|
||||
});
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
@@ -314,13 +311,11 @@ describe('/activity', () => {
|
||||
});
|
||||
|
||||
it('should return a 200 for a duplicate like on an asset', async () => {
|
||||
const [reaction] = await Promise.all([
|
||||
createActivity({
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.Like,
|
||||
}),
|
||||
]);
|
||||
const reaction = await createActivity({
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.Like,
|
||||
});
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('/asset', () => {
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]);
|
||||
user2Assets = [await utils.createAsset(user2.accessToken)];
|
||||
|
||||
await Promise.all([
|
||||
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LoginResponseDto, getAllAlbums, getAllAssets } from '@immich/sdk';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
|
||||
import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
@@ -23,7 +24,7 @@ describe(`immich upload`, () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(stderr).toBe('');
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 asset')]),
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -35,7 +36,7 @@ describe(`immich upload`, () => {
|
||||
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(first.stderr).toBe('');
|
||||
expect(first.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 asset')]),
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||
);
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
@@ -69,7 +70,7 @@ describe(`immich upload`, () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
|
||||
expect(stderr).toBe('');
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -88,7 +89,7 @@ describe(`immich upload`, () => {
|
||||
]);
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully uploaded 9 new assets'),
|
||||
expect.stringContaining('Successfully created 1 new album'),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
]),
|
||||
@@ -107,7 +108,7 @@ describe(`immich upload`, () => {
|
||||
it('should add existing assets to albums', async () => {
|
||||
const response1 = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
|
||||
expect(response1.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
||||
);
|
||||
expect(response1.stderr).toBe('');
|
||||
expect(response1.exitCode).toBe(0);
|
||||
@@ -147,7 +148,7 @@ describe(`immich upload`, () => {
|
||||
]);
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully uploaded 9 new assets'),
|
||||
expect.stringContaining('Successfully created 1 new album'),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
]),
|
||||
@@ -180,7 +181,7 @@ describe(`immich upload`, () => {
|
||||
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully uploaded 9 new assets'),
|
||||
expect.stringContaining('Deleting assets that have been uploaded'),
|
||||
]),
|
||||
);
|
||||
@@ -192,6 +193,32 @@ describe(`immich upload`, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich upload --skip-hash', () => {
|
||||
it('should skip hashing', async () => {
|
||||
const filename = `albums/nature/silver_fir.jpg`;
|
||||
await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
bytes: readFileSync(`${testAssetDir}/${filename}`),
|
||||
filename: 'silver_fit.jpg',
|
||||
},
|
||||
});
|
||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/${filename}`, '--skip-hash']);
|
||||
|
||||
expect(stderr).toBe('');
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Skipping hash check, assuming all files are new',
|
||||
expect.stringContaining('Successfully uploaded 0 new assets'),
|
||||
expect.stringContaining('Skipped 1 duplicate asset'),
|
||||
]),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich upload --concurrency <number>', () => {
|
||||
it('should work', async () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
@@ -203,7 +230,10 @@ describe(`immich upload`, () => {
|
||||
|
||||
expect(stderr).toBe('');
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
|
||||
expect.arrayContaining([
|
||||
'Found 9 new files and 0 duplicates',
|
||||
expect.stringContaining('Successfully uploaded 9 new assets'),
|
||||
]),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { setTimeout } from 'node:timers';
|
||||
|
||||
export default async () => {
|
||||
const setup = async () => {
|
||||
let _resolve: () => unknown;
|
||||
let _reject: (error: Error) => unknown;
|
||||
|
||||
@@ -31,3 +31,5 @@ export default async () => {
|
||||
await new Promise<void>((resolve) => exec('docker compose down', () => resolve()));
|
||||
};
|
||||
};
|
||||
|
||||
export default setup;
|
||||
|
||||
@@ -25,6 +25,7 @@ module.exports = {
|
||||
'unicorn/prefer-top-level-await': 'off',
|
||||
'unicorn/prefer-event-target': 'off',
|
||||
'unicorn/no-thenable': 'off',
|
||||
'unicorn/import-style': 'off',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ RUN apt-get install --no-install-recommends -yqq tini
|
||||
WORKDIR /usr/src/app
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
RUN npm ci && \
|
||||
# sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
||||
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
||||
# they're marked as optional dependencies, so we need to copy them manually after pruning
|
||||
rm -rf node_modules/@img/sharp-libvips* && \
|
||||
rm -rf node_modules/@img/sharp-linuxmusl-x64
|
||||
@@ -22,6 +22,7 @@ FROM dev AS prod
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev --omit=optional
|
||||
COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
|
||||
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
|
||||
|
||||
# web build
|
||||
FROM node:iron-alpine3.18@sha256:fa5d3cf51725bd42d32e67917623038539dbe720dab082f590785c001eb4dfef as web
|
||||
|
||||
Generated
+10
-9
@@ -35,7 +35,6 @@
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"exiftool-vendored": "~24.6.0",
|
||||
"exiftool-vendored.pl": "12.78",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^8.0.0",
|
||||
@@ -89,7 +88,7 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"jest": "^29.6.4",
|
||||
"jest-when": "^3.6.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
@@ -7494,9 +7493,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-unicorn": {
|
||||
"version": "51.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
|
||||
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
|
||||
"version": "52.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
|
||||
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
@@ -7774,6 +7773,7 @@
|
||||
"version": "12.78.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz",
|
||||
"integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"!win32"
|
||||
]
|
||||
@@ -19876,9 +19876,9 @@
|
||||
}
|
||||
},
|
||||
"eslint-plugin-unicorn": {
|
||||
"version": "51.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
|
||||
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
|
||||
"version": "52.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
|
||||
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
@@ -20051,7 +20051,8 @@
|
||||
"exiftool-vendored.pl": {
|
||||
"version": "12.78.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz",
|
||||
"integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg=="
|
||||
"integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==",
|
||||
"optional": true
|
||||
},
|
||||
"exit": {
|
||||
"version": "0.1.2",
|
||||
|
||||
+1
-2
@@ -59,7 +59,6 @@
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"exiftool-vendored": "~24.6.0",
|
||||
"exiftool-vendored.pl": "12.78",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^8.0.0",
|
||||
@@ -113,7 +112,7 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"jest": "^29.6.4",
|
||||
"jest-when": "^3.6.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
|
||||
@@ -695,7 +695,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not schedule delete-files job for readonly assets', async () => {
|
||||
it('should only delete generated files for readonly assets', async () => {
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.readOnly.id, {
|
||||
faces: {
|
||||
@@ -709,7 +709,20 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([]);
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.DELETE_FILES,
|
||||
data: {
|
||||
files: [
|
||||
assetStub.readOnly.thumbnailPath,
|
||||
assetStub.readOnly.previewPath,
|
||||
assetStub.readOnly.encodedVideoPath,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly);
|
||||
});
|
||||
@@ -748,7 +761,6 @@ describe(AssetService.name, () => {
|
||||
assetStub.external.thumbnailPath,
|
||||
assetStub.external.previewPath,
|
||||
assetStub.external.encodedVideoPath,
|
||||
assetStub.external.sidecarPath,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -399,14 +399,12 @@ export class AssetService {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
||||
}
|
||||
|
||||
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath, asset.sidecarPath];
|
||||
if (!fromExternal) {
|
||||
files.push(asset.originalPath);
|
||||
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
|
||||
if (!(asset.isExternal || asset.isReadOnly)) {
|
||||
files.push(asset.sidecarPath, asset.originalPath);
|
||||
}
|
||||
|
||||
if (!asset.isReadOnly) {
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
||||
}
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -210,25 +210,21 @@ describe(MediaService.name, () => {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail for an image', async () => {
|
||||
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
|
||||
|
||||
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||
{
|
||||
size: 1440,
|
||||
format: ImageFormat.JPEG,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
},
|
||||
);
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', previewPath, {
|
||||
size: 1440,
|
||||
format,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath });
|
||||
});
|
||||
|
||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||
@@ -342,25 +338,25 @@ describe(MediaService.name, () => {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
||||
it.each(Object.values(ImageFormat))(
|
||||
'should generate a %s thumbnail for an image when specified',
|
||||
async (format) => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
|
||||
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
||||
{
|
||||
format: ImageFormat.WEBP,
|
||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, {
|
||||
size: 250,
|
||||
format,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
},
|
||||
);
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||
@@ -747,6 +743,67 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v copy',
|
||||
'-c:a aac',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should include hevc tag when target is hevc and copying hevc video stream', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v copy',
|
||||
'-c:a aac',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-tag:v hvc1',
|
||||
'-v verbose',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy audio stream when audio matches target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
|
||||
@@ -1091,9 +1148,9 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable thread pooling for h264 if thread limit is above 0', async () => {
|
||||
it('should disable thread pooling for h264 if thread limit is 1', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
@@ -1111,9 +1168,8 @@ describe(MediaService.name, () => {
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset ultrafast',
|
||||
'-threads 2',
|
||||
'-x264-params "pools=none"',
|
||||
'-x264-params "frame-threads=2"',
|
||||
'-threads 1',
|
||||
'-x264-params frame-threads=1:pools=none',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
@@ -1148,10 +1204,10 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable thread pooling for hevc if thread limit is above 0', async () => {
|
||||
it('should disable thread pooling for hevc if thread limit is 1', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 },
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
@@ -1172,9 +1228,8 @@ describe(MediaService.name, () => {
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset ultrafast',
|
||||
'-threads 2',
|
||||
'-x265-params "pools=none"',
|
||||
'-x265-params "frame-threads=2"',
|
||||
'-threads 1',
|
||||
'-x265-params frame-threads=1:pools=none',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
|
||||
@@ -167,12 +167,15 @@ export class MediaService {
|
||||
}
|
||||
|
||||
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig(),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||
]);
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, ImageFormat.JPEG);
|
||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
|
||||
await this.assetRepository.update({ id: asset.id, previewPath });
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
@@ -210,18 +213,21 @@ export class MediaService {
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
|
||||
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`,
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig(),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||
]);
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, ImageFormat.WEBP);
|
||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
|
||||
await this.assetRepository.update({ id: asset.id, thumbnailPath });
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
+19
-17
@@ -37,9 +37,12 @@ class BaseConfig implements VideoCodecSWConfig {
|
||||
}
|
||||
|
||||
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
||||
const videoCodec = [TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy';
|
||||
const audioCodec = [TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy';
|
||||
|
||||
const options = [
|
||||
`-c:v ${[TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy'}`,
|
||||
`-c:a ${[TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy'}`,
|
||||
`-c:v ${videoCodec}`,
|
||||
`-c:a ${audioCodec}`,
|
||||
// Makes a second pass moving the moov atom to the
|
||||
// beginning of the file for improved playback speed.
|
||||
'-movflags faststart',
|
||||
@@ -61,7 +64,10 @@ class BaseConfig implements VideoCodecSWConfig {
|
||||
options.push(`-g ${this.getGopSize()}`);
|
||||
}
|
||||
|
||||
if (this.config.targetVideoCodec === VideoCodec.HEVC) {
|
||||
if (
|
||||
this.config.targetVideoCodec === VideoCodec.HEVC &&
|
||||
(videoCodec !== 'copy' || videoStream.codecName === 'hevc')
|
||||
) {
|
||||
options.push('-tag:v hvc1');
|
||||
}
|
||||
|
||||
@@ -343,27 +349,23 @@ export class ThumbnailConfig extends BaseConfig {
|
||||
|
||||
export class H264Config extends BaseConfig {
|
||||
getThreadOptions() {
|
||||
if (this.config.threads <= 0) {
|
||||
return [];
|
||||
const options = super.getThreadOptions();
|
||||
if (this.config.threads === 1) {
|
||||
options.push('-x264-params frame-threads=1:pools=none');
|
||||
}
|
||||
return [
|
||||
...super.getThreadOptions(),
|
||||
'-x264-params "pools=none"',
|
||||
`-x264-params "frame-threads=${this.config.threads}"`,
|
||||
];
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
export class HEVCConfig extends BaseConfig {
|
||||
getThreadOptions() {
|
||||
if (this.config.threads <= 0) {
|
||||
return [];
|
||||
const options = super.getThreadOptions();
|
||||
if (this.config.threads === 1) {
|
||||
options.push('-x265-params frame-threads=1:pools=none');
|
||||
}
|
||||
return [
|
||||
...super.getThreadOptions(),
|
||||
'-x265-params "pools=none"',
|
||||
`-x265-params "frame-threads=${this.config.threads}"`,
|
||||
];
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
@@ -173,4 +173,8 @@ export const probeStub = {
|
||||
bitrate: 0,
|
||||
},
|
||||
}),
|
||||
videoStreamH264: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ module.exports = {
|
||||
'unicorn/no-nested-ternary': 'off',
|
||||
'unicorn/consistent-function-scoping': 'off',
|
||||
'unicorn/prefer-top-level-await': 'off',
|
||||
'unicorn/import-style': 'off',
|
||||
// TODO: set recommended-type-checked and remove these rules
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
|
||||
Generated
+4
-4
@@ -45,7 +45,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"eslint-plugin-unicorn": "^51.0.1",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"factory.ts": "^1.4.1",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.2.5",
|
||||
@@ -4167,9 +4167,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/eslint-plugin-unicorn": {
|
||||
"version": "51.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
|
||||
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
|
||||
"version": "52.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
|
||||
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"eslint-plugin-unicorn": "^51.0.1",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"factory.ts": "^1.4.1",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.2.5",
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
<SettingButtonsRow
|
||||
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['ffmpeg'] })}
|
||||
on:save={() => dispatch('save', { ffmpeg: config.ffmpeg })}
|
||||
showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig)}
|
||||
showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig.ffmpeg)}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Colorspace, type SystemConfigDto } from '@immich/sdk';
|
||||
import { Colorspace, ImageFormat, type SystemConfigDto } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -24,6 +24,19 @@
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" on:submit|preventDefault>
|
||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label="THUMBNAIL FORMAT"
|
||||
desc="WebP produces smaller files than JPEG, but is slower to encode."
|
||||
bind:value={config.image.thumbnailFormat}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={config.image.thumbnailFormat !== savedConfig.image.thumbnailFormat}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label="THUMBNAIL RESOLUTION"
|
||||
desc="Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
|
||||
@@ -41,6 +54,19 @@
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label="PREVIEW FORMAT"
|
||||
desc="WebP produces smaller files than JPEG, but is slower to encode."
|
||||
bind:value={config.image.previewFormat}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={config.image.previewFormat !== savedConfig.image.previewFormat}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label="PREVIEW RESOLUTION"
|
||||
desc="Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
|
||||
@@ -81,7 +107,7 @@
|
||||
<SettingButtonsRow
|
||||
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['image'] })}
|
||||
on:save={() => dispatch('save', { image: config.image })}
|
||||
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||
showResetToDefault={!isEqual(savedConfig.image, defaultConfig.image)}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
<SettingButtonsRow
|
||||
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['newVersionCheck'] })}
|
||||
on:save={() => dispatch('save', { newVersionCheck: config.newVersionCheck })}
|
||||
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||
showResetToDefault={!isEqual(savedConfig.newVersionCheck, defaultConfig.newVersionCheck)}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<SettingButtonsRow
|
||||
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['server'] })}
|
||||
on:save={() => dispatch('save', { server: config.server })}
|
||||
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||
showResetToDefault={!isEqual(savedConfig.server, defaultConfig.server)}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -236,7 +236,7 @@
|
||||
<SettingButtonsRow
|
||||
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['storageTemplate'] })}
|
||||
on:save={() => dispatch('save', { storageTemplate: config.storageTemplate })}
|
||||
showResetToDefault={!isEqual(savedConfig, defaultConfig) && !minified}
|
||||
showResetToDefault={!isEqual(savedConfig.storageTemplate, defaultConfig.storageTemplate) && !minified}
|
||||
{disabled}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<SettingButtonsRow
|
||||
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['trash'] })}
|
||||
on:save={() => dispatch('save', { trash: config.trash })}
|
||||
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||
showResetToDefault={!isEqual(savedConfig.trash, defaultConfig.trash)}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<SettingButtonsRow
|
||||
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['user'] })}
|
||||
on:save={() => dispatch('save', { user: config.user })}
|
||||
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||
showResetToDefault={!isEqual(savedConfig.user, defaultConfig.user)}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10',
|
||||
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
|
||||
red: 'bg-red-500 text-white enabled:hover:bg-red-400',
|
||||
green: 'bg-green-600 text-white enabled:hover:bg-green-400/90',
|
||||
green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90',
|
||||
gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
|
||||
'transparent-gray':
|
||||
'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25',
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
export let peopleWithFaces: AssetFaceResponseDto[];
|
||||
export let allPeople: PersonResponseDto[];
|
||||
export let editedPersonIndex: number;
|
||||
export let editedPerson: PersonResponseDto;
|
||||
export let assetType: AssetTypeEnum;
|
||||
export let assetId: string;
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
const handleCreatePerson = async () => {
|
||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
||||
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
|
||||
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||
|
||||
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
||||
{#if searchName == ''}
|
||||
{#each allPeople as person (person.id)}
|
||||
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
|
||||
{#if person.id !== editedPerson.id}
|
||||
<div class="w-fit">
|
||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||
<div class="relative">
|
||||
@@ -255,7 +255,7 @@
|
||||
{/each}
|
||||
{:else}
|
||||
{#each searchedPeople as person (person.id)}
|
||||
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
|
||||
{#if person.id !== editedPerson.id}
|
||||
<div class="w-fit">
|
||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||
<div class="relative">
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
export let assetType: AssetTypeEnum;
|
||||
|
||||
// keep track of the changes
|
||||
let numberOfPersonToCreate: string[] = [];
|
||||
let numberOfAssetFaceGenerated: string[] = [];
|
||||
let peopleToCreate: string[] = [];
|
||||
let assetFaceGenerated: string[] = [];
|
||||
|
||||
// faces
|
||||
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
||||
let selectedPersonToReassign: (PersonResponseDto | null)[];
|
||||
let selectedPersonToCreate: (string | null)[];
|
||||
let editedPersonIndex: number;
|
||||
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
|
||||
let selectedPersonToCreate: Record<string, string> = {};
|
||||
let editedPerson: PersonResponseDto;
|
||||
|
||||
// loading spinners
|
||||
let isShowLoadingDone = false;
|
||||
@@ -49,6 +49,8 @@
|
||||
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
|
||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const thumbnailWidth = '90px';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
refresh: void;
|
||||
@@ -60,8 +62,6 @@
|
||||
const { people } = await getAllPeople({ withHidden: true });
|
||||
allPeople = people;
|
||||
peopleWithFaces = await getFaces({ id: assetId });
|
||||
selectedPersonToCreate = Array.from({ length: peopleWithFaces.length });
|
||||
selectedPersonToReassign = Array.from({ length: peopleWithFaces.length });
|
||||
} catch (error) {
|
||||
handleError(error, "Can't get faces");
|
||||
} finally {
|
||||
@@ -71,12 +71,12 @@
|
||||
}
|
||||
|
||||
const onPersonThumbnail = (personId: string) => {
|
||||
numberOfAssetFaceGenerated.push(personId);
|
||||
assetFaceGenerated.push(personId);
|
||||
if (
|
||||
isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) &&
|
||||
isEqual(assetFaceGenerated, peopleToCreate) &&
|
||||
loaderLoadingDoneTimeout &&
|
||||
automaticRefreshTimeout &&
|
||||
selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length
|
||||
Object.keys(selectedPersonToCreate).length === peopleToCreate.length
|
||||
) {
|
||||
clearTimeout(loaderLoadingDoneTimeout);
|
||||
clearTimeout(automaticRefreshTimeout);
|
||||
@@ -97,36 +97,41 @@
|
||||
dispatch('close');
|
||||
};
|
||||
|
||||
const handleReset = (index: number) => {
|
||||
if (selectedPersonToReassign[index]) {
|
||||
selectedPersonToReassign[index] = null;
|
||||
const handleReset = (id: string) => {
|
||||
if (selectedPersonToReassign[id]) {
|
||||
delete selectedPersonToReassign[id];
|
||||
|
||||
// trigger reactivity
|
||||
selectedPersonToReassign = selectedPersonToReassign;
|
||||
}
|
||||
if (selectedPersonToCreate[index]) {
|
||||
selectedPersonToCreate[index] = null;
|
||||
if (selectedPersonToCreate[id]) {
|
||||
delete selectedPersonToCreate[id];
|
||||
|
||||
// trigger reactivity
|
||||
selectedPersonToCreate = selectedPersonToCreate;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditFaces = async () => {
|
||||
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
|
||||
const numberOfChanges =
|
||||
selectedPersonToCreate.filter((person) => person !== null).length +
|
||||
selectedPersonToReassign.filter((person) => person !== null).length;
|
||||
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
|
||||
|
||||
if (numberOfChanges > 0) {
|
||||
try {
|
||||
for (const [index, peopleWithFace] of peopleWithFaces.entries()) {
|
||||
const personId = selectedPersonToReassign[index]?.id;
|
||||
for (const personWithFace of peopleWithFaces) {
|
||||
const personId = selectedPersonToReassign[personWithFace.id]?.id;
|
||||
|
||||
if (personId) {
|
||||
await reassignFacesById({
|
||||
id: personId,
|
||||
faceDto: { id: peopleWithFace.id },
|
||||
faceDto: { id: personWithFace.id },
|
||||
});
|
||||
} else if (selectedPersonToCreate[index]) {
|
||||
} else if (selectedPersonToCreate[personWithFace.id]) {
|
||||
const data = await createPerson({ personCreateDto: {} });
|
||||
numberOfPersonToCreate.push(data.id);
|
||||
peopleToCreate.push(data.id);
|
||||
await reassignFacesById({
|
||||
id: data.id,
|
||||
faceDto: { id: peopleWithFace.id },
|
||||
faceDto: { id: personWithFace.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,7 +146,7 @@
|
||||
}
|
||||
|
||||
isShowLoadingDone = false;
|
||||
if (numberOfPersonToCreate.length === 0) {
|
||||
if (peopleToCreate.length === 0) {
|
||||
clearTimeout(loaderLoadingDoneTimeout);
|
||||
dispatch('refresh');
|
||||
} else {
|
||||
@@ -150,23 +155,26 @@
|
||||
};
|
||||
|
||||
const handleCreatePerson = (newFeaturePhoto: string | null) => {
|
||||
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
|
||||
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||
if (newFeaturePhoto && personToUpdate) {
|
||||
selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto;
|
||||
selectedPersonToCreate[personToUpdate.id] = newFeaturePhoto;
|
||||
}
|
||||
showSeletecFaces = false;
|
||||
};
|
||||
|
||||
const handleReassignFace = (person: PersonResponseDto | null) => {
|
||||
if (person) {
|
||||
selectedPersonToReassign[editedPersonIndex] = person;
|
||||
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||
if (person && personToUpdate) {
|
||||
selectedPersonToReassign[personToUpdate.id] = person;
|
||||
showSeletecFaces = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePersonPicker = (index: number) => {
|
||||
editedPersonIndex = index;
|
||||
showSeletecFaces = true;
|
||||
const handlePersonPicker = (person: PersonResponseDto | null) => {
|
||||
if (person) {
|
||||
editedPerson = person;
|
||||
showSeletecFaces = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -217,35 +225,48 @@
|
||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={selectedPersonToCreate[index] ||
|
||||
getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)}
|
||||
altText={selectedPersonToReassign[index]
|
||||
? selectedPersonToReassign[index]?.name
|
||||
: selectedPersonToCreate[index]
|
||||
? 'New person'
|
||||
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
|
||||
title={selectedPersonToReassign[index]
|
||||
? selectedPersonToReassign[index]?.name
|
||||
: selectedPersonToCreate[index]
|
||||
? 'New person'
|
||||
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={selectedPersonToReassign[index]
|
||||
? selectedPersonToReassign[index]?.isHidden
|
||||
: selectedPersonToCreate[index]
|
||||
? false
|
||||
: face.person?.isHidden}
|
||||
/>
|
||||
{#if selectedPersonToCreate[face.id]}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={selectedPersonToCreate[face.id]}
|
||||
altText={selectedPersonToCreate[face.id]}
|
||||
title={'New person'}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
/>
|
||||
{:else if selectedPersonToReassign[face.id]}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
|
||||
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
|
||||
title={getPersonNameWithHiddenValue(
|
||||
selectedPersonToReassign[face.id].name,
|
||||
face.person?.isHidden,
|
||||
)}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||
/>
|
||||
{:else}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(face.person.id)}
|
||||
altText={face.person.name || face.person.id}
|
||||
title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={face.person.isHidden}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !selectedPersonToCreate[index]}
|
||||
|
||||
{#if !selectedPersonToCreate[face.id]}
|
||||
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
||||
{#if selectedPersonToReassign[index]?.id}
|
||||
{selectedPersonToReassign[index]?.name}
|
||||
{#if selectedPersonToReassign[face.id]?.id}
|
||||
{selectedPersonToReassign[face.id]?.name}
|
||||
{:else}
|
||||
{face.person?.name}
|
||||
{/if}
|
||||
@@ -253,8 +274,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
|
||||
{#if selectedPersonToCreate[index] || selectedPersonToReassign[index]}
|
||||
<button on:click={() => handleReset(index)} class="flex h-full w-full">
|
||||
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
|
||||
<button on:click={() => handleReset(face.id)} class="flex h-full w-full">
|
||||
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||
<div>
|
||||
<Icon path={mdiRestart} size={18} />
|
||||
@@ -262,7 +283,7 @@
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<button on:click={() => handlePersonPicker(index)} class="flex h-full w-full">
|
||||
<button on:click={() => handlePersonPicker(face.person)} class="flex h-full w-full">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
|
||||
/>
|
||||
@@ -282,7 +303,7 @@
|
||||
<AssignFaceSidePanel
|
||||
{peopleWithFaces}
|
||||
{allPeople}
|
||||
{editedPersonIndex}
|
||||
{editedPerson}
|
||||
{assetType}
|
||||
{assetId}
|
||||
on:close={() => (showSeletecFaces = false)}
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImmichLogo from './immich-logo.svelte';
|
||||
export let dropHandler: (event: DragEvent) => void;
|
||||
import { page } from '$app/stores';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { fileUploadHandler } from '$lib/utils/file-uploader';
|
||||
|
||||
$: albumId = ($page.route?.id === '/(user)/albums/[albumId]' || undefined) && $page.params.albumId;
|
||||
$: isShare = $page.route?.id === '/(user)/share/[key]' || undefined;
|
||||
|
||||
let dragStartTarget: EventTarget | null = null;
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
||||
dragStartTarget = e.target;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:body
|
||||
on:dragenter|stopPropagation|preventDefault={handleDragEnter}
|
||||
on:dragleave|stopPropagation|preventDefault={(e) => {
|
||||
const onDragLeave = (e: DragEvent) => {
|
||||
if (dragStartTarget === e.target) {
|
||||
dragStartTarget = null;
|
||||
}
|
||||
}}
|
||||
on:drop|stopPropagation|preventDefault={(e) => {
|
||||
};
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
dragStartTarget = null;
|
||||
dropHandler(e);
|
||||
}}
|
||||
await handleFiles(e.dataTransfer?.files);
|
||||
};
|
||||
|
||||
const onPaste = ({ clipboardData }: ClipboardEvent) => handleFiles(clipboardData?.files);
|
||||
|
||||
const handleFiles = async (files?: FileList) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesArray: File[] = Array.from<File>(files);
|
||||
if (isShare) {
|
||||
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
|
||||
} else {
|
||||
await fileUploadHandler(filesArray, albumId);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:paste={onPaste} />
|
||||
|
||||
<svelte:body
|
||||
on:dragenter|stopPropagation|preventDefault={onDragEnter}
|
||||
on:dragleave|stopPropagation|preventDefault={onDragLeave}
|
||||
on:drop|stopPropagation|preventDefault={onDrop}
|
||||
/>
|
||||
|
||||
{#if dragStartTarget}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { shortcut } from '$lib/utils/shortcut';
|
||||
import { shortcuts } from '$lib/utils/shortcut';
|
||||
import { focusOutside } from '$lib/utils/focus-outside';
|
||||
|
||||
export let value = '';
|
||||
@@ -87,12 +87,11 @@
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: () => {
|
||||
onFocusOut();
|
||||
},
|
||||
}}
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onFocusOut },
|
||||
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input.focus() },
|
||||
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }} use:focusOutside={{ onFocusOut }}>
|
||||
@@ -130,12 +129,10 @@
|
||||
on:click={onFocusIn}
|
||||
on:focus={onFocusIn}
|
||||
disabled={showFilter}
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: () => {
|
||||
onFocusOut();
|
||||
},
|
||||
}}
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onFocusOut },
|
||||
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
general: [
|
||||
{ key: ['←', '→'], action: 'Previous or next photo' },
|
||||
{ key: ['Esc'], action: 'Back, close, or deselect' },
|
||||
{ key: ['/'], action: 'Search your photos' },
|
||||
{ key: ['Ctrl', 'k'], action: 'Search your photos' },
|
||||
{ key: ['Ctrl', '⇧', 'k'], action: 'Open the search filters' },
|
||||
],
|
||||
actions: [
|
||||
{ key: ['f'], action: 'Favorite or unfavorite photo' },
|
||||
@@ -40,7 +41,7 @@
|
||||
<FullScreenModal onClose={() => dispatch('close')}>
|
||||
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
|
||||
<div
|
||||
class="w-[400px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[650px]"
|
||||
class="rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
>
|
||||
<div class="relative px-4 pt-4">
|
||||
<h1 class="px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">Keyboard Shortcuts</h1>
|
||||
@@ -54,7 +55,7 @@
|
||||
<h2>General</h2>
|
||||
<div class="text-sm">
|
||||
{#each shortcuts.general as shortcut}
|
||||
<div class="grid grid-cols-[20%_80%] items-center gap-4 pt-4 text-sm">
|
||||
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
||||
<div class="flex justify-self-end">
|
||||
{#each shortcut.key as key}
|
||||
<p
|
||||
@@ -74,7 +75,7 @@
|
||||
<h2>Actions</h2>
|
||||
<div class="text-sm">
|
||||
{#each shortcuts.actions as shortcut}
|
||||
<div class="grid grid-cols-[20%_80%] items-center gap-4 pt-4 text-sm">
|
||||
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
||||
<div class="flex justify-self-end">
|
||||
{#each shortcut.key as key}
|
||||
<p
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
mdiMagnify,
|
||||
mdiMap,
|
||||
mdiTrashCanOutline,
|
||||
mdiTuneVariant,
|
||||
} from '@mdi/js';
|
||||
import LoadingSpinner from '../loading-spinner.svelte';
|
||||
import StatusBox from '../status-box.svelte';
|
||||
@@ -146,12 +145,6 @@
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
{/if}
|
||||
|
||||
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
|
||||
<p class="hidden p-6 group-hover:sm:block md:block">AUTOMATION</p>
|
||||
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
|
||||
</div>
|
||||
<SideBarLink title="Workflows" routeId="/(user)/workflows" icon={mdiTuneVariant} />
|
||||
</nav>
|
||||
|
||||
<!-- Status Box -->
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import { mdiPencil, mdiTrashCan } from '@mdi/js';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-zinc-300 dark:bg-zinc-800 text-black dark:text-white min-h-[60px] grid grid-cols-[15%_70%_15%] place-items-center place-content-center rounded-2xl mx-4 mt-2 p-2"
|
||||
>
|
||||
<p class="col-start-2 col-span-1">Add to album "RANDOM"</p>
|
||||
|
||||
<div class="col-start-3 col-span-1 flex gap-2 justify-self-end">
|
||||
<CircleIconButton size="20" padding="2" title="Remove rule" icon={mdiTrashCan} />
|
||||
<CircleIconButton size="20" padding="2" title="Edit rule" icon={mdiPencil} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import { mdiPencil, mdiTrashCan } from '@mdi/js';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-zinc-200 text-black dark:bg-zinc-700 dark:text-white min-h-[60px] grid grid-cols-[15%_70%_15%] place-items-center place-content-center rounded-2xl mx-4 mt-2 p-2"
|
||||
>
|
||||
<p class="col-start-2 col-span-1">And has Alex and Henry and Nate</p>
|
||||
|
||||
<div class="col-start-3 col-span-1 flex gap-2 justify-self-end">
|
||||
<CircleIconButton size="20" padding="2" title="Remove rule" icon={mdiTrashCan} />
|
||||
<CircleIconButton size="20" padding="2" title="Edit rule" icon={mdiPencil} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +0,0 @@
|
||||
<div
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black min-h-[60px] flex place-items-center place-content-center mx-4 mt-2 rounded-2xl"
|
||||
>
|
||||
When an asset is uploaded
|
||||
</div>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script lang="ts">
|
||||
// export let onSelect: () => void;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-3xl mr-2 my-4 min-h-[60px] grid grid-cols-[32px_1fr] gap-2 place-items-center p-4 place-content-center bg-gray-100 hover:bg-gray-200 dark:bg-gray-900 hover:dark:bg-gray-800"
|
||||
>
|
||||
<div class="w-4 h-4 rounded-full bg-green-400"></div>
|
||||
<p class="dark:text-gray-300">Add this photo to every albums and notify everybody about this glorious asset</p>
|
||||
</div>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import WorkflowActionCard from '$lib/components/workflow-page/editor/workflow-action-card.svelte';
|
||||
import WorkflowRuleCard from '$lib/components/workflow-page/editor/workflow-rule-card.svelte';
|
||||
import WorkflowTriggerCard from '$lib/components/workflow-page/editor/workflow-trigger-card.svelte';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
</script>
|
||||
|
||||
<section class="h-full overflow-scroll">
|
||||
<div
|
||||
id="workflow-control-bar"
|
||||
class="sticky top-0 flex justify-between place-items-center border-b border-gray-200 dark:border-gray-800 p-4 bg-zinc-50 dark:bg-zinc-900 z-20"
|
||||
>
|
||||
<p class="uppercase text-lg dark:text-white font-medium">
|
||||
Add this photo to every albums and notify everybody about this glorious asset
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" color="red">Discard</Button>
|
||||
<Button size="sm">Disable</Button>
|
||||
<Button size="sm" color="green">Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workflows-selection">
|
||||
<!-- TRIGGER BLOCK -->
|
||||
<div class="translate-y-3">
|
||||
<p class="pl-4 text-xs dark:text-gray-300">TRIGGER</p>
|
||||
<WorkflowTriggerCard />
|
||||
</div>
|
||||
|
||||
<!-- VISUAL CONNECTOR -->
|
||||
<div class="relative w-full grid grid-cols-3 place-items-center place-content-center z-10">
|
||||
<p class="col-start-1 col-span-1 justify-self-start self-end pl-4 pb-6 text-xs dark:text-gray-300">RULES</p>
|
||||
<div class="col-start-2 col-span-1 flex flex-col place-items-center">
|
||||
<div
|
||||
class="rounded-full border-[6px] border-immich-primary dark:border-immich-dark-primary h-[20px] w-[20px] bg-white translate-y-1"
|
||||
></div>
|
||||
<div
|
||||
class="h-[60px] w-[5px] bg-white bg-gradient-to-b from-immich-primary dark:from-immich-dark-primary via-zinc-300 dark:via-gray-600 dark:to-zinc-700"
|
||||
></div>
|
||||
<div
|
||||
class="rounded-full border-[6px] border-zinc-200 dark:border-gray-700 h-[20px] w-[20px] bg-white -translate-y-1"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RULES BLOCK -->
|
||||
<div id="rule-block" class="-translate-y-6">
|
||||
<WorkflowRuleCard />
|
||||
<WorkflowRuleCard />
|
||||
|
||||
<div
|
||||
class="border-2 dark:border-gray-700 dark:text-white min-h-[60px] flex place-items-center place-content-center rounded-2xl mx-4 mt-2"
|
||||
>
|
||||
<span><Icon path={mdiPlus} /></span> ADD RULE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VISUAL CONNECTOR -->
|
||||
<div class="relative w-full grid grid-cols-3 place-items-center place-content-center -translate-y-9 z-10">
|
||||
<p class="col-start-1 col-span-1 justify-self-start self-end pl-4 pb-6 text-xs dark:text-gray-300">ACTIONS</p>
|
||||
<div class="col-start-2 col-span-1 flex flex-col place-items-center">
|
||||
<div class="rounded-full border-[6px] border-gray-900 h-[20px] w-[20px] bg-white translate-y-1"></div>
|
||||
<div class="h-[60px] w-[5px] bg-white bg-gradient-to-b from-gray-900 via-indigo-800 to-gray-700"></div>
|
||||
<div class="rounded-full border-[6px] border-gray-700 h-[20px] w-[20px] bg-white -translate-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACTION BLOCK -->
|
||||
<div id="action-block" class="-translate-y-14">
|
||||
<WorkflowActionCard />
|
||||
<WorkflowActionCard />
|
||||
<div
|
||||
class="border-2 dark:border-gray-500 dark:text-white min-h-[60px] flex place-items-center place-content-center rounded-2xl mx-4 mt-2"
|
||||
>
|
||||
<span><Icon path={mdiPlus} /></span> ADD ACTION
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import WorkflowCard from '$lib/components/workflow-page/workflow-card.svelte';
|
||||
</script>
|
||||
|
||||
<section id="workflow-list" class="border-r border-gray-200 dark:border-gray-800 h-full relative overflow-scroll pr-2">
|
||||
<div class="sticky top-0 dark:bg-immich-dark-bg flex justify-between place-items-center pr-2 py-4 bg-immich-bg">
|
||||
<p class="text-xs dark:text-white">CURRENT WORKFLOWS</p>
|
||||
<Button size="sm">New Workflow</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#each Array.from({ length: 50 }) as _}
|
||||
<WorkflowCard />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,29 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { fileUploadHandler } from '$lib/utils/file-uploader';
|
||||
|
||||
let albumId: string | undefined;
|
||||
|
||||
const dropHandler = async ({ dataTransfer }: DragEvent) => {
|
||||
const files = dataTransfer?.files;
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesArray: File[] = Array.from<File>(files);
|
||||
albumId = ($page.route.id === '/(user)/albums/[albumId]' || undefined) && $page.params.albumId;
|
||||
|
||||
const isShare = $page.route.id === '/(user)/share/[key]' || undefined;
|
||||
if (isShare) {
|
||||
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
|
||||
} else {
|
||||
await fileUploadHandler(filesArray, albumId);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<slot {albumId} />
|
||||
|
||||
<UploadCover {dropHandler} />
|
||||
<slot />
|
||||
<UploadCover />
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import WorkflowEditor from '$lib/components/workflow-page/workflow-editor.svelte';
|
||||
import WorkflowList from '$lib/components/workflow-page/workflow-list.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title} scrollbar={false}>
|
||||
<section class="grid grid-cols-[25%_1fr] h-full">
|
||||
<WorkflowList />
|
||||
<WorkflowEditor />
|
||||
</section>
|
||||
</UserPageLayout>
|
||||
@@ -1,12 +0,0 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
await authenticate();
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: 'Workflows',
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -19,7 +19,6 @@
|
||||
import '../app.css';
|
||||
|
||||
let showNavigationLoadingBar = false;
|
||||
let albumId: string | undefined;
|
||||
|
||||
const isSharedLinkRoute = (route: string | null) => route?.startsWith('/(user)/share/[key]');
|
||||
|
||||
@@ -111,7 +110,7 @@
|
||||
</FullscreenContainer>
|
||||
</noscript>
|
||||
|
||||
<slot {albumId} />
|
||||
<slot />
|
||||
|
||||
{#if showNavigationLoadingBar}
|
||||
<NavigationLoadingBar />
|
||||
|
||||
Reference in New Issue
Block a user