Compare commits
17 Commits
feat/prelo
...
fix/scroll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e628e6a807 | ||
|
|
971ba63447 | ||
|
|
d5ee823fbc | ||
|
|
26f33652e1 | ||
|
|
c86fa81e47 | ||
|
|
42ad3e6bb0 | ||
|
|
a6e703ed6b | ||
|
|
b6f871786c | ||
|
|
62a490eca2 | ||
|
|
60679a6369 | ||
|
|
63ad3c8373 | ||
|
|
ad0dbf0315 | ||
|
|
b2f2be3485 | ||
|
|
1ef2834603 | ||
|
|
35e03c1d6f | ||
|
|
005528ab5e | ||
|
|
8d515adac5 |
@@ -33,6 +33,7 @@
|
|||||||
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
|
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
|
||||||
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
|
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
|
||||||
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
||||||
|
<a href="readme_i18n/README_vi_VN.md">Tiếng Việt</a>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.19",
|
"version": "2.2.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.19",
|
"version": "2.2.20",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.19",
|
"version": "2.2.20",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
|
|||||||
@@ -333,7 +333,11 @@ You may need to add mount points or docker volumes for the following internal co
|
|||||||
- `immich-machine-learning:/.cache`
|
- `immich-machine-learning:/.cache`
|
||||||
- `redis:/data`
|
- `redis:/data`
|
||||||
|
|
||||||
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
|
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION` and `/cache` for machine-learning.
|
||||||
|
|
||||||
|
:::note Docker Compose Volumes
|
||||||
|
The Docker Compose top level volume element does not support non-root access, all of the above volumes must be local volume mounts.
|
||||||
|
:::
|
||||||
|
|
||||||
For a further hardened system, you can add the following block to every container except for `immich_postgres`.
|
For a further hardened system, you can add the following block to every container except for `immich_postgres`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
# Libraries
|
# External Libraries
|
||||||
|
|
||||||
## Overview
|
External libraries track assets stored in the filesystem outside of Immich. When the external library is scanned, Immich will load videos and photos from disk and create the corresponding assets. These assets will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. Later, if a file is modified outside of Immich, you need to scan the library for the changes to show up.
|
||||||
|
|
||||||
Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries.
|
If an external asset is deleted from disk, Immich will move it to trash on rescan. To restore the asset, you need to restore the original file. After 30 days the file will be removed from trash, and any changes to metadata within Immich will be lost.
|
||||||
|
|
||||||
## External Libraries
|
:::caution
|
||||||
|
|
||||||
External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc.
|
If you add metadata to an external asset in any way (i.e. add it to an album or edit the description), that metadata is only stored inside Immich and will not be persisted to the external asset file. If you move an asset to another location within the library all such metadata will be lost upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release.
|
||||||
|
|
||||||
If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case:
|
:::
|
||||||
|
|
||||||
- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets
|
|
||||||
- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files.
|
|
||||||
- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk.
|
|
||||||
|
|
||||||
:::caution
|
:::caution
|
||||||
|
|
||||||
@@ -20,22 +16,6 @@ Due to aggressive caching it can take some time for a refreshed asset to appear
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries.
|
|
||||||
|
|
||||||
:::caution
|
|
||||||
|
|
||||||
If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
### Deleted External Assets
|
|
||||||
|
|
||||||
Note: Either a manual or scheduled library scan must have been performed to identify offline assets before this process will work.
|
|
||||||
|
|
||||||
In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored.
|
|
||||||
|
|
||||||
Finally, files can be deleted from Immich via the `Remove Offline Files` job. This job can be found by the three dots menu for the associated external storage that was configured under Administration > Libraries (the same location described at [create external libraries](#create-external-libraries)). When this job is run, any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich.
|
|
||||||
|
|
||||||
### Import Paths
|
### Import Paths
|
||||||
|
|
||||||
External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible.
|
External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible.
|
||||||
@@ -66,9 +46,13 @@ Some basic examples:
|
|||||||
- `**/Raw/**` will exclude all files in any directory named `Raw`
|
- `**/Raw/**` will exclude all files in any directory named `Raw`
|
||||||
- `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg`
|
- `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg`
|
||||||
|
|
||||||
|
Special characters such as @ should be escaped, for instance:
|
||||||
|
|
||||||
|
- `**/\@eadir/**` will exclude all files in any directory named `@eadir`
|
||||||
|
|
||||||
### Automatic watching (EXPERIMENTAL)
|
### Automatic watching (EXPERIMENTAL)
|
||||||
|
|
||||||
This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button.
|
This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan.
|
||||||
|
|
||||||
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes.
|
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes.
|
||||||
|
|
||||||
@@ -84,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up.
|
|||||||
|
|
||||||
### Nightly job
|
### Nightly job
|
||||||
|
|
||||||
There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion.
|
There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -120,7 +104,7 @@ This will disallow the images from being deleted in the web UI, or adding metada
|
|||||||
_Remember to run `docker compose up -d` to register the changes. Make sure you can see the mounted path in the container._
|
_Remember to run `docker compose up -d` to register the changes. Make sure you can see the mounted path in the container._
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Create External Libraries
|
### Create A New Library
|
||||||
|
|
||||||
These actions must be performed by the Immich administrator.
|
These actions must be performed by the Immich administrator.
|
||||||
|
|
||||||
@@ -144,7 +128,7 @@ Next, we'll add an exclusion pattern to filter out raw files.
|
|||||||
- Enter `**/Raw/**` and click save.
|
- Enter `**/Raw/**` and click save.
|
||||||
- Click save
|
- Click save
|
||||||
- Click the drop-down menu on the newly created library
|
- Click the drop-down menu on the newly created library
|
||||||
- Click on Scan Library Files
|
- Click on Scan
|
||||||
|
|
||||||
The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library.
|
The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library.
|
||||||
|
|
||||||
@@ -161,7 +145,7 @@ If you get an error here, please rename the other external library to something
|
|||||||
- Click on Add Path
|
- Click on Add Path
|
||||||
- Enter `/mnt/media/videos` then click Add
|
- Enter `/mnt/media/videos` then click Add
|
||||||
- Click Save
|
- Click Save
|
||||||
- Click on Scan Library Files
|
- Click on Scan
|
||||||
|
|
||||||
Within seconds, the assets from the old-pics and videos folders should show up in the main timeline.
|
Within seconds, the assets from the old-pics and videos folders should show up in the main timeline.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
mdiLeadPencil,
|
mdiLeadPencil,
|
||||||
mdiLockOff,
|
mdiLockOff,
|
||||||
mdiLockOutline,
|
mdiLockOutline,
|
||||||
|
mdiMicrosoftWindows,
|
||||||
mdiSecurity,
|
mdiSecurity,
|
||||||
mdiSpeedometerSlow,
|
mdiSpeedometerSlow,
|
||||||
mdiTrashCan,
|
mdiTrashCan,
|
||||||
@@ -21,6 +22,18 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri
|
|||||||
type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date };
|
type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date };
|
||||||
|
|
||||||
const items: Item[] = [
|
const items: Item[] = [
|
||||||
|
{
|
||||||
|
icon: mdiMicrosoftWindows,
|
||||||
|
iconColor: '#357EC7',
|
||||||
|
title: 'Hidden files in Windows are cursed',
|
||||||
|
description:
|
||||||
|
'Hidden files in Windows cannot be opened with the "w" flag. That, combined with SMB option "hide dot files" leads to a lot of confusion.',
|
||||||
|
link: {
|
||||||
|
url: 'https://github.com/immich-app/immich/pull/12812',
|
||||||
|
text: '#12812',
|
||||||
|
},
|
||||||
|
date: new Date(2024, 8, 20),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: mdiWrap,
|
icon: mdiWrap,
|
||||||
iconColor: 'gray',
|
iconColor: 'gray',
|
||||||
|
|||||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v1.116.0",
|
||||||
|
"url": "https://v1.116.0.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.115.0",
|
"label": "v1.115.0",
|
||||||
"url": "https://v1.115.0.archive.immich.app"
|
"url": "https://v1.115.0.archive.immich.app"
|
||||||
|
|||||||
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.1.0",
|
"@eslint/eslintrc": "^3.1.0",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
},
|
},
|
||||||
"../cli": {
|
"../cli": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.19",
|
"version": "2.2.20",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk';
|
||||||
LibraryResponseDto,
|
|
||||||
LoginResponseDto,
|
|
||||||
ScanLibraryDto,
|
|
||||||
getAllLibraries,
|
|
||||||
removeOfflineFiles,
|
|
||||||
scanLibrary,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { cpSync, existsSync } from 'node:fs';
|
import { cpSync, existsSync } from 'node:fs';
|
||||||
import { Socket } from 'socket.io-client';
|
import { Socket } from 'socket.io-client';
|
||||||
import { userDto, uuidDto } from 'src/fixtures';
|
import { userDto, uuidDto } from 'src/fixtures';
|
||||||
@@ -15,8 +8,7 @@ import request from 'supertest';
|
|||||||
import { utimes } from 'utimes';
|
import { utimes } from 'utimes';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) =>
|
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
|
||||||
scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) });
|
|
||||||
|
|
||||||
describe('/libraries', () => {
|
describe('/libraries', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
@@ -293,14 +285,19 @@ describe('/libraries', () => {
|
|||||||
expect(body).toEqual(errorDto.unauthorized);
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should scan external library', async () => {
|
it('should import new asset when scanning external library', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp/directoryA`],
|
importPaths: [`${testAssetDirInternal}/temp/directoryA`],
|
||||||
});
|
});
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
const { status } = await request(app)
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 });
|
.post(`/libraries/${library.id}/scan`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send();
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, {
|
const { assets } = await utils.metadataSearch(admin.accessToken, {
|
||||||
originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,
|
originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,
|
||||||
@@ -315,8 +312,13 @@ describe('/libraries', () => {
|
|||||||
exclusionPatterns: ['**/directoryA'],
|
exclusionPatterns: ['**/directoryA'],
|
||||||
});
|
});
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
const { status } = await request(app)
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 });
|
.post(`/libraries/${library.id}/scan`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send();
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
@@ -330,8 +332,13 @@ describe('/libraries', () => {
|
|||||||
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
|
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
|
||||||
});
|
});
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
const { status } = await request(app)
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
|
.post(`/libraries/${library.id}/scan`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send();
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
@@ -340,95 +347,144 @@ describe('/libraries', () => {
|
|||||||
expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined();
|
expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pick up new files', async () => {
|
it('should reimport a modified file', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
});
|
});
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
|
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
|
||||||
|
|
||||||
expect(assets.count).toBe(2);
|
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
await scan(admin.accessToken, library.id);
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 });
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`);
|
||||||
|
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001);
|
||||||
|
|
||||||
expect(newAssets.count).toBe(3);
|
const { status } = await request(app)
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
|
.post(`/libraries/${library.id}/scan`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({ refreshModifiedFiles: true });
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
||||||
|
|
||||||
|
const { assets } = await utils.metadataSearch(admin.accessToken, {
|
||||||
|
libraryId: library.id,
|
||||||
|
model: 'NIKON D750',
|
||||||
|
});
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should offline a file missing from disk', async () => {
|
it('should not reimport unmodified files', async () => {
|
||||||
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
||||||
|
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`);
|
||||||
|
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post(`/libraries/${library.id}/scan`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({ refreshModifiedFiles: true });
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
||||||
|
|
||||||
|
const { assets } = await utils.metadataSearch(admin.accessToken, {
|
||||||
|
libraryId: library.id,
|
||||||
|
model: 'NIKON D750',
|
||||||
|
});
|
||||||
|
expect(assets.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 scan(admin.accessToken, library.id);
|
await scan(admin.accessToken, library.id);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
expect(assets.count).toBe(3);
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
|
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post(`/libraries/${library.id}/scan`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send();
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
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.metadataSearch(admin.accessToken, { libraryId: library.id });
|
const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
expect(newAssets.count).toBe(3);
|
expect(newAssets.items).toEqual([]);
|
||||||
|
|
||||||
expect(newAssets.items).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
isOffline: true,
|
|
||||||
originalFileName: 'assetC.png',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should offline a file outside of import paths', async () => {
|
it('should set an asset offline its file is not in any import path', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
await scan(admin.accessToken, library.id);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
|
utils.createDirectory(`${testAssetDir}/temp/another-path/`);
|
||||||
|
|
||||||
await request(app)
|
await request(app)
|
||||||
.put(`/libraries/${library.id}`)
|
.put(`/libraries/${library.id}`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ importPaths: [`${testAssetDirInternal}/temp/directoryA`] });
|
.send({ importPaths: [`${testAssetDirInternal}/temp/another-path/`] });
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post(`/libraries/${library.id}/scan`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send();
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: 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);
|
||||||
|
|
||||||
expect(assets.items).toEqual(
|
const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
expect(newAssets.items).toEqual([]);
|
||||||
isOffline: false,
|
|
||||||
originalFileName: 'assetA.png',
|
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
}),
|
utils.removeDirectory(`${testAssetDir}/temp/another-path/`);
|
||||||
expect.objectContaining({
|
|
||||||
isOffline: true,
|
|
||||||
originalFileName: 'assetB.png',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should offline a file covered by an exclusion pattern', async () => {
|
it('should set an asset offline if its file is covered by an exclusion pattern', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
@@ -437,6 +493,12 @@ describe('/libraries', () => {
|
|||||||
await scan(admin.accessToken, library.id);
|
await scan(admin.accessToken, library.id);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const { assets } = await utils.metadataSearch(admin.accessToken, {
|
||||||
|
libraryId: library.id,
|
||||||
|
originalFileName: 'assetB.png',
|
||||||
|
});
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
await request(app)
|
await request(app)
|
||||||
.put(`/libraries/${library.id}`)
|
.put(`/libraries/${library.id}`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
@@ -445,282 +507,21 @@ describe('/libraries', () => {
|
|||||||
await scan(admin.accessToken, library.id);
|
await scan(admin.accessToken, library.id);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: 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);
|
||||||
|
|
||||||
expect(assets.count).toBe(2);
|
const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
expect(assets.items).toEqual(
|
expect(newAssets.items).toEqual([
|
||||||
expect.arrayContaining([
|
expect.objectContaining({
|
||||||
expect.objectContaining({
|
originalFileName: 'assetA.png',
|
||||||
isOffline: false,
|
}),
|
||||||
originalFileName: 'assetA.png',
|
]);
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
isOffline: true,
|
|
||||||
originalFileName: 'assetB.png',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not try to delete offline files', async () => {
|
it('should not trash an online asset', async () => {
|
||||||
utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`);
|
|
||||||
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
|
||||||
ownerId: admin.userId,
|
|
||||||
importPaths: [`${testAssetDirInternal}/temp/offline1`],
|
|
||||||
});
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
|
||||||
expect(initialAssets).toEqual({
|
|
||||||
count: 1,
|
|
||||||
total: 1,
|
|
||||||
facets: [],
|
|
||||||
items: [expect.objectContaining({ originalFileName: 'assetA.png' })],
|
|
||||||
nextPage: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, {
|
|
||||||
libraryId: library.id,
|
|
||||||
isOffline: true,
|
|
||||||
});
|
|
||||||
expect(offlineAssets).toEqual({
|
|
||||||
count: 1,
|
|
||||||
total: 1,
|
|
||||||
facets: [],
|
|
||||||
items: [expect.objectContaining({ originalFileName: 'assetA.png' })],
|
|
||||||
nextPage: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`);
|
|
||||||
await removeOfflineFiles({ id: library.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 });
|
|
||||||
|
|
||||||
expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true);
|
|
||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should scan new files', async () => {
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
|
||||||
ownerId: admin.userId,
|
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
|
||||||
});
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/directoryC/assetC.png`);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
|
||||||
|
|
||||||
expect(assets.count).toBe(3);
|
|
||||||
expect(assets.items).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
originalFileName: 'assetC.png',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/directoryC/assetC.png`);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with refreshModifiedFiles=true', () => {
|
|
||||||
it('should reimport modified files', async () => {
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
|
||||||
ownerId: admin.userId,
|
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
|
||||||
});
|
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id, { refreshModifiedFiles: true });
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, {
|
|
||||||
libraryId: library.id,
|
|
||||||
model: 'NIKON D750',
|
|
||||||
});
|
|
||||||
expect(assets.count).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not reimport unmodified files', async () => {
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
|
||||||
ownerId: admin.userId,
|
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
|
||||||
});
|
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id, { refreshModifiedFiles: true });
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, {
|
|
||||||
libraryId: library.id,
|
|
||||||
model: 'NIKON D750',
|
|
||||||
});
|
|
||||||
expect(assets.count).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with refreshAllFiles=true', () => {
|
|
||||||
it('should reimport all files', async () => {
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
|
||||||
ownerId: admin.userId,
|
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
|
||||||
});
|
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id, { refreshAllFiles: true });
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
|
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, {
|
|
||||||
libraryId: library.id,
|
|
||||||
model: 'NIKON D750',
|
|
||||||
});
|
|
||||||
expect(assets.count).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /libraries/:id/removeOffline', () => {
|
|
||||||
it('should require authentication', async () => {
|
|
||||||
const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/removeOffline`).send({});
|
|
||||||
|
|
||||||
expect(status).toBe(401);
|
|
||||||
expect(body).toEqual(errorDto.unauthorized);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove offline files', async () => {
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
|
||||||
ownerId: admin.userId,
|
|
||||||
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
|
||||||
});
|
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/offline/online.png`);
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, {
|
|
||||||
libraryId: library.id,
|
|
||||||
});
|
|
||||||
expect(initialAssets.count).toBe(2);
|
|
||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, {
|
|
||||||
libraryId: library.id,
|
|
||||||
isOffline: true,
|
|
||||||
});
|
|
||||||
expect(offlineAssets.count).toBe(1);
|
|
||||||
|
|
||||||
const { status } = await request(app)
|
|
||||||
.post(`/libraries/${library.id}/removeOffline`)
|
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
|
||||||
.send();
|
|
||||||
expect(status).toBe(204);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
|
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
|
||||||
|
|
||||||
expect(assets.count).toBe(1);
|
|
||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove offline files from trash', async () => {
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
|
||||||
ownerId: admin.userId,
|
|
||||||
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
|
||||||
});
|
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/offline/online.png`);
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, {
|
|
||||||
libraryId: library.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(initialAssets.count).toBe(2);
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
|
|
||||||
const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, {
|
|
||||||
libraryId: library.id,
|
|
||||||
isOffline: true,
|
|
||||||
});
|
|
||||||
expect(offlineAssets.count).toBe(1);
|
|
||||||
|
|
||||||
const { status } = await request(app)
|
|
||||||
.post(`/libraries/${library.id}/removeOffline`)
|
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
|
||||||
.send();
|
|
||||||
expect(status).toBe(204);
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
|
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
|
||||||
|
|
||||||
expect(assets.count).toBe(1);
|
|
||||||
expect(assets.items[0].isOffline).toBe(false);
|
|
||||||
expect(assets.items[0].originalPath).toEqual(`${testAssetDirInternal}/temp/offline/online.png`);
|
|
||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not remove online files', async () => {
|
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
@@ -733,10 +534,11 @@ describe('/libraries', () => {
|
|||||||
expect(assetsBefore.count).toBeGreaterThan(1);
|
expect(assetsBefore.count).toBeGreaterThan(1);
|
||||||
|
|
||||||
const { status } = await request(app)
|
const { status } = await request(app)
|
||||||
.post(`/libraries/${library.id}/removeOffline`)
|
.post(`/libraries/${library.id}/scan`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send();
|
.send();
|
||||||
expect(status).toBe(204);
|
expect(status).toBe(204);
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
@@ -828,7 +630,7 @@ describe('/libraries', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
await scan(admin.accessToken, library.id);
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/libraries/${library.id}`)
|
.delete(`/libraries/${library.id}`)
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ describe('/search', () => {
|
|||||||
dto: { size: -1.5 },
|
dto: { size: -1.5 },
|
||||||
expected: ['size must not be less than 1', 'size must be an integer number'],
|
expected: ['size must not be less than 1', 'size must be an integer number'],
|
||||||
},
|
},
|
||||||
...['isArchived', 'isFavorite', 'isEncoded', 'isMotion', 'isOffline', 'isVisible'].map((value) => ({
|
...['isArchived', 'isFavorite', 'isEncoded', 'isOffline', 'isMotion', 'isVisible'].map((value) => ({
|
||||||
should: `should reject ${value} not a boolean`,
|
should: `should reject ${value} not a boolean`,
|
||||||
dto: { [value]: 'immich' },
|
dto: { [value]: 'immich' },
|
||||||
expected: [`${value} must be a boolean value`],
|
expected: [`${value} must be a boolean value`],
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk';
|
import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { Socket } from 'socket.io-client';
|
import { Socket } from 'socket.io-client';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
import { app, asBearerAuth, utils } from 'src/utils';
|
import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
|
||||||
|
|
||||||
describe('/trash', () => {
|
describe('/trash', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let ws: Socket;
|
let ws: Socket;
|
||||||
@@ -44,6 +47,8 @@ describe('/trash', () => {
|
|||||||
|
|
||||||
const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
|
const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
expect(after.total).toBe(0);
|
expect(after.total).toBe(0);
|
||||||
|
|
||||||
|
expect(existsSync(before.originalPath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should empty the trash with archived assets', async () => {
|
it('should empty the trash with archived assets', async () => {
|
||||||
@@ -64,6 +69,46 @@ describe('/trash', () => {
|
|||||||
|
|
||||||
const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
|
const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
expect(after.total).toBe(0);
|
expect(after.total).toBe(0);
|
||||||
|
|
||||||
|
expect(existsSync(before.originalPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not delete offline-trashed assets from disk', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assets.items.length).toBe(1);
|
||||||
|
const asset = assets.items[0];
|
||||||
|
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
|
||||||
|
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
|
||||||
|
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
|
||||||
|
|
||||||
|
const assetAfter = await utils.getAssetInfo(admin.accessToken, asset.id);
|
||||||
|
expect(assetAfter).toMatchObject({ isTrashed: true, isOffline: true });
|
||||||
|
|
||||||
|
expect(existsSync(`${testAssetDir}/temp/offline/offline.png`)).toBe(true);
|
||||||
|
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,6 +136,37 @@ describe('/trash', () => {
|
|||||||
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
|
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false }));
|
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not restore offline-trashed assets', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
const assetId = assets.items[0].id;
|
||||||
|
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
|
||||||
|
|
||||||
|
const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /trash/restore/assets', () => {
|
describe('POST /trash/restore/assets', () => {
|
||||||
@@ -118,5 +194,38 @@ describe('/trash', () => {
|
|||||||
const after = await utils.getAssetInfo(admin.accessToken, assetId);
|
const after = await utils.getAssetInfo(admin.accessToken, assetId);
|
||||||
expect(after.isTrashed).toBe(false);
|
expect(after.isTrashed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not restore an offline-trashed asset', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
const assetId = assets.items[0].id;
|
||||||
|
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const before = await utils.getAssetInfo(admin.accessToken, assetId);
|
||||||
|
expect(before.isTrashed).toBe(true);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/trash/restore/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({ ids: [assetId] });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const after = await utils.getAssetInfo(admin.accessToken, assetId);
|
||||||
|
expect(after.isTrashed).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -372,6 +372,12 @@ export const utils = {
|
|||||||
writeFileSync(path, makeRandomImage());
|
writeFileSync(path, makeRandomImage());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createDirectory: (path: string) => {
|
||||||
|
if (!existsSync(dirname(path))) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
removeImageFile: (path: string) => {
|
removeImageFile: (path: string) => {
|
||||||
if (!existsSync(path)) {
|
if (!existsSync(path)) {
|
||||||
return;
|
return;
|
||||||
@@ -380,6 +386,14 @@ export const utils = {
|
|||||||
rmSync(path);
|
rmSync(path);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeDirectory: (path: string) => {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(path);
|
||||||
|
},
|
||||||
|
|
||||||
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
|
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>
|
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "machine-learning"
|
name = "machine-learning"
|
||||||
version = "1.115.0"
|
version = "1.116.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 159,
|
"android.injected.version.code" => 160,
|
||||||
"android.injected.version.name" => "1.115.0",
|
"android.injected.version.name" => "1.116.0",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
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')
|
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')
|
||||||
|
|||||||
@@ -401,7 +401,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 175;
|
CURRENT_PROJECT_VERSION = 176;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -543,7 +543,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 175;
|
CURRENT_PROJECT_VERSION = 176;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 175;
|
CURRENT_PROJECT_VERSION = 176;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -58,11 +58,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.115.0</string>
|
<string>1.116.0</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>175</string>
|
<string>176</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Release"
|
desc "iOS Release"
|
||||||
lane :release do
|
lane :release do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.115.0"
|
version_number: "1.116.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
141
mobile/lib/entities/asset.entity.g.dart
generated
141
mobile/lib/entities/asset.entity.g.dart
generated
@@ -57,69 +57,64 @@ const AssetSchema = CollectionSchema(
|
|||||||
name: r'isFavorite',
|
name: r'isFavorite',
|
||||||
type: IsarType.bool,
|
type: IsarType.bool,
|
||||||
),
|
),
|
||||||
r'isOffline': PropertySchema(
|
|
||||||
id: 8,
|
|
||||||
name: r'isOffline',
|
|
||||||
type: IsarType.bool,
|
|
||||||
),
|
|
||||||
r'isTrashed': PropertySchema(
|
r'isTrashed': PropertySchema(
|
||||||
id: 9,
|
id: 8,
|
||||||
name: r'isTrashed',
|
name: r'isTrashed',
|
||||||
type: IsarType.bool,
|
type: IsarType.bool,
|
||||||
),
|
),
|
||||||
r'livePhotoVideoId': PropertySchema(
|
r'livePhotoVideoId': PropertySchema(
|
||||||
id: 10,
|
id: 9,
|
||||||
name: r'livePhotoVideoId',
|
name: r'livePhotoVideoId',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'localId': PropertySchema(
|
r'localId': PropertySchema(
|
||||||
id: 11,
|
id: 10,
|
||||||
name: r'localId',
|
name: r'localId',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'ownerId': PropertySchema(
|
r'ownerId': PropertySchema(
|
||||||
id: 12,
|
id: 11,
|
||||||
name: r'ownerId',
|
name: r'ownerId',
|
||||||
type: IsarType.long,
|
type: IsarType.long,
|
||||||
),
|
),
|
||||||
r'remoteId': PropertySchema(
|
r'remoteId': PropertySchema(
|
||||||
id: 13,
|
id: 12,
|
||||||
name: r'remoteId',
|
name: r'remoteId',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'stackCount': PropertySchema(
|
r'stackCount': PropertySchema(
|
||||||
id: 14,
|
id: 13,
|
||||||
name: r'stackCount',
|
name: r'stackCount',
|
||||||
type: IsarType.long,
|
type: IsarType.long,
|
||||||
),
|
),
|
||||||
r'stackId': PropertySchema(
|
r'stackId': PropertySchema(
|
||||||
id: 15,
|
id: 14,
|
||||||
name: r'stackId',
|
name: r'stackId',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'stackPrimaryAssetId': PropertySchema(
|
r'stackPrimaryAssetId': PropertySchema(
|
||||||
id: 16,
|
id: 15,
|
||||||
name: r'stackPrimaryAssetId',
|
name: r'stackPrimaryAssetId',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'thumbhash': PropertySchema(
|
r'thumbhash': PropertySchema(
|
||||||
id: 17,
|
id: 16,
|
||||||
name: r'thumbhash',
|
name: r'thumbhash',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'type': PropertySchema(
|
r'type': PropertySchema(
|
||||||
id: 18,
|
id: 17,
|
||||||
name: r'type',
|
name: r'type',
|
||||||
type: IsarType.byte,
|
type: IsarType.byte,
|
||||||
enumMap: _AssettypeEnumValueMap,
|
enumMap: _AssettypeEnumValueMap,
|
||||||
),
|
),
|
||||||
r'updatedAt': PropertySchema(
|
r'updatedAt': PropertySchema(
|
||||||
id: 19,
|
id: 18,
|
||||||
name: r'updatedAt',
|
name: r'updatedAt',
|
||||||
type: IsarType.dateTime,
|
type: IsarType.dateTime,
|
||||||
),
|
),
|
||||||
r'width': PropertySchema(
|
r'width': PropertySchema(
|
||||||
id: 20,
|
id: 19,
|
||||||
name: r'width',
|
name: r'width',
|
||||||
type: IsarType.int,
|
type: IsarType.int,
|
||||||
)
|
)
|
||||||
@@ -244,19 +239,18 @@ void _assetSerialize(
|
|||||||
writer.writeInt(offsets[5], object.height);
|
writer.writeInt(offsets[5], object.height);
|
||||||
writer.writeBool(offsets[6], object.isArchived);
|
writer.writeBool(offsets[6], object.isArchived);
|
||||||
writer.writeBool(offsets[7], object.isFavorite);
|
writer.writeBool(offsets[7], object.isFavorite);
|
||||||
writer.writeBool(offsets[8], object.isOffline);
|
writer.writeBool(offsets[8], object.isTrashed);
|
||||||
writer.writeBool(offsets[9], object.isTrashed);
|
writer.writeString(offsets[9], object.livePhotoVideoId);
|
||||||
writer.writeString(offsets[10], object.livePhotoVideoId);
|
writer.writeString(offsets[10], object.localId);
|
||||||
writer.writeString(offsets[11], object.localId);
|
writer.writeLong(offsets[11], object.ownerId);
|
||||||
writer.writeLong(offsets[12], object.ownerId);
|
writer.writeString(offsets[12], object.remoteId);
|
||||||
writer.writeString(offsets[13], object.remoteId);
|
writer.writeLong(offsets[13], object.stackCount);
|
||||||
writer.writeLong(offsets[14], object.stackCount);
|
writer.writeString(offsets[14], object.stackId);
|
||||||
writer.writeString(offsets[15], object.stackId);
|
writer.writeString(offsets[15], object.stackPrimaryAssetId);
|
||||||
writer.writeString(offsets[16], object.stackPrimaryAssetId);
|
writer.writeString(offsets[16], object.thumbhash);
|
||||||
writer.writeString(offsets[17], object.thumbhash);
|
writer.writeByte(offsets[17], object.type.index);
|
||||||
writer.writeByte(offsets[18], object.type.index);
|
writer.writeDateTime(offsets[18], object.updatedAt);
|
||||||
writer.writeDateTime(offsets[19], object.updatedAt);
|
writer.writeInt(offsets[19], object.width);
|
||||||
writer.writeInt(offsets[20], object.width);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Asset _assetDeserialize(
|
Asset _assetDeserialize(
|
||||||
@@ -275,20 +269,19 @@ Asset _assetDeserialize(
|
|||||||
id: id,
|
id: id,
|
||||||
isArchived: reader.readBoolOrNull(offsets[6]) ?? false,
|
isArchived: reader.readBoolOrNull(offsets[6]) ?? false,
|
||||||
isFavorite: reader.readBoolOrNull(offsets[7]) ?? false,
|
isFavorite: reader.readBoolOrNull(offsets[7]) ?? false,
|
||||||
isOffline: reader.readBoolOrNull(offsets[8]) ?? false,
|
isTrashed: reader.readBoolOrNull(offsets[8]) ?? false,
|
||||||
isTrashed: reader.readBoolOrNull(offsets[9]) ?? false,
|
livePhotoVideoId: reader.readStringOrNull(offsets[9]),
|
||||||
livePhotoVideoId: reader.readStringOrNull(offsets[10]),
|
localId: reader.readStringOrNull(offsets[10]),
|
||||||
localId: reader.readStringOrNull(offsets[11]),
|
ownerId: reader.readLong(offsets[11]),
|
||||||
ownerId: reader.readLong(offsets[12]),
|
remoteId: reader.readStringOrNull(offsets[12]),
|
||||||
remoteId: reader.readStringOrNull(offsets[13]),
|
stackCount: reader.readLongOrNull(offsets[13]) ?? 0,
|
||||||
stackCount: reader.readLongOrNull(offsets[14]) ?? 0,
|
stackId: reader.readStringOrNull(offsets[14]),
|
||||||
stackId: reader.readStringOrNull(offsets[15]),
|
stackPrimaryAssetId: reader.readStringOrNull(offsets[15]),
|
||||||
stackPrimaryAssetId: reader.readStringOrNull(offsets[16]),
|
thumbhash: reader.readStringOrNull(offsets[16]),
|
||||||
thumbhash: reader.readStringOrNull(offsets[17]),
|
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ??
|
||||||
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
|
|
||||||
AssetType.other,
|
AssetType.other,
|
||||||
updatedAt: reader.readDateTime(offsets[19]),
|
updatedAt: reader.readDateTime(offsets[18]),
|
||||||
width: reader.readIntOrNull(offsets[20]),
|
width: reader.readIntOrNull(offsets[19]),
|
||||||
);
|
);
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
@@ -319,29 +312,27 @@ P _assetDeserializeProp<P>(
|
|||||||
case 8:
|
case 8:
|
||||||
return (reader.readBoolOrNull(offset) ?? false) as P;
|
return (reader.readBoolOrNull(offset) ?? false) as P;
|
||||||
case 9:
|
case 9:
|
||||||
return (reader.readBoolOrNull(offset) ?? false) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 10:
|
case 10:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 11:
|
case 11:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
|
||||||
case 12:
|
|
||||||
return (reader.readLong(offset)) as P;
|
return (reader.readLong(offset)) as P;
|
||||||
case 13:
|
case 12:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 14:
|
case 13:
|
||||||
return (reader.readLongOrNull(offset) ?? 0) as P;
|
return (reader.readLongOrNull(offset) ?? 0) as P;
|
||||||
|
case 14:
|
||||||
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 15:
|
case 15:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 16:
|
case 16:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 17:
|
case 17:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
|
||||||
case 18:
|
|
||||||
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
|
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
|
||||||
AssetType.other) as P;
|
AssetType.other) as P;
|
||||||
case 19:
|
case 18:
|
||||||
return (reader.readDateTime(offset)) as P;
|
return (reader.readDateTime(offset)) as P;
|
||||||
case 20:
|
case 19:
|
||||||
return (reader.readIntOrNull(offset)) as P;
|
return (reader.readIntOrNull(offset)) as P;
|
||||||
default:
|
default:
|
||||||
throw IsarError('Unknown property with id $propertyId');
|
throw IsarError('Unknown property with id $propertyId');
|
||||||
@@ -1362,16 +1353,6 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> isOfflineEqualTo(
|
|
||||||
bool value) {
|
|
||||||
return QueryBuilder.apply(this, (query) {
|
|
||||||
return query.addFilterCondition(FilterCondition.equalTo(
|
|
||||||
property: r'isOffline',
|
|
||||||
value: value,
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> isTrashedEqualTo(
|
QueryBuilder<Asset, Asset, QAfterFilterCondition> isTrashedEqualTo(
|
||||||
bool value) {
|
bool value) {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
@@ -2647,18 +2628,6 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsOffline() {
|
|
||||||
return QueryBuilder.apply(this, (query) {
|
|
||||||
return query.addSortBy(r'isOffline', Sort.asc);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsOfflineDesc() {
|
|
||||||
return QueryBuilder.apply(this, (query) {
|
|
||||||
return query.addSortBy(r'isOffline', Sort.desc);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsTrashed() {
|
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsTrashed() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addSortBy(r'isTrashed', Sort.asc);
|
return query.addSortBy(r'isTrashed', Sort.asc);
|
||||||
@@ -2913,18 +2882,6 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsOffline() {
|
|
||||||
return QueryBuilder.apply(this, (query) {
|
|
||||||
return query.addSortBy(r'isOffline', Sort.asc);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsOfflineDesc() {
|
|
||||||
return QueryBuilder.apply(this, (query) {
|
|
||||||
return query.addSortBy(r'isOffline', Sort.desc);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsTrashed() {
|
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsTrashed() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addSortBy(r'isTrashed', Sort.asc);
|
return query.addSortBy(r'isTrashed', Sort.asc);
|
||||||
@@ -3121,12 +3078,6 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QDistinct> distinctByIsOffline() {
|
|
||||||
return QueryBuilder.apply(this, (query) {
|
|
||||||
return query.addDistinctBy(r'isOffline');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QDistinct> distinctByIsTrashed() {
|
QueryBuilder<Asset, Asset, QDistinct> distinctByIsTrashed() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addDistinctBy(r'isTrashed');
|
return query.addDistinctBy(r'isTrashed');
|
||||||
@@ -3263,12 +3214,6 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryBuilder<Asset, bool, QQueryOperations> isOfflineProperty() {
|
|
||||||
return QueryBuilder.apply(this, (query) {
|
|
||||||
return query.addPropertyName(r'isOffline');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBuilder<Asset, bool, QQueryOperations> isTrashedProperty() {
|
QueryBuilder<Asset, bool, QQueryOperations> isTrashedProperty() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addPropertyName(r'isTrashed');
|
return query.addPropertyName(r'isTrashed');
|
||||||
|
|||||||
@@ -72,13 +72,14 @@ extension AssetListExtension on Iterable<Asset> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Filters out offline assets and returns those that are still accessible by the Immich server
|
/// Filters out offline assets and returns those that are still accessible by the Immich server
|
||||||
|
/// TODO: isOffline is removed from Immich, so this method is not useful anymore
|
||||||
Iterable<Asset> nonOfflineOnly({
|
Iterable<Asset> nonOfflineOnly({
|
||||||
void Function()? errorCallback,
|
void Function()? errorCallback,
|
||||||
}) {
|
}) {
|
||||||
final bool onlyLive = every((e) => !e.isOffline);
|
final bool onlyLive = every((e) => false);
|
||||||
if (!onlyLive) {
|
if (!onlyLive) {
|
||||||
if (errorCallback != null) errorCallback();
|
if (errorCallback != null) errorCallback();
|
||||||
return where((a) => !a.isOffline);
|
return where((a) => false);
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,107 +51,109 @@ class CropImagePage extends HookWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: context.scaffoldBackgroundColor,
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
body: LayoutBuilder(
|
body: SafeArea(
|
||||||
builder: (BuildContext context, BoxConstraints constraints) {
|
child: LayoutBuilder(
|
||||||
return Column(
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
children: [
|
return Column(
|
||||||
Container(
|
children: [
|
||||||
padding: const EdgeInsets.only(top: 20),
|
Container(
|
||||||
width: constraints.maxWidth * 0.9,
|
padding: const EdgeInsets.only(top: 20),
|
||||||
height: constraints.maxHeight * 0.6,
|
width: constraints.maxWidth * 0.9,
|
||||||
child: CropImage(
|
height: constraints.maxHeight * 0.6,
|
||||||
controller: cropController,
|
child: CropImage(
|
||||||
image: image,
|
controller: cropController,
|
||||||
gridColor: Colors.white,
|
image: image,
|
||||||
),
|
gridColor: Colors.white,
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.scaffoldBackgroundColor,
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
topLeft: Radius.circular(20),
|
|
||||||
topRight: Radius.circular(20),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Center(
|
),
|
||||||
child: Column(
|
Expanded(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Container(
|
||||||
children: [
|
width: double.infinity,
|
||||||
Padding(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.only(
|
color: context.scaffoldBackgroundColor,
|
||||||
left: 20,
|
borderRadius: const BorderRadius.only(
|
||||||
right: 20,
|
topLeft: Radius.circular(20),
|
||||||
bottom: 10,
|
topRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.rotate_left,
|
||||||
|
color: Theme.of(context).iconTheme.color,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
cropController.rotateLeft();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.rotate_right,
|
||||||
|
color: Theme.of(context).iconTheme.color,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
cropController.rotateRight();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: <Widget>[
|
||||||
IconButton(
|
_AspectRatioButton(
|
||||||
icon: Icon(
|
cropController: cropController,
|
||||||
Icons.rotate_left,
|
aspectRatio: aspectRatio,
|
||||||
color: Theme.of(context).iconTheme.color,
|
ratio: null,
|
||||||
),
|
label: 'Free',
|
||||||
onPressed: () {
|
|
||||||
cropController.rotateLeft();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
IconButton(
|
_AspectRatioButton(
|
||||||
icon: Icon(
|
cropController: cropController,
|
||||||
Icons.rotate_right,
|
aspectRatio: aspectRatio,
|
||||||
color: Theme.of(context).iconTheme.color,
|
ratio: 1.0,
|
||||||
),
|
label: '1:1',
|
||||||
onPressed: () {
|
),
|
||||||
cropController.rotateRight();
|
_AspectRatioButton(
|
||||||
},
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 16.0 / 9.0,
|
||||||
|
label: '16:9',
|
||||||
|
),
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 3.0 / 2.0,
|
||||||
|
label: '3:2',
|
||||||
|
),
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 7.0 / 5.0,
|
||||||
|
label: '7:5',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: <Widget>[
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: null,
|
|
||||||
label: 'Free',
|
|
||||||
),
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: 1.0,
|
|
||||||
label: '1:1',
|
|
||||||
),
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: 16.0 / 9.0,
|
|
||||||
label: '16:9',
|
|
||||||
),
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: 3.0 / 2.0,
|
|
||||||
label: '3:2',
|
|
||||||
),
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: 7.0 / 5.0,
|
|
||||||
label: '7:5',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,12 +86,16 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
void handleAppPause() {
|
void handleAppPause() {
|
||||||
state = AppLifeCycleEnum.paused;
|
state = AppLifeCycleEnum.paused;
|
||||||
_wasPaused = true;
|
_wasPaused = true;
|
||||||
// Do not cancel backup if manual upload is in progress
|
|
||||||
if (_ref.read(backupProvider.notifier).backupProgress !=
|
if (_ref.read(authenticationProvider).isAuthenticated) {
|
||||||
BackUpProgressEnum.manualInProgress) {
|
// Do not cancel backup if manual upload is in progress
|
||||||
_ref.read(backupProvider.notifier).cancelBackup();
|
if (_ref.read(backupProvider.notifier).backupProgress !=
|
||||||
|
BackUpProgressEnum.manualInProgress) {
|
||||||
|
_ref.read(backupProvider.notifier).cancelBackup();
|
||||||
|
}
|
||||||
|
_ref.read(websocketProvider.notifier).disconnect();
|
||||||
}
|
}
|
||||||
_ref.read(websocketProvider.notifier).disconnect();
|
|
||||||
ImmichLogger().flush();
|
ImmichLogger().flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
mobile/lib/utils/provider_utils.dart
Normal file
16
mobile/lib/utils/provider_utils.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/album_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/person_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/user_api.repository.dart';
|
||||||
|
|
||||||
|
void invalidateAllApiRepositoryProviders(WidgetRef ref) {
|
||||||
|
ref.invalidate(userApiRepositoryProvider);
|
||||||
|
ref.invalidate(activityApiRepositoryProvider);
|
||||||
|
ref.invalidate(partnerApiRepositoryProvider);
|
||||||
|
ref.invalidate(albumApiRepositoryProvider);
|
||||||
|
ref.invalidate(personApiRepositoryProvider);
|
||||||
|
ref.invalidate(assetApiRepositoryProvider);
|
||||||
|
}
|
||||||
@@ -172,29 +172,12 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shareAsset() {
|
shareAsset() {
|
||||||
if (asset.isOffline) {
|
|
||||||
ImmichToast.show(
|
|
||||||
durationInSecond: 1,
|
|
||||||
context: context,
|
|
||||||
msg: 'asset_action_share_err_offline'.tr(),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context);
|
ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleEdit() async {
|
void handleEdit() async {
|
||||||
final image = Image(image: ImmichImage.imageProvider(asset: asset));
|
final image = Image(image: ImmichImage.imageProvider(asset: asset));
|
||||||
if (asset.isOffline) {
|
|
||||||
ImmichToast.show(
|
|
||||||
durationInSecond: 1,
|
|
||||||
context: context,
|
|
||||||
msg: 'asset_action_edit_err_offline'.tr(),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => EditImagePage(
|
builder: (context) => EditImagePage(
|
||||||
@@ -219,16 +202,6 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
if (asset.isLocal) {
|
if (asset.isLocal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (asset.isOffline) {
|
|
||||||
ImmichToast.show(
|
|
||||||
durationInSecond: 1,
|
|
||||||
context: context,
|
|
||||||
msg: 'asset_action_share_err_offline'.tr(),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
||||||
asset,
|
asset,
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -183,8 +183,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||||
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
||||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||||
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
|
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
||||||
buildDownloadButton(),
|
|
||||||
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
||||||
buildAddToAlbumButton(),
|
buildAddToAlbumButton(),
|
||||||
if (asset.isTrashed) buildRestoreButton(),
|
if (asset.isTrashed) buildRestoreButton(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'package:immich_mobile/providers/asset.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
|
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
|
||||||
@@ -186,6 +187,9 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
// This will remove current cache asset state of previous user login.
|
// This will remove current cache asset state of previous user login.
|
||||||
ref.read(assetProvider.notifier).clearAllAsset();
|
ref.read(assetProvider.notifier).clearAllAsset();
|
||||||
|
|
||||||
|
// Invalidate all api repository provider instance to take into account new access token
|
||||||
|
invalidateAllApiRepositoryProviders(ref);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final isAuthenticated =
|
final isAuthenticated =
|
||||||
await ref.read(authenticationProvider.notifier).login(
|
await ref.read(authenticationProvider.notifier).login(
|
||||||
|
|||||||
5
mobile/openapi/README.md
generated
5
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 1.115.0
|
- API version: 1.116.0
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
@@ -133,7 +133,6 @@ Class | Method | HTTP request | Description
|
|||||||
*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries |
|
*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries |
|
||||||
*LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} |
|
*LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} |
|
||||||
*LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics |
|
*LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics |
|
||||||
*LibrariesApi* | [**removeOfflineFiles**](doc//LibrariesApi.md#removeofflinefiles) | **POST** /libraries/{id}/removeOffline |
|
|
||||||
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan |
|
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan |
|
||||||
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} |
|
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} |
|
||||||
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate |
|
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate |
|
||||||
@@ -385,7 +384,6 @@ Class | Method | HTTP request | Description
|
|||||||
- [ReactionLevel](doc//ReactionLevel.md)
|
- [ReactionLevel](doc//ReactionLevel.md)
|
||||||
- [ReactionType](doc//ReactionType.md)
|
- [ReactionType](doc//ReactionType.md)
|
||||||
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
|
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
|
||||||
- [ScanLibraryDto](doc//ScanLibraryDto.md)
|
|
||||||
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
|
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
|
||||||
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
|
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
|
||||||
- [SearchExploreItem](doc//SearchExploreItem.md)
|
- [SearchExploreItem](doc//SearchExploreItem.md)
|
||||||
@@ -448,6 +446,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [TagUpsertDto](doc//TagUpsertDto.md)
|
- [TagUpsertDto](doc//TagUpsertDto.md)
|
||||||
- [TagsResponse](doc//TagsResponse.md)
|
- [TagsResponse](doc//TagsResponse.md)
|
||||||
- [TagsUpdate](doc//TagsUpdate.md)
|
- [TagsUpdate](doc//TagsUpdate.md)
|
||||||
|
- [TestEmailResponseDto](doc//TestEmailResponseDto.md)
|
||||||
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
|
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
|
||||||
- [TimeBucketSize](doc//TimeBucketSize.md)
|
- [TimeBucketSize](doc//TimeBucketSize.md)
|
||||||
- [ToneMapping](doc//ToneMapping.md)
|
- [ToneMapping](doc//ToneMapping.md)
|
||||||
|
|||||||
2
mobile/openapi/lib/api.dart
generated
2
mobile/openapi/lib/api.dart
generated
@@ -197,7 +197,6 @@ part 'model/ratings_update.dart';
|
|||||||
part 'model/reaction_level.dart';
|
part 'model/reaction_level.dart';
|
||||||
part 'model/reaction_type.dart';
|
part 'model/reaction_type.dart';
|
||||||
part 'model/reverse_geocoding_state_response_dto.dart';
|
part 'model/reverse_geocoding_state_response_dto.dart';
|
||||||
part 'model/scan_library_dto.dart';
|
|
||||||
part 'model/search_album_response_dto.dart';
|
part 'model/search_album_response_dto.dart';
|
||||||
part 'model/search_asset_response_dto.dart';
|
part 'model/search_asset_response_dto.dart';
|
||||||
part 'model/search_explore_item.dart';
|
part 'model/search_explore_item.dart';
|
||||||
@@ -260,6 +259,7 @@ part 'model/tag_update_dto.dart';
|
|||||||
part 'model/tag_upsert_dto.dart';
|
part 'model/tag_upsert_dto.dart';
|
||||||
part 'model/tags_response.dart';
|
part 'model/tags_response.dart';
|
||||||
part 'model/tags_update.dart';
|
part 'model/tags_update.dart';
|
||||||
|
part 'model/test_email_response_dto.dart';
|
||||||
part 'model/time_bucket_response_dto.dart';
|
part 'model/time_bucket_response_dto.dart';
|
||||||
part 'model/time_bucket_size.dart';
|
part 'model/time_bucket_size.dart';
|
||||||
part 'model/tone_mapping.dart';
|
part 'model/tone_mapping.dart';
|
||||||
|
|||||||
14
mobile/openapi/lib/api/assets_api.dart
generated
14
mobile/openapi/lib/api/assets_api.dart
generated
@@ -833,14 +833,12 @@ class AssetsApi {
|
|||||||
///
|
///
|
||||||
/// * [bool] isFavorite:
|
/// * [bool] isFavorite:
|
||||||
///
|
///
|
||||||
/// * [bool] isOffline:
|
|
||||||
///
|
|
||||||
/// * [bool] isVisible:
|
/// * [bool] isVisible:
|
||||||
///
|
///
|
||||||
/// * [String] livePhotoVideoId:
|
/// * [String] livePhotoVideoId:
|
||||||
///
|
///
|
||||||
/// * [MultipartFile] sidecarData:
|
/// * [MultipartFile] sidecarData:
|
||||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async {
|
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final path = r'/assets';
|
final path = r'/assets';
|
||||||
|
|
||||||
@@ -896,10 +894,6 @@ class AssetsApi {
|
|||||||
hasFields = true;
|
hasFields = true;
|
||||||
mp.fields[r'isFavorite'] = parameterToString(isFavorite);
|
mp.fields[r'isFavorite'] = parameterToString(isFavorite);
|
||||||
}
|
}
|
||||||
if (isOffline != null) {
|
|
||||||
hasFields = true;
|
|
||||||
mp.fields[r'isOffline'] = parameterToString(isOffline);
|
|
||||||
}
|
|
||||||
if (isVisible != null) {
|
if (isVisible != null) {
|
||||||
hasFields = true;
|
hasFields = true;
|
||||||
mp.fields[r'isVisible'] = parameterToString(isVisible);
|
mp.fields[r'isVisible'] = parameterToString(isVisible);
|
||||||
@@ -951,15 +945,13 @@ class AssetsApi {
|
|||||||
///
|
///
|
||||||
/// * [bool] isFavorite:
|
/// * [bool] isFavorite:
|
||||||
///
|
///
|
||||||
/// * [bool] isOffline:
|
|
||||||
///
|
|
||||||
/// * [bool] isVisible:
|
/// * [bool] isVisible:
|
||||||
///
|
///
|
||||||
/// * [String] livePhotoVideoId:
|
/// * [String] livePhotoVideoId:
|
||||||
///
|
///
|
||||||
/// * [MultipartFile] sidecarData:
|
/// * [MultipartFile] sidecarData:
|
||||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async {
|
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async {
|
||||||
final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, );
|
final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, );
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|||||||
54
mobile/openapi/lib/api/libraries_api.dart
generated
54
mobile/openapi/lib/api/libraries_api.dart
generated
@@ -243,13 +243,13 @@ class LibrariesApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /libraries/{id}/removeOffline' operation and returns the [Response].
|
/// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [String] id (required):
|
/// * [String] id (required):
|
||||||
Future<Response> removeOfflineFilesWithHttpInfo(String id,) async {
|
Future<Response> scanLibraryWithHttpInfo(String id,) async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final path = r'/libraries/{id}/removeOffline'
|
final path = r'/libraries/{id}/scan'
|
||||||
.replaceAll('{id}', id);
|
.replaceAll('{id}', id);
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
// ignore: prefer_final_locals
|
||||||
@@ -276,52 +276,8 @@ class LibrariesApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [String] id (required):
|
/// * [String] id (required):
|
||||||
Future<void> removeOfflineFiles(String id,) async {
|
Future<void> scanLibrary(String id,) async {
|
||||||
final response = await removeOfflineFilesWithHttpInfo(id,);
|
final response = await scanLibraryWithHttpInfo(id,);
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response].
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [String] id (required):
|
|
||||||
///
|
|
||||||
/// * [ScanLibraryDto] scanLibraryDto (required):
|
|
||||||
Future<Response> scanLibraryWithHttpInfo(String id, ScanLibraryDto scanLibraryDto,) async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final path = r'/libraries/{id}/scan'
|
|
||||||
.replaceAll('{id}', id);
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody = scanLibraryDto;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
const contentTypes = <String>['application/json'];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
path,
|
|
||||||
'POST',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [String] id (required):
|
|
||||||
///
|
|
||||||
/// * [ScanLibraryDto] scanLibraryDto (required):
|
|
||||||
Future<void> scanLibrary(String id, ScanLibraryDto scanLibraryDto,) async {
|
|
||||||
final response = await scanLibraryWithHttpInfo(id, scanLibraryDto,);
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|||||||
10
mobile/openapi/lib/api/notifications_api.dart
generated
10
mobile/openapi/lib/api/notifications_api.dart
generated
@@ -48,10 +48,18 @@ class NotificationsApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
|
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
|
||||||
Future<void> sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async {
|
Future<TestEmailResponseDto?> sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async {
|
||||||
final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,);
|
final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,);
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TestEmailResponseDto',) as TestEmailResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
@@ -448,8 +448,6 @@ class ApiClient {
|
|||||||
return ReactionTypeTypeTransformer().decode(value);
|
return ReactionTypeTypeTransformer().decode(value);
|
||||||
case 'ReverseGeocodingStateResponseDto':
|
case 'ReverseGeocodingStateResponseDto':
|
||||||
return ReverseGeocodingStateResponseDto.fromJson(value);
|
return ReverseGeocodingStateResponseDto.fromJson(value);
|
||||||
case 'ScanLibraryDto':
|
|
||||||
return ScanLibraryDto.fromJson(value);
|
|
||||||
case 'SearchAlbumResponseDto':
|
case 'SearchAlbumResponseDto':
|
||||||
return SearchAlbumResponseDto.fromJson(value);
|
return SearchAlbumResponseDto.fromJson(value);
|
||||||
case 'SearchAssetResponseDto':
|
case 'SearchAssetResponseDto':
|
||||||
@@ -574,6 +572,8 @@ class ApiClient {
|
|||||||
return TagsResponse.fromJson(value);
|
return TagsResponse.fromJson(value);
|
||||||
case 'TagsUpdate':
|
case 'TagsUpdate':
|
||||||
return TagsUpdate.fromJson(value);
|
return TagsUpdate.fromJson(value);
|
||||||
|
case 'TestEmailResponseDto':
|
||||||
|
return TestEmailResponseDto.fromJson(value);
|
||||||
case 'TimeBucketResponseDto':
|
case 'TimeBucketResponseDto':
|
||||||
return TimeBucketResponseDto.fromJson(value);
|
return TimeBucketResponseDto.fromJson(value);
|
||||||
case 'TimeBucketSize':
|
case 'TimeBucketSize':
|
||||||
|
|||||||
125
mobile/openapi/lib/model/scan_library_dto.dart
generated
125
mobile/openapi/lib/model/scan_library_dto.dart
generated
@@ -1,125 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class ScanLibraryDto {
|
|
||||||
/// Returns a new [ScanLibraryDto] instance.
|
|
||||||
ScanLibraryDto({
|
|
||||||
this.refreshAllFiles,
|
|
||||||
this.refreshModifiedFiles,
|
|
||||||
});
|
|
||||||
|
|
||||||
///
|
|
||||||
/// 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
|
|
||||||
/// source code must fall back to having a nullable type.
|
|
||||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
|
||||||
///
|
|
||||||
bool? refreshAllFiles;
|
|
||||||
|
|
||||||
///
|
|
||||||
/// 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
|
|
||||||
/// source code must fall back to having a nullable type.
|
|
||||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
|
||||||
///
|
|
||||||
bool? refreshModifiedFiles;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is ScanLibraryDto &&
|
|
||||||
other.refreshAllFiles == refreshAllFiles &&
|
|
||||||
other.refreshModifiedFiles == refreshModifiedFiles;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(refreshAllFiles == null ? 0 : refreshAllFiles!.hashCode) +
|
|
||||||
(refreshModifiedFiles == null ? 0 : refreshModifiedFiles!.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'ScanLibraryDto[refreshAllFiles=$refreshAllFiles, refreshModifiedFiles=$refreshModifiedFiles]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
if (this.refreshAllFiles != null) {
|
|
||||||
json[r'refreshAllFiles'] = this.refreshAllFiles;
|
|
||||||
} else {
|
|
||||||
// json[r'refreshAllFiles'] = null;
|
|
||||||
}
|
|
||||||
if (this.refreshModifiedFiles != null) {
|
|
||||||
json[r'refreshModifiedFiles'] = this.refreshModifiedFiles;
|
|
||||||
} else {
|
|
||||||
// json[r'refreshModifiedFiles'] = null;
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [ScanLibraryDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static ScanLibraryDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "ScanLibraryDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return ScanLibraryDto(
|
|
||||||
refreshAllFiles: mapValueOfType<bool>(json, r'refreshAllFiles'),
|
|
||||||
refreshModifiedFiles: mapValueOfType<bool>(json, r'refreshModifiedFiles'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<ScanLibraryDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <ScanLibraryDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = ScanLibraryDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, ScanLibraryDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, ScanLibraryDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = ScanLibraryDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of ScanLibraryDto-objects as value to a dart map
|
|
||||||
static Map<String, List<ScanLibraryDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<ScanLibraryDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = ScanLibraryDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
99
mobile/openapi/lib/model/test_email_response_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/test_email_response_dto.dart
generated
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class TestEmailResponseDto {
|
||||||
|
/// Returns a new [TestEmailResponseDto] instance.
|
||||||
|
TestEmailResponseDto({
|
||||||
|
required this.messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
String messageId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is TestEmailResponseDto &&
|
||||||
|
other.messageId == messageId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(messageId.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'TestEmailResponseDto[messageId=$messageId]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'messageId'] = this.messageId;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [TestEmailResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static TestEmailResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "TestEmailResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return TestEmailResponseDto(
|
||||||
|
messageId: mapValueOfType<String>(json, r'messageId')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<TestEmailResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <TestEmailResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = TestEmailResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, TestEmailResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, TestEmailResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = TestEmailResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of TestEmailResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<TestEmailResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<TestEmailResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = TestEmailResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'messageId',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.115.0+159
|
version: 1.116.0+160
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.0 <4.0.0'
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
|||||||
@@ -2853,41 +2853,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/libraries/{id}/removeOffline": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "removeOfflineFiles",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"format": "uuid",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"204": {
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"Libraries"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/libraries/{id}/scan": {
|
"/libraries/{id}/scan": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "scanLibrary",
|
"operationId": "scanLibrary",
|
||||||
@@ -2902,16 +2867,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ScanLibraryDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"204": {
|
||||||
"description": ""
|
"description": ""
|
||||||
@@ -3491,6 +3446,13 @@
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TestEmailResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7447,7 +7409,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
@@ -8280,9 +8242,6 @@
|
|||||||
"isFavorite": {
|
"isFavorite": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"isOffline": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"isVisible": {
|
"isVisible": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -10621,17 +10580,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"ScanLibraryDto": {
|
|
||||||
"properties": {
|
|
||||||
"refreshAllFiles": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"refreshModifiedFiles": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"SearchAlbumResponseDto": {
|
"SearchAlbumResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"count": {
|
"count": {
|
||||||
@@ -12348,6 +12296,17 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TestEmailResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"messageId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"messageId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TimeBucketResponseDto": {
|
"TimeBucketResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"count": {
|
"count": {
|
||||||
|
|||||||
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 1.115.0
|
* 1.116.0
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
@@ -366,7 +366,6 @@ export type AssetMediaCreateDto = {
|
|||||||
fileModifiedAt: string;
|
fileModifiedAt: string;
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
isOffline?: boolean;
|
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
livePhotoVideoId?: string;
|
livePhotoVideoId?: string;
|
||||||
sidecarData?: Blob;
|
sidecarData?: Blob;
|
||||||
@@ -579,10 +578,6 @@ export type UpdateLibraryDto = {
|
|||||||
importPaths?: string[];
|
importPaths?: string[];
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
export type ScanLibraryDto = {
|
|
||||||
refreshAllFiles?: boolean;
|
|
||||||
refreshModifiedFiles?: boolean;
|
|
||||||
};
|
|
||||||
export type LibraryStatsResponseDto = {
|
export type LibraryStatsResponseDto = {
|
||||||
photos: number;
|
photos: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -656,6 +651,9 @@ export type SystemConfigSmtpDto = {
|
|||||||
replyTo: string;
|
replyTo: string;
|
||||||
transport: SystemConfigSmtpTransportDto;
|
transport: SystemConfigSmtpTransportDto;
|
||||||
};
|
};
|
||||||
|
export type TestEmailResponseDto = {
|
||||||
|
messageId: string;
|
||||||
|
};
|
||||||
export type OAuthConfigDto = {
|
export type OAuthConfigDto = {
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
};
|
};
|
||||||
@@ -2063,24 +2061,14 @@ export function updateLibrary({ id, updateLibraryDto }: {
|
|||||||
body: updateLibraryDto
|
body: updateLibraryDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
export function removeOfflineFiles({ id }: {
|
export function scanLibrary({ id }: {
|
||||||
id: string;
|
id: string;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/removeOffline`, {
|
return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, {
|
||||||
...opts,
|
...opts,
|
||||||
method: "POST"
|
method: "POST"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
export function scanLibrary({ id, scanLibraryDto }: {
|
|
||||||
id: string;
|
|
||||||
scanLibraryDto: ScanLibraryDto;
|
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, oazapfts.json({
|
|
||||||
...opts,
|
|
||||||
method: "POST",
|
|
||||||
body: scanLibraryDto
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
export function getLibraryStatistics({ id }: {
|
export function getLibraryStatistics({ id }: {
|
||||||
id: string;
|
id: string;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
@@ -2220,7 +2208,10 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
|
|||||||
export function sendTestEmail({ systemConfigSmtpDto }: {
|
export function sendTestEmail({ systemConfigSmtpDto }: {
|
||||||
systemConfigSmtpDto: SystemConfigSmtpDto;
|
systemConfigSmtpDto: SystemConfigSmtpDto;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchText("/notifications/test-email", oazapfts.json({
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: TestEmailResponseDto;
|
||||||
|
}>("/notifications/test-email", oazapfts.json({
|
||||||
...opts,
|
...opts,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: systemConfigSmtpDto
|
body: systemConfigSmtpDto
|
||||||
|
|||||||
133
readme_i18n/README_vi_VN.md
Normal file
133
readme_i18n/README_vi_VN.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<p align="center">
|
||||||
|
<br/>
|
||||||
|
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Giấy phép: AGPLv3"></a>
|
||||||
|
<a href="https://discord.immich.app">
|
||||||
|
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
|
||||||
|
</a>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Đăng nhập bằng URL Tuỳ chỉnh">
|
||||||
|
</p>
|
||||||
|
<h3 align="center">Giải pháp quản lý ảnh và video tự lưu trữ hiệu suất cao</h3>
|
||||||
|
<br/>
|
||||||
|
<a href="https://immich.app">
|
||||||
|
<img src="../design/immich-screenshots.png" title="Ảnh chụp màn hình chính">
|
||||||
|
</a>
|
||||||
|
<br/>
|
||||||
|
<p align="center">
|
||||||
|
|
||||||
|
<a href="../README.md">English</a>
|
||||||
|
<a href="README_ca_ES.md">Català</a>
|
||||||
|
<a href="README_es_ES.md">Español</a>
|
||||||
|
<a href="README_fr_FR.md">Français</a>
|
||||||
|
<a href="README_it_IT.md">Italiano</a>
|
||||||
|
<a href="README_ja_JP.md">日本語</a>
|
||||||
|
<a href="README_ko_KR.md">한국어</a>
|
||||||
|
<a href="README_de_DE.md">Deutsch</a>
|
||||||
|
<a href="README_nl_NL.md">Nederlands</a>
|
||||||
|
<a href="README_tr_TR.md">Türkçe</a>
|
||||||
|
<a href="README_zh_CN.md">中文</a>
|
||||||
|
<a href="README_ru_RU.md">Русский</a>
|
||||||
|
<a href="README_pt_BR.md">Português Brasileiro</a>
|
||||||
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Tuyên bố miễn trừ trách nhiệm
|
||||||
|
|
||||||
|
- ⚠️ Dự án đang được phát triển **rất tích cực**.
|
||||||
|
- ⚠️ Dự kiến sẽ có lỗi và thay đổi đột ngột.
|
||||||
|
- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.**
|
||||||
|
- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn!
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/.
|
||||||
|
|
||||||
|
## Liên kết
|
||||||
|
|
||||||
|
- [Tài liệu](https://immich.app/docs)
|
||||||
|
- [Giới thiệu](https://immich.app/docs/overview/introduction)
|
||||||
|
- [Cài đặt](https://immich.app/docs/install/requirements)
|
||||||
|
- [Lộ trình](https://immich.app/roadmap)
|
||||||
|
- [Demo](#demo)
|
||||||
|
- [Tính năng](#Tính-năng)
|
||||||
|
- [Dịch thuật](https://immich.app/docs/developer/translations)
|
||||||
|
- [Đóng góp](https://immich.app/docs/overview/support-the-project)
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
Truy cập bản demo [tại đây](https://demo.immich.app). Bản demo đang chạy trên máy ảo Oracle Free-tier ở Amsterdam với CPU ARM64 lõi tứ 2,4 GHz và RAM 24 GB.
|
||||||
|
|
||||||
|
Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app/api` cho `Server Endpoint URL`
|
||||||
|
|
||||||
|
### Thông tin đăng nhập
|
||||||
|
|
||||||
|
| Email | Mật khẩu |
|
||||||
|
| --------------- | -------- |
|
||||||
|
| demo@immich.app | demo |
|
||||||
|
|
||||||
|
## Tính năng
|
||||||
|
|
||||||
|
| Tính năng | Mobile | Web |
|
||||||
|
| :--------------------------------------------------- | ------ | ----- |
|
||||||
|
| Tải lên và xem video, ảnh | Có | Có |
|
||||||
|
| Tự động sao lưu khi ứng dụng được mở | Có | N/A |
|
||||||
|
| Ngăn chặn sự trùng lặp nội dung | Có | Có |
|
||||||
|
| Album được chọn để sao lưu | Có | N/A |
|
||||||
|
| Tải ảnh và video xuống thiết bị cục bộ | Có | Có |
|
||||||
|
| Hỗ trợ nhiều người dùng | Có | Có |
|
||||||
|
| Album và Album được chia sẻ | Có | Có |
|
||||||
|
| Thanh cuộn có thể chà / kéo | Có | Có |
|
||||||
|
| Hỗ trợ định dạng raw | Có | Có |
|
||||||
|
| Xem metadata (EXIF, bản đồ) | Có | Có |
|
||||||
|
| Tìm kiếm theo metadata, đối tượng, khuôn mặt và CLIP | Có | Có |
|
||||||
|
| Chức năng quản trị (quản lý người dùng) | Không | Có |
|
||||||
|
| Sao lưu trong nền | Có | N/A |
|
||||||
|
| Cuộn ảo | Có | Có |
|
||||||
|
| Hỗ trợ OAuth | Có | Có |
|
||||||
|
| API Keys | N/A | Có |
|
||||||
|
| Sao lưu và phát lại Live Photo/Motion Photo | Có | Có |
|
||||||
|
| Hỗ trợ hiển thị hình ảnh 360 độ | Không | Có |
|
||||||
|
| Cấu trúc lưu trữ do người dùng xác định | Có | Có |
|
||||||
|
| Chia sẻ công khai | Có | Có |
|
||||||
|
| Lưu trữ và Yêu thích | Có | Có |
|
||||||
|
| Bản đồ toàn cầu | Có | Có |
|
||||||
|
| Chia sẻ đối tác | Có | Có |
|
||||||
|
| Nhận dạng khuôn mặt và phân cụm | Có | Có |
|
||||||
|
| Kỷ niệm (x năm trước) | Có | Có |
|
||||||
|
| Hỗ trợ ngoại tuyến | Có | Không |
|
||||||
|
| Thư viện chỉ đọc | Có | Có |
|
||||||
|
| Ảnh xếp chồng | Có | Có |
|
||||||
|
|
||||||
|
## Dịch thuật
|
||||||
|
|
||||||
|
Đọc thêm về dịch thuật [tại đây](https://immich.app/docs/developer/translations).
|
||||||
|
|
||||||
|
<a href="https://hosted.weblate.org/engage/immich/">
|
||||||
|
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Tình trạng dịch thuật" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Hoạt động của repository
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Lịch sử Đánh dấu sao
|
||||||
|
|
||||||
|
<a href="https://star-history.com/#immich-app/immich&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
||||||
|
<img alt="Biểu đồ Lịch sử Đánh dấu" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Người đóng góp
|
||||||
|
|
||||||
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||||
|
</a>
|
||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bullmq": "^10.0.1",
|
"@nestjs/bullmq": "^10.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
LibraryResponseDto,
|
LibraryResponseDto,
|
||||||
LibraryStatsResponseDto,
|
LibraryStatsResponseDto,
|
||||||
ScanLibraryDto,
|
|
||||||
UpdateLibraryDto,
|
UpdateLibraryDto,
|
||||||
ValidateLibraryDto,
|
ValidateLibraryDto,
|
||||||
ValidateLibraryResponseDto,
|
ValidateLibraryResponseDto,
|
||||||
@@ -43,6 +42,13 @@ export class LibraryController {
|
|||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true })
|
||||||
|
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
|
||||||
|
return this.service.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/validate')
|
@Post(':id/validate')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Authenticated({ admin: true })
|
@Authenticated({ admin: true })
|
||||||
@@ -51,13 +57,6 @@ export class LibraryController {
|
|||||||
return this.service.validate(id, dto);
|
return this.service.validate(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
@Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true })
|
|
||||||
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
|
|
||||||
return this.service.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id/statistics')
|
@Get(':id/statistics')
|
||||||
@Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true })
|
@Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true })
|
||||||
getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
|
getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
|
||||||
@@ -66,15 +65,8 @@ export class LibraryController {
|
|||||||
|
|
||||||
@Post(':id/scan')
|
@Post(':id/scan')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@Authenticated({ admin: true })
|
@Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true })
|
||||||
scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
|
scanLibrary(@Param() { id }: UUIDParamDto) {
|
||||||
return this.service.queueScan(id, dto);
|
return this.service.queueScan(id);
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/removeOffline')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
@Authenticated({ admin: true })
|
|
||||||
removeOfflineFiles(@Param() { id }: UUIDParamDto) {
|
|
||||||
return this.service.queueRemoveOffline(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { TestEmailResponseDto } from 'src/dtos/notification.dto';
|
||||||
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
||||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { NotificationService } from 'src/services/notification.service';
|
import { NotificationService } from 'src/services/notification.service';
|
||||||
@@ -13,7 +14,7 @@ export class NotificationController {
|
|||||||
@Post('test-email')
|
@Post('test-email')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Authenticated({ admin: true })
|
@Authenticated({ admin: true })
|
||||||
sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto) {
|
sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
|
||||||
return this.service.sendTestEmail(auth.user.id, dto);
|
return this.service.sendTestEmail(auth.user.id, dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,6 @@ export class AssetMediaCreateDto extends AssetMediaBase {
|
|||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({ optional: true })
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
isOffline?: boolean;
|
|
||||||
|
|
||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID({ optional: true })
|
||||||
livePhotoVideoId?: string;
|
livePhotoVideoId?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
|
import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
|
||||||
import { LibraryEntity } from 'src/entities/library.entity';
|
import { LibraryEntity } from 'src/entities/library.entity';
|
||||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class CreateLibraryDto {
|
export class CreateLibraryDto {
|
||||||
@ValidateUUID()
|
@ValidateUUID()
|
||||||
@@ -89,14 +89,6 @@ export class LibrarySearchDto {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ScanLibraryDto {
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
refreshModifiedFiles?: boolean;
|
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
refreshAllFiles?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LibraryResponseDto {
|
export class LibraryResponseDto {
|
||||||
id!: string;
|
id!: string;
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
|
|||||||
3
server/src/dtos/notification.dto.ts
Normal file
3
server/src/dtos/notification.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class TestEmailResponseDto {
|
||||||
|
messageId!: string;
|
||||||
|
}
|
||||||
@@ -36,8 +36,6 @@ export enum WithoutProperty {
|
|||||||
|
|
||||||
export enum WithProperty {
|
export enum WithProperty {
|
||||||
SIDECAR = 'sidecar',
|
SIDECAR = 'sidecar',
|
||||||
IS_ONLINE = 'isOnline',
|
|
||||||
IS_OFFLINE = 'isOffline',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TimeBucketSize {
|
export enum TimeBucketSize {
|
||||||
@@ -176,7 +174,6 @@ export interface IAssetRepository {
|
|||||||
): Paginated<AssetEntity>;
|
): Paginated<AssetEntity>;
|
||||||
getRandom(userIds: string[], count: number): Promise<AssetEntity[]>;
|
getRandom(userIds: string[], count: number): Promise<AssetEntity[]>;
|
||||||
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
||||||
getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity>;
|
|
||||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
|
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
|
||||||
deleteAll(ownerId: string): Promise<void>;
|
deleteAll(ownerId: string): Promise<void>;
|
||||||
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||||
|
|||||||
@@ -76,12 +76,12 @@ export enum JobName {
|
|||||||
FACIAL_RECOGNITION = 'facial-recognition',
|
FACIAL_RECOGNITION = 'facial-recognition',
|
||||||
|
|
||||||
// library management
|
// library management
|
||||||
LIBRARY_SCAN = 'library-refresh',
|
LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files',
|
||||||
LIBRARY_SCAN_ASSET = 'library-refresh-asset',
|
LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets',
|
||||||
LIBRARY_REMOVE_OFFLINE = 'library-remove-offline',
|
LIBRARY_SYNC_FILE = 'library-sync-file',
|
||||||
LIBRARY_CHECK_OFFLINE = 'library-check-offline',
|
LIBRARY_SYNC_ASSET = 'library-sync-asset',
|
||||||
LIBRARY_DELETE = 'library-delete',
|
LIBRARY_DELETE = 'library-delete',
|
||||||
LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh',
|
LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all',
|
||||||
LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup',
|
LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup',
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
@@ -137,16 +137,11 @@ export interface ILibraryFileJob extends IEntityJob {
|
|||||||
assetPath: string;
|
assetPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILibraryOfflineJob extends IEntityJob {
|
export interface ILibraryAssetJob extends IEntityJob {
|
||||||
importPaths: string[];
|
importPaths: string[];
|
||||||
exclusionPatterns: string[];
|
exclusionPatterns: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILibraryRefreshJob extends IEntityJob {
|
|
||||||
refreshModifiedFiles: boolean;
|
|
||||||
refreshAllFiles: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IBulkEntityJob extends IBaseJob {
|
export interface IBulkEntityJob extends IBaseJob {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
}
|
}
|
||||||
@@ -277,12 +272,12 @@ export type JobItem =
|
|||||||
| { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob }
|
| { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob }
|
||||||
|
|
||||||
// Library Management
|
// Library Management
|
||||||
| { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob }
|
| { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob }
|
||||||
| { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob }
|
| { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob }
|
||||||
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
|
| { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob }
|
||||||
|
| { name: JobName.LIBRARY_SYNC_ASSET; data: IEntityJob }
|
||||||
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
|
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
|
||||||
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
|
| { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob }
|
||||||
| { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob }
|
|
||||||
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
|
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
|
||||||
|
|
||||||
// Notification
|
// Notification
|
||||||
|
|||||||
@@ -7,7 +7,18 @@ export interface ExifDuration {
|
|||||||
Scale?: number;
|
Scale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagsWithWrongTypes = 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription' | 'RegionInfo';
|
type StringOrNumber = string | number;
|
||||||
|
|
||||||
|
type TagsWithWrongTypes =
|
||||||
|
| 'FocalLength'
|
||||||
|
| 'Duration'
|
||||||
|
| 'Description'
|
||||||
|
| 'ImageDescription'
|
||||||
|
| 'RegionInfo'
|
||||||
|
| 'TagsList'
|
||||||
|
| 'Keywords'
|
||||||
|
| 'HierarchicalSubject'
|
||||||
|
| 'ISO';
|
||||||
export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||||
ContentIdentifier?: string;
|
ContentIdentifier?: string;
|
||||||
MotionPhoto?: number;
|
MotionPhoto?: number;
|
||||||
@@ -20,10 +31,14 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
|||||||
EmbeddedVideoType?: string;
|
EmbeddedVideoType?: string;
|
||||||
EmbeddedVideoFile?: BinaryField;
|
EmbeddedVideoFile?: BinaryField;
|
||||||
MotionPhotoVideo?: BinaryField;
|
MotionPhotoVideo?: BinaryField;
|
||||||
|
TagsList?: StringOrNumber[];
|
||||||
|
HierarchicalSubject?: StringOrNumber[];
|
||||||
|
Keywords?: StringOrNumber | StringOrNumber[];
|
||||||
|
ISO?: number | number[];
|
||||||
|
|
||||||
// Type is wrong, can also be number.
|
// Type is wrong, can also be number.
|
||||||
Description?: string | number;
|
Description?: StringOrNumber;
|
||||||
ImageDescription?: string | number;
|
ImageDescription?: StringOrNumber;
|
||||||
|
|
||||||
// Extended properties for image regions, such as faces
|
// Extended properties for image regions, such as faces
|
||||||
RegionInfo?: {
|
RegionInfo?: {
|
||||||
|
|||||||
@@ -268,35 +268,6 @@ DELETE FROM "assets"
|
|||||||
WHERE
|
WHERE
|
||||||
"ownerId" = $1
|
"ownerId" = $1
|
||||||
|
|
||||||
-- AssetRepository.getExternalLibraryAssetPaths
|
|
||||||
SELECT DISTINCT
|
|
||||||
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
|
|
||||||
FROM
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
"AssetEntity"."id" AS "AssetEntity_id",
|
|
||||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
|
||||||
"AssetEntity"."isOffline" AS "AssetEntity_isOffline"
|
|
||||||
FROM
|
|
||||||
"assets" "AssetEntity"
|
|
||||||
LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId"
|
|
||||||
AND (
|
|
||||||
"AssetEntity__AssetEntity_library"."deletedAt" IS NULL
|
|
||||||
)
|
|
||||||
WHERE
|
|
||||||
(
|
|
||||||
(
|
|
||||||
((("AssetEntity__AssetEntity_library"."id" = $1)))
|
|
||||||
AND ("AssetEntity"."isExternal" = $2)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
AND ("AssetEntity"."deletedAt" IS NULL)
|
|
||||||
) "distinctAlias"
|
|
||||||
ORDER BY
|
|
||||||
"AssetEntity_id" ASC
|
|
||||||
LIMIT
|
|
||||||
2
|
|
||||||
|
|
||||||
-- AssetRepository.getByLibraryIdAndOriginalPath
|
-- AssetRepository.getByLibraryIdAndOriginalPath
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
|
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
|
||||||
@@ -366,18 +337,6 @@ WHERE
|
|||||||
AND "originalPath" = path
|
AND "originalPath" = path
|
||||||
);
|
);
|
||||||
|
|
||||||
-- AssetRepository.updateOfflineLibraryAssets
|
|
||||||
UPDATE "assets"
|
|
||||||
SET
|
|
||||||
"isOffline" = $1,
|
|
||||||
"updatedAt" = CURRENT_TIMESTAMP
|
|
||||||
WHERE
|
|
||||||
(
|
|
||||||
"libraryId" = $2
|
|
||||||
AND NOT ("originalPath" IN ($3))
|
|
||||||
AND "isOffline" = $4
|
|
||||||
)
|
|
||||||
|
|
||||||
-- AssetRepository.getAllByDeviceId
|
-- AssetRepository.getAllByDeviceId
|
||||||
SELECT
|
SELECT
|
||||||
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
|
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
AssetDeltaSyncOptions,
|
AssetDeltaSyncOptions,
|
||||||
AssetExploreFieldOptions,
|
AssetExploreFieldOptions,
|
||||||
AssetFullSyncOptions,
|
AssetFullSyncOptions,
|
||||||
AssetPathEntity,
|
|
||||||
AssetStats,
|
AssetStats,
|
||||||
AssetStatsOptions,
|
AssetStatsOptions,
|
||||||
AssetUpdateAllOptions,
|
AssetUpdateAllOptions,
|
||||||
@@ -177,14 +176,6 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
return this.getAll(pagination, { ...options, userIds: [userId] });
|
return this.getAll(pagination, { ...options, userIds: [userId] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
|
|
||||||
getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity> {
|
|
||||||
return paginate(this.repository, pagination, {
|
|
||||||
select: { id: true, originalPath: true, isOffline: true },
|
|
||||||
where: { library: { id: libraryId }, isExternal: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null> {
|
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
@@ -198,24 +189,16 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise<string[]> {
|
async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise<string[]> {
|
||||||
const result = await this.repository.query(
|
const result = await this.repository.query(
|
||||||
`
|
`
|
||||||
WITH paths AS (SELECT unnest($2::text[]) AS path)
|
WITH paths AS (SELECT unnest($2::text[]) AS path)
|
||||||
SELECT path FROM paths
|
SELECT path
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path);
|
FROM paths
|
||||||
`,
|
WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path);
|
||||||
|
`,
|
||||||
[libraryId, originalPaths],
|
[libraryId, originalPaths],
|
||||||
);
|
);
|
||||||
return result.map((row: { path: string }) => row.path);
|
return result.map((row: { path: string }) => row.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] })
|
|
||||||
@ChunkedArray({ paramIndex: 1 })
|
|
||||||
async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise<void> {
|
|
||||||
await this.repository.update(
|
|
||||||
{ library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false },
|
|
||||||
{ isOffline: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
|
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
|
||||||
let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files');
|
let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files');
|
||||||
builder = searchAssetBuilder(builder, options);
|
builder = searchAssetBuilder(builder, options);
|
||||||
@@ -373,12 +356,10 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql(
|
@GenerateSql(
|
||||||
...Object.values(WithProperty)
|
...Object.values(WithProperty).map((property) => ({
|
||||||
.filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE)
|
name: property,
|
||||||
.map((property) => ({
|
params: [DummyValue.PAGINATION, property],
|
||||||
name: property,
|
})),
|
||||||
params: [DummyValue.PAGINATION, property],
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity> {
|
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity> {
|
||||||
let relations: FindOptionsRelations<AssetEntity> = {};
|
let relations: FindOptionsRelations<AssetEntity> = {};
|
||||||
@@ -531,26 +512,16 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
where = [{ sidecarPath: Not(IsNull()), isVisible: true }];
|
where = [{ sidecarPath: Not(IsNull()), isVisible: true }];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case WithProperty.IS_OFFLINE: {
|
|
||||||
if (!libraryId) {
|
|
||||||
throw new Error('Library id is required when finding offline assets');
|
|
||||||
}
|
|
||||||
where = [{ isOffline: true, libraryId }];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case WithProperty.IS_ONLINE: {
|
|
||||||
if (!libraryId) {
|
|
||||||
throw new Error('Library id is required when finding online assets');
|
|
||||||
}
|
|
||||||
where = [{ isOffline: false, libraryId }];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Invalid getWith property: ${property}`);
|
throw new Error(`Invalid getWith property: ${property}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (libraryId) {
|
||||||
|
where = [{ ...where, libraryId }];
|
||||||
|
}
|
||||||
|
|
||||||
return paginate(this.repository, pagination, {
|
return paginate(this.repository, pagination, {
|
||||||
where,
|
where,
|
||||||
withDeleted,
|
withDeleted,
|
||||||
@@ -750,7 +721,10 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
|
builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
|
||||||
|
|
||||||
if (options.isTrashed) {
|
if (options.isTrashed) {
|
||||||
builder.andWhere('asset.status = :status', { status: AssetStatus.TRASHED });
|
// TODO: Temporarily inverted to support showing offline assets in the trash queries.
|
||||||
|
// Once offline assets are handled in a separate screen, this should be set back to status = TRASHED
|
||||||
|
// and the offline screens should use a separate isOffline = true parameter in the timeline query.
|
||||||
|
builder.andWhere('asset.status != :status', { status: AssetStatus.DELETED });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,12 +79,12 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
|||||||
[JobName.SIDECAR_WRITE]: QueueName.SIDECAR,
|
[JobName.SIDECAR_WRITE]: QueueName.SIDECAR,
|
||||||
|
|
||||||
// Library management
|
// Library management
|
||||||
[JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY,
|
[JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_SCAN]: QueueName.LIBRARY,
|
[JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY,
|
||||||
|
[JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_DELETE]: QueueName.LIBRARY,
|
[JobName.LIBRARY_DELETE]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY,
|
[JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
|
[JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
|
|
||||||
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
|
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
|
||||||
|
|
||||||
// Notification
|
// Notification
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
|
|||||||
import { AssetStatus } from 'src/enum';
|
import { AssetStatus } from 'src/enum';
|
||||||
import { ITrashRepository } from 'src/interfaces/trash.interface';
|
import { ITrashRepository } from 'src/interfaces/trash.interface';
|
||||||
import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination';
|
import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination';
|
||||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
|
|
||||||
export class TrashRepository implements ITrashRepository {
|
export class TrashRepository implements ITrashRepository {
|
||||||
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
|
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
|
||||||
@@ -26,7 +26,7 @@ export class TrashRepository implements ITrashRepository {
|
|||||||
|
|
||||||
async restore(userId: string): Promise<number> {
|
async restore(userId: string): Promise<number> {
|
||||||
const result = await this.assetRepository.update(
|
const result = await this.assetRepository.update(
|
||||||
{ ownerId: userId, deletedAt: Not(IsNull()) },
|
{ ownerId: userId, status: AssetStatus.TRASHED },
|
||||||
{ status: AssetStatus.ACTIVE, deletedAt: null },
|
{ status: AssetStatus.ACTIVE, deletedAt: null },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export class TrashRepository implements ITrashRepository {
|
|||||||
|
|
||||||
async empty(userId: string): Promise<number> {
|
async empty(userId: string): Promise<number> {
|
||||||
const result = await this.assetRepository.update(
|
const result = await this.assetRepository.update(
|
||||||
{ ownerId: userId, deletedAt: Not(IsNull()), status: AssetStatus.TRASHED },
|
{ ownerId: userId, status: AssetStatus.TRASHED },
|
||||||
{ status: AssetStatus.DELETED },
|
{ status: AssetStatus.DELETED },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -43,7 +43,10 @@ export class TrashRepository implements ITrashRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async restoreAll(ids: string[]): Promise<number> {
|
async restoreAll(ids: string[]): Promise<number> {
|
||||||
const result = await this.assetRepository.update({ id: In(ids) }, { status: AssetStatus.ACTIVE, deletedAt: null });
|
const result = await this.assetRepository.update(
|
||||||
|
{ id: In(ids), status: AssetStatus.TRASHED },
|
||||||
|
{ status: AssetStatus.ACTIVE, deletedAt: null },
|
||||||
|
);
|
||||||
return result.affected ?? 0;
|
return result.affected ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,7 +427,6 @@ export class AssetMediaService {
|
|||||||
livePhotoVideoId: dto.livePhotoVideoId,
|
livePhotoVideoId: dto.livePhotoVideoId,
|
||||||
originalFileName: file.originalName,
|
originalFileName: file.originalName,
|
||||||
sidecarPath: sidecarFile?.originalPath,
|
sidecarPath: sidecarFile?.originalPath,
|
||||||
isOffline: dto.isOffline ?? false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sidecarFile) {
|
if (sidecarFile) {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export class JobService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case QueueName.LIBRARY: {
|
case QueueName.LIBRARY: {
|
||||||
return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } });
|
return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } });
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
|||||||
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||||
import {
|
import {
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
|
ILibraryAssetJob,
|
||||||
ILibraryFileJob,
|
ILibraryFileJob,
|
||||||
ILibraryOfflineJob,
|
|
||||||
ILibraryRefreshJob,
|
|
||||||
JobName,
|
JobName,
|
||||||
JOBS_LIBRARY_PAGINATION_SIZE,
|
JOBS_LIBRARY_PAGINATION_SIZE,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
@@ -37,6 +36,10 @@ import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/sto
|
|||||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||||
import { Mocked, vitest } from 'vitest';
|
import { Mocked, vitest } from 'vitest';
|
||||||
|
|
||||||
|
async function* mockWalk() {
|
||||||
|
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||||
|
}
|
||||||
|
|
||||||
describe(LibraryService.name, () => {
|
describe(LibraryService.name, () => {
|
||||||
let sut: LibraryService;
|
let sut: LibraryService;
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ describe(LibraryService.name, () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
cronExpression: '0 1 * * *',
|
cronExpression: '0 1 * * *',
|
||||||
},
|
},
|
||||||
watch: { enabled: false },
|
watch: { enabled: true },
|
||||||
},
|
},
|
||||||
} as SystemConfig);
|
} as SystemConfig);
|
||||||
|
|
||||||
@@ -163,102 +166,29 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
describe('handleQueueAssetRefresh', () => {
|
describe('handleQueueAssetRefresh', () => {
|
||||||
it('should queue refresh of a new asset', async () => {
|
it('should queue refresh of a new asset', async () => {
|
||||||
const mockLibraryJob: ILibraryRefreshJob = {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
refreshModifiedFiles: false,
|
|
||||||
refreshAllFiles: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
|
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
|
||||||
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
storageMock.walk.mockImplementation(mockWalk);
|
||||||
storageMock.walk.mockImplementation(async function* generator() {
|
|
||||||
yield ['/data/user1/photo.jpg'];
|
|
||||||
});
|
|
||||||
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
|
||||||
|
|
||||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id });
|
||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.LIBRARY_SCAN_ASSET,
|
name: JobName.LIBRARY_SYNC_FILE,
|
||||||
data: {
|
data: {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: libraryStub.externalLibrary1.owner.id,
|
ownerId: libraryStub.externalLibrary1.owner.id,
|
||||||
assetPath: '/data/user1/photo.jpg',
|
assetPath: '/data/user1/photo.jpg',
|
||||||
force: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should queue offline check of existing online assets', async () => {
|
|
||||||
const mockLibraryJob: ILibraryRefreshJob = {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
refreshModifiedFiles: false,
|
|
||||||
refreshAllFiles: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
|
||||||
storageMock.walk.mockImplementation(async function* generator() {});
|
|
||||||
assetMock.getWith.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
|
|
||||||
|
|
||||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
|
||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
name: JobName.LIBRARY_CHECK_OFFLINE,
|
|
||||||
data: {
|
|
||||||
id: assetStub.external.id,
|
|
||||||
importPaths: libraryStub.externalLibrary1.importPaths,
|
|
||||||
exclusionPatterns: [],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fail when library can't be found", async () => {
|
it("should fail when library can't be found", async () => {
|
||||||
const mockLibraryJob: ILibraryRefreshJob = {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
refreshModifiedFiles: false,
|
|
||||||
refreshAllFiles: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
libraryMock.get.mockResolvedValue(null);
|
libraryMock.get.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
|
await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED);
|
||||||
});
|
|
||||||
|
|
||||||
it('should force queue new assets', async () => {
|
|
||||||
const mockLibraryJob: ILibraryRefreshJob = {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
refreshModifiedFiles: false,
|
|
||||||
refreshAllFiles: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
|
||||||
storageMock.walk.mockImplementation(async function* generator() {
|
|
||||||
yield ['/data/user1/photo.jpg'];
|
|
||||||
});
|
|
||||||
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
|
||||||
|
|
||||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
|
||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
name: JobName.LIBRARY_SCAN_ASSET,
|
|
||||||
data: {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
ownerId: libraryStub.externalLibrary1.owner.id,
|
|
||||||
assetPath: '/data/user1/photo.jpg',
|
|
||||||
force: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore import paths that do not exist', async () => {
|
it('should ignore import paths that do not exist', async () => {
|
||||||
@@ -276,16 +206,9 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
|
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
|
||||||
|
|
||||||
const mockLibraryJob: ILibraryRefreshJob = {
|
|
||||||
id: libraryStub.externalLibraryWithImportPaths1.id,
|
|
||||||
refreshModifiedFiles: false,
|
|
||||||
refreshAllFiles: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
||||||
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
|
||||||
|
|
||||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id });
|
||||||
|
|
||||||
expect(storageMock.walk).toHaveBeenCalledWith({
|
expect(storageMock.walk).toHaveBeenCalledWith({
|
||||||
pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]],
|
pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]],
|
||||||
@@ -296,9 +219,36 @@ describe(LibraryService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleOfflineCheck', () => {
|
describe('handleQueueRemoveDeleted', () => {
|
||||||
|
it('should queue online check of existing assets', async () => {
|
||||||
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
storageMock.walk.mockImplementation(async function* generator() {});
|
||||||
|
assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
|
||||||
|
|
||||||
|
await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id });
|
||||||
|
|
||||||
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: JobName.LIBRARY_SYNC_ASSET,
|
||||||
|
data: {
|
||||||
|
id: assetStub.external.id,
|
||||||
|
importPaths: libraryStub.externalLibrary1.importPaths,
|
||||||
|
exclusionPatterns: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when library can't be found", async () => {
|
||||||
|
libraryMock.get.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSyncAsset', () => {
|
||||||
it('should skip missing assets', async () => {
|
it('should skip missing assets', async () => {
|
||||||
const mockAssetJob: ILibraryOfflineJob = {
|
const mockAssetJob: ILibraryAssetJob = {
|
||||||
id: assetStub.external.id,
|
id: assetStub.external.id,
|
||||||
importPaths: ['/'],
|
importPaths: ['/'],
|
||||||
exclusionPatterns: [],
|
exclusionPatterns: [],
|
||||||
@@ -306,41 +256,31 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
assetMock.getById.mockResolvedValue(null);
|
assetMock.getById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED);
|
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED);
|
||||||
|
|
||||||
expect(assetMock.update).not.toHaveBeenCalled();
|
expect(assetMock.remove).not.toHaveBeenCalled();
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing with already-offline assets', async () => {
|
|
||||||
const mockAssetJob: ILibraryOfflineJob = {
|
|
||||||
id: assetStub.external.id,
|
|
||||||
importPaths: ['/'],
|
|
||||||
exclusionPatterns: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getById.mockResolvedValue(assetStub.offline);
|
|
||||||
|
|
||||||
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
|
||||||
|
|
||||||
expect(assetMock.update).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should offline assets no longer on disk', async () => {
|
it('should offline assets no longer on disk', async () => {
|
||||||
const mockAssetJob: ILibraryOfflineJob = {
|
const mockAssetJob: ILibraryAssetJob = {
|
||||||
id: assetStub.external.id,
|
id: assetStub.external.id,
|
||||||
importPaths: ['/'],
|
importPaths: ['/'],
|
||||||
exclusionPatterns: [],
|
exclusionPatterns: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getById.mockResolvedValue(assetStub.external);
|
assetMock.getById.mockResolvedValue(assetStub.external);
|
||||||
|
storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory'));
|
||||||
|
|
||||||
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
|
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
|
||||||
|
isOffline: true,
|
||||||
|
deletedAt: expect.any(Date),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should offline assets matching an exclusion pattern', async () => {
|
it('should offline assets matching an exclusion pattern', async () => {
|
||||||
const mockAssetJob: ILibraryOfflineJob = {
|
const mockAssetJob: ILibraryAssetJob = {
|
||||||
id: assetStub.external.id,
|
id: assetStub.external.id,
|
||||||
importPaths: ['/'],
|
importPaths: ['/'],
|
||||||
exclusionPatterns: ['**/user1/**'],
|
exclusionPatterns: ['**/user1/**'],
|
||||||
@@ -348,13 +288,15 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
assetMock.getById.mockResolvedValue(assetStub.external);
|
assetMock.getById.mockResolvedValue(assetStub.external);
|
||||||
|
|
||||||
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
|
isOffline: true,
|
||||||
|
deletedAt: expect.any(Date),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set assets outside of import paths as offline', async () => {
|
it('should set assets outside of import paths as offline', async () => {
|
||||||
const mockAssetJob: ILibraryOfflineJob = {
|
const mockAssetJob: ILibraryAssetJob = {
|
||||||
id: assetStub.external.id,
|
id: assetStub.external.id,
|
||||||
importPaths: ['/data/user2'],
|
importPaths: ['/data/user2'],
|
||||||
exclusionPatterns: [],
|
exclusionPatterns: [],
|
||||||
@@ -363,28 +305,74 @@ describe(LibraryService.name, () => {
|
|||||||
assetMock.getById.mockResolvedValue(assetStub.external);
|
assetMock.getById.mockResolvedValue(assetStub.external);
|
||||||
storageMock.checkFileExists.mockResolvedValue(true);
|
storageMock.checkFileExists.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
|
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
|
||||||
|
isOffline: true,
|
||||||
|
deletedAt: expect.any(Date),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should do nothing with online assets', async () => {
|
it('should do nothing with online assets', async () => {
|
||||||
const mockAssetJob: ILibraryOfflineJob = {
|
const mockAssetJob: ILibraryAssetJob = {
|
||||||
id: assetStub.external.id,
|
id: assetStub.external.id,
|
||||||
importPaths: ['/'],
|
importPaths: ['/'],
|
||||||
exclusionPatterns: [],
|
exclusionPatterns: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getById.mockResolvedValue(assetStub.external);
|
assetMock.getById.mockResolvedValue(assetStub.external);
|
||||||
storageMock.checkFileExists.mockResolvedValue(true);
|
storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
|
||||||
|
|
||||||
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(assetMock.update).not.toHaveBeenCalled();
|
expect(assetMock.updateAll).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should un-trash an asset previously marked as offline', async () => {
|
||||||
|
const mockAssetJob: ILibraryAssetJob = {
|
||||||
|
id: assetStub.external.id,
|
||||||
|
importPaths: ['/'],
|
||||||
|
exclusionPatterns: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
assetMock.getById.mockResolvedValue(assetStub.trashedOffline);
|
||||||
|
storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats);
|
||||||
|
|
||||||
|
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], {
|
||||||
|
deletedAt: null,
|
||||||
|
fileCreatedAt: assetStub.trashedOffline.fileModifiedAt,
|
||||||
|
fileModifiedAt: assetStub.trashedOffline.fileModifiedAt,
|
||||||
|
isOffline: false,
|
||||||
|
originalFileName: 'path.jpg',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleAssetRefresh', () => {
|
it('should update file when mtime has changed', async () => {
|
||||||
|
const mockAssetJob: ILibraryAssetJob = {
|
||||||
|
id: assetStub.external.id,
|
||||||
|
importPaths: ['/'],
|
||||||
|
exclusionPatterns: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const newMTime = new Date();
|
||||||
|
assetMock.getById.mockResolvedValue(assetStub.external);
|
||||||
|
storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats);
|
||||||
|
|
||||||
|
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
|
||||||
|
fileModifiedAt: newMTime,
|
||||||
|
fileCreatedAt: newMTime,
|
||||||
|
isOffline: false,
|
||||||
|
originalFileName: 'photo.jpg',
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSyncFile', () => {
|
||||||
let mockUser: UserEntity;
|
let mockUser: UserEntity;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -397,42 +385,18 @@ describe(LibraryService.name, () => {
|
|||||||
} as Stats);
|
} as Stats);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject an unknown file extension', async () => {
|
it('should import a new asset', async () => {
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
ownerId: mockUser.id,
|
|
||||||
assetPath: '/data/user1/file.xyz',
|
|
||||||
force: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject an unknown file type', async () => {
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
ownerId: mockUser.id,
|
|
||||||
assetPath: '/data/user1/file.xyz',
|
|
||||||
force: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add a new image', async () => {
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
const mockLibraryJob: ILibraryFileJob = {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: mockUser.id,
|
ownerId: mockUser.id,
|
||||||
assetPath: '/data/user1/photo.jpg',
|
assetPath: '/data/user1/photo.jpg',
|
||||||
force: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||||
assetMock.create.mockResolvedValue(assetStub.image);
|
assetMock.create.mockResolvedValue(assetStub.image);
|
||||||
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(assetMock.create.mock.calls).toEqual([
|
expect(assetMock.create.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
@@ -467,19 +431,19 @@ describe(LibraryService.name, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add a new image with sidecar', async () => {
|
it('should import a new asset with sidecar', async () => {
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
const mockLibraryJob: ILibraryFileJob = {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: mockUser.id,
|
ownerId: mockUser.id,
|
||||||
assetPath: '/data/user1/photo.jpg',
|
assetPath: '/data/user1/photo.jpg',
|
||||||
force: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||||
assetMock.create.mockResolvedValue(assetStub.image);
|
assetMock.create.mockResolvedValue(assetStub.image);
|
||||||
storageMock.checkFileExists.mockResolvedValue(true);
|
storageMock.checkFileExists.mockResolvedValue(true);
|
||||||
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(assetMock.create.mock.calls).toEqual([
|
expect(assetMock.create.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
@@ -514,18 +478,18 @@ describe(LibraryService.name, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add a new video', async () => {
|
it('should import a new video', async () => {
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
const mockLibraryJob: ILibraryFileJob = {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: mockUser.id,
|
ownerId: mockUser.id,
|
||||||
assetPath: '/data/user1/video.mp4',
|
assetPath: '/data/user1/video.mp4',
|
||||||
force: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||||
assetMock.create.mockResolvedValue(assetStub.video);
|
assetMock.create.mockResolvedValue(assetStub.video);
|
||||||
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(assetMock.create.mock.calls).toEqual([
|
expect(assetMock.create.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
@@ -568,29 +532,27 @@ describe(LibraryService.name, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not add an image to a soft deleted library', async () => {
|
it('should not import an asset to a soft deleted library', async () => {
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
const mockLibraryJob: ILibraryFileJob = {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: mockUser.id,
|
ownerId: mockUser.id,
|
||||||
assetPath: '/data/user1/photo.jpg',
|
assetPath: '/data/user1/photo.jpg',
|
||||||
force: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||||
assetMock.create.mockResolvedValue(assetStub.image);
|
assetMock.create.mockResolvedValue(assetStub.image);
|
||||||
libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() });
|
libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() });
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
|
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
|
||||||
|
|
||||||
expect(assetMock.create.mock.calls).toEqual([]);
|
expect(assetMock.create.mock.calls).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not import an asset when mtime matches db asset', async () => {
|
it('should not refresh a file whose mtime matches existing asset', async () => {
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
const mockLibraryJob: ILibraryFileJob = {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: mockUser.id,
|
ownerId: mockUser.id,
|
||||||
assetPath: assetStub.hasFileExtension.originalPath,
|
assetPath: assetStub.hasFileExtension.originalPath,
|
||||||
force: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
storageMock.stat.mockResolvedValue({
|
storageMock.stat.mockResolvedValue({
|
||||||
@@ -601,190 +563,52 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension);
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
|
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
|
||||||
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should import an asset when mtime differs from db asset', async () => {
|
it('should skip existing asset', async () => {
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
const mockLibraryJob: ILibraryFileJob = {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: mockUser.id,
|
ownerId: mockUser.id,
|
||||||
assetPath: '/data/user1/photo.jpg',
|
assetPath: '/data/user1/photo.jpg',
|
||||||
force: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
|
||||||
assetMock.create.mockResolvedValue(assetStub.image);
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.METADATA_EXTRACTION,
|
|
||||||
data: {
|
|
||||||
id: assetStub.image.id,
|
|
||||||
source: 'upload',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalledWith({
|
|
||||||
name: JobName.VIDEO_CONVERSION,
|
|
||||||
data: {
|
|
||||||
id: assetStub.image.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should import an asset that is missing a file extension', async () => {
|
it('should not refresh an asset trashed by user', async () => {
|
||||||
// This tests for the case where the file extension is missing from the asset path.
|
|
||||||
// This happened in previous versions of Immich
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
const mockLibraryJob: ILibraryFileJob = {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: mockUser.id,
|
ownerId: mockUser.id,
|
||||||
assetPath: assetStub.missingFileExtension.originalPath,
|
|
||||||
force: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension);
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
|
||||||
|
|
||||||
expect(assetMock.updateAll).toHaveBeenCalledWith(
|
|
||||||
[assetStub.missingFileExtension.id],
|
|
||||||
expect.objectContaining({ originalFileName: 'photo.jpg' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set a missing asset to offline', async () => {
|
|
||||||
storageMock.stat.mockRejectedValue(new Error('Path not found'));
|
|
||||||
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
|
||||||
id: assetStub.image.id,
|
|
||||||
ownerId: mockUser.id,
|
|
||||||
assetPath: '/data/user1/photo.jpg',
|
|
||||||
force: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
|
|
||||||
assetMock.create.mockResolvedValue(assetStub.image);
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
|
||||||
|
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true });
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
|
||||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should online a previously-offline asset', async () => {
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
|
||||||
id: assetStub.offline.id,
|
|
||||||
ownerId: mockUser.id,
|
|
||||||
assetPath: '/data/user1/photo.jpg',
|
|
||||||
force: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline);
|
|
||||||
assetMock.create.mockResolvedValue(assetStub.offline);
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
|
||||||
|
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false });
|
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.METADATA_EXTRACTION,
|
|
||||||
data: {
|
|
||||||
id: assetStub.offline.id,
|
|
||||||
source: 'upload',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalledWith({
|
|
||||||
name: JobName.VIDEO_CONVERSION,
|
|
||||||
data: {
|
|
||||||
id: assetStub.offline.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing when mtime matches existing asset', async () => {
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
|
||||||
id: assetStub.image.id,
|
|
||||||
ownerId: assetStub.image.ownerId,
|
|
||||||
assetPath: '/data/user1/photo.jpg',
|
|
||||||
force: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
|
|
||||||
assetMock.create.mockResolvedValue(assetStub.image);
|
|
||||||
|
|
||||||
expect(assetMock.update).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should refresh an existing asset if forced', async () => {
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
|
||||||
id: assetStub.image.id,
|
|
||||||
ownerId: assetStub.hasFileExtension.ownerId,
|
|
||||||
assetPath: assetStub.hasFileExtension.originalPath,
|
assetPath: assetStub.hasFileExtension.originalPath,
|
||||||
force: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed);
|
||||||
assetMock.create.mockResolvedValue(assetStub.hasFileExtension);
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
|
||||||
|
|
||||||
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], {
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
fileCreatedAt: new Date('2023-01-01'),
|
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||||
fileModifiedAt: new Date('2023-01-01'),
|
|
||||||
originalFileName: assetStub.hasFileExtension.originalFileName,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should refresh an existing asset with modified mtime', async () => {
|
it('should throw BadRequestException when asset does not exist', async () => {
|
||||||
const filemtime = new Date();
|
|
||||||
filemtime.setSeconds(assetStub.image.fileModifiedAt.getSeconds() + 10);
|
|
||||||
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
ownerId: userStub.admin.id,
|
|
||||||
assetPath: '/data/user1/photo.jpg',
|
|
||||||
force: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
storageMock.stat.mockResolvedValue({
|
|
||||||
size: 100,
|
|
||||||
mtime: filemtime,
|
|
||||||
ctime: new Date('2023-01-01'),
|
|
||||||
} as Stats);
|
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
|
||||||
assetMock.create.mockResolvedValue(assetStub.image);
|
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
|
|
||||||
|
|
||||||
expect(assetMock.create).toHaveBeenCalled();
|
|
||||||
const createdAsset = assetMock.create.mock.calls[0][0];
|
|
||||||
|
|
||||||
expect(createdAsset.fileModifiedAt).toEqual(filemtime);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when asset does not exist', async () => {
|
|
||||||
storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'"));
|
storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'"));
|
||||||
|
|
||||||
const mockLibraryJob: ILibraryFileJob = {
|
const mockLibraryJob: ILibraryFileJob = {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
assetPath: '/data/user1/photo.jpg',
|
assetPath: '/data/user1/photo.jpg',
|
||||||
force: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||||
assetMock.create.mockResolvedValue(assetStub.image);
|
assetMock.create.mockResolvedValue(assetStub.image);
|
||||||
|
|
||||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -857,7 +681,6 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
describe('getStatistics', () => {
|
describe('getStatistics', () => {
|
||||||
it('should return library statistics', async () => {
|
it('should return library statistics', async () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
|
||||||
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
|
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
|
||||||
await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({
|
await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({
|
||||||
photos: 10,
|
photos: 10,
|
||||||
@@ -1092,12 +915,11 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.LIBRARY_SCAN_ASSET,
|
name: JobName.LIBRARY_SYNC_FILE,
|
||||||
data: {
|
data: {
|
||||||
id: libraryStub.externalLibraryWithImportPaths1.id,
|
id: libraryStub.externalLibraryWithImportPaths1.id,
|
||||||
assetPath: '/foo/photo.jpg',
|
assetPath: '/foo/photo.jpg',
|
||||||
ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id,
|
ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id,
|
||||||
force: false,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -1114,30 +936,16 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.LIBRARY_SCAN_ASSET,
|
name: JobName.LIBRARY_SYNC_FILE,
|
||||||
data: {
|
data: {
|
||||||
id: libraryStub.externalLibraryWithImportPaths1.id,
|
id: libraryStub.externalLibraryWithImportPaths1.id,
|
||||||
assetPath: '/foo/photo.jpg',
|
assetPath: '/foo/photo.jpg',
|
||||||
ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id,
|
ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id,
|
||||||
force: false,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a file unlink event', async () => {
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
|
||||||
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
|
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external);
|
|
||||||
storageMock.watch.mockImplementation(
|
|
||||||
makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await sut.watchAll();
|
|
||||||
|
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle an error event', async () => {
|
it('should handle an error event', async () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
||||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external);
|
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external);
|
||||||
@@ -1232,72 +1040,23 @@ describe(LibraryService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('queueScan', () => {
|
describe('queueScan', () => {
|
||||||
it('should queue a library scan of external library', async () => {
|
it('should queue a library scan', async () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await sut.queueScan(libraryStub.externalLibrary1.id, {});
|
await sut.queueScan(libraryStub.externalLibrary1.id);
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: JobName.LIBRARY_SCAN,
|
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
|
||||||
data: {
|
data: {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
refreshModifiedFiles: false,
|
|
||||||
refreshAllFiles: false,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should queue a library scan of all modified assets', async () => {
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
|
||||||
|
|
||||||
await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true });
|
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: JobName.LIBRARY_SCAN,
|
name: JobName.LIBRARY_QUEUE_SYNC_ASSETS,
|
||||||
data: {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
refreshModifiedFiles: true,
|
|
||||||
refreshAllFiles: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should queue a forced library scan', async () => {
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
|
||||||
|
|
||||||
await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true });
|
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: JobName.LIBRARY_SCAN,
|
|
||||||
data: {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
refreshModifiedFiles: false,
|
|
||||||
refreshAllFiles: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('queueEmptyTrash', () => {
|
|
||||||
it('should queue the trash job', async () => {
|
|
||||||
await sut.queueRemoveOffline(libraryStub.externalLibrary1.id);
|
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: JobName.LIBRARY_REMOVE_OFFLINE,
|
|
||||||
data: {
|
data: {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
},
|
},
|
||||||
@@ -1311,7 +1070,7 @@ describe(LibraryService.name, () => {
|
|||||||
it('should queue the refresh job', async () => {
|
it('should queue the refresh job', async () => {
|
||||||
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
|
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
|
||||||
|
|
||||||
await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
@@ -1323,48 +1082,32 @@ describe(LibraryService.name, () => {
|
|||||||
]);
|
]);
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.LIBRARY_SCAN,
|
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
|
||||||
data: {
|
data: {
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
refreshModifiedFiles: true,
|
|
||||||
refreshAllFiles: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should queue the force refresh job', async () => {
|
|
||||||
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
|
|
||||||
|
|
||||||
await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS);
|
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.LIBRARY_QUEUE_CLEANUP,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
name: JobName.LIBRARY_SCAN,
|
|
||||||
data: {
|
|
||||||
id: libraryStub.externalLibrary1.id,
|
|
||||||
refreshModifiedFiles: false,
|
|
||||||
refreshAllFiles: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleRemoveOfflineFiles', () => {
|
describe('handleQueueAssetOfflineCheck', () => {
|
||||||
it('should queue trash deletion jobs', async () => {
|
it('should queue removal jobs', async () => {
|
||||||
assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
|
||||||
assetMock.getById.mockResolvedValue(assetStub.image1);
|
assetMock.getById.mockResolvedValue(assetStub.image1);
|
||||||
|
|
||||||
await expect(sut.handleRemoveOffline({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } },
|
{
|
||||||
|
name: JobName.LIBRARY_SYNC_ASSET,
|
||||||
|
data: {
|
||||||
|
id: assetStub.image1.id,
|
||||||
|
importPaths: libraryStub.externalLibrary1.importPaths,
|
||||||
|
exclusionPatterns: libraryStub.externalLibrary1.exclusionPatterns,
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { R_OK } from 'node:constants';
|
import { R_OK } from 'node:constants';
|
||||||
import { Stats } from 'node:fs';
|
|
||||||
import path, { basename, parse } from 'node:path';
|
import path, { basename, parse } from 'node:path';
|
||||||
import picomatch from 'picomatch';
|
import picomatch from 'picomatch';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
@@ -10,27 +9,26 @@ import {
|
|||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
LibraryResponseDto,
|
LibraryResponseDto,
|
||||||
LibraryStatsResponseDto,
|
LibraryStatsResponseDto,
|
||||||
ScanLibraryDto,
|
mapLibrary,
|
||||||
UpdateLibraryDto,
|
UpdateLibraryDto,
|
||||||
ValidateLibraryDto,
|
ValidateLibraryDto,
|
||||||
ValidateLibraryImportPathResponseDto,
|
ValidateLibraryImportPathResponseDto,
|
||||||
ValidateLibraryResponseDto,
|
ValidateLibraryResponseDto,
|
||||||
mapLibrary,
|
|
||||||
} from 'src/dtos/library.dto';
|
} from 'src/dtos/library.dto';
|
||||||
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
import { LibraryEntity } from 'src/entities/library.entity';
|
||||||
import { AssetType } from 'src/enum';
|
import { AssetType } from 'src/enum';
|
||||||
import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||||
import { ArgOf } from 'src/interfaces/event.interface';
|
import { ArgOf } from 'src/interfaces/event.interface';
|
||||||
import {
|
import {
|
||||||
IBaseJob,
|
|
||||||
IEntityJob,
|
IEntityJob,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
|
ILibraryAssetJob,
|
||||||
ILibraryFileJob,
|
ILibraryFileJob,
|
||||||
ILibraryOfflineJob,
|
|
||||||
ILibraryRefreshJob,
|
|
||||||
JOBS_LIBRARY_PAGINATION_SIZE,
|
|
||||||
JobName,
|
JobName,
|
||||||
|
JOBS_LIBRARY_PAGINATION_SIZE,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
} from 'src/interfaces/job.interface';
|
} from 'src/interfaces/job.interface';
|
||||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||||
@@ -78,11 +76,7 @@ export class LibraryService {
|
|||||||
this.jobRepository.addCronJob(
|
this.jobRepository.addCronJob(
|
||||||
'libraryScan',
|
'libraryScan',
|
||||||
scan.cronExpression,
|
scan.cronExpression,
|
||||||
() =>
|
() => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger),
|
||||||
handlePromiseError(
|
|
||||||
this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }),
|
|
||||||
this.logger,
|
|
||||||
),
|
|
||||||
scan.enabled,
|
scan.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -143,7 +137,7 @@ export class LibraryService {
|
|||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
this.logger.debug(`File add event received for ${path} in library ${library.id}}`);
|
this.logger.debug(`File add event received for ${path} in library ${library.id}}`);
|
||||||
if (matcher(path)) {
|
if (matcher(path)) {
|
||||||
await this.scanAssets(library.id, [path], library.ownerId, false);
|
await this.syncFiles(library, [path]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return handlePromiseError(handler(), this.logger);
|
return handlePromiseError(handler(), this.logger);
|
||||||
@@ -151,9 +145,13 @@ export class LibraryService {
|
|||||||
onChange: (path) => {
|
onChange: (path) => {
|
||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
this.logger.debug(`Detected file change for ${path} in library ${library.id}`);
|
this.logger.debug(`Detected file change for ${path} in library ${library.id}`);
|
||||||
|
const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path);
|
||||||
|
if (asset) {
|
||||||
|
await this.syncAssets(library, [asset.id]);
|
||||||
|
}
|
||||||
if (matcher(path)) {
|
if (matcher(path)) {
|
||||||
// Note: if the changed file was not previously imported, it will be imported now.
|
// Note: if the changed file was not previously imported, it will be imported now.
|
||||||
await this.scanAssets(library.id, [path], library.ownerId, false);
|
await this.syncFiles(library, [path]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return handlePromiseError(handler(), this.logger);
|
return handlePromiseError(handler(), this.logger);
|
||||||
@@ -162,8 +160,8 @@ export class LibraryService {
|
|||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`);
|
this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`);
|
||||||
const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path);
|
const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path);
|
||||||
if (asset && matcher(path)) {
|
if (asset) {
|
||||||
await this.assetRepository.update({ id: asset.id, isOffline: true });
|
await this.syncAssets(library, [asset.id]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return handlePromiseError(handler(), this.logger);
|
return handlePromiseError(handler(), this.logger);
|
||||||
@@ -216,7 +214,7 @@ export class LibraryService {
|
|||||||
async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
|
async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
|
||||||
const statistics = await this.repository.getStatistics(id);
|
const statistics = await this.repository.getStatistics(id);
|
||||||
if (!statistics) {
|
if (!statistics) {
|
||||||
throw new BadRequestException('Library not found');
|
throw new BadRequestException(`Library ${id} not found`);
|
||||||
}
|
}
|
||||||
return statistics;
|
return statistics;
|
||||||
}
|
}
|
||||||
@@ -250,20 +248,28 @@ export class LibraryService {
|
|||||||
return mapLibrary(library);
|
return mapLibrary(library);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) {
|
private async syncFiles({ id, ownerId }: LibraryEntity, assetPaths: string[]) {
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
assetPaths.map((assetPath) => ({
|
assetPaths.map((assetPath) => ({
|
||||||
name: JobName.LIBRARY_SCAN_ASSET,
|
name: JobName.LIBRARY_SYNC_FILE,
|
||||||
data: {
|
data: {
|
||||||
id: libraryId,
|
id,
|
||||||
assetPath,
|
assetPath,
|
||||||
ownerId,
|
ownerId,
|
||||||
force,
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async syncAssets({ importPaths, exclusionPatterns }: LibraryEntity, assetIds: string[]) {
|
||||||
|
await this.jobRepository.queueAll(
|
||||||
|
assetIds.map((assetId) => ({
|
||||||
|
name: JobName.LIBRARY_SYNC_ASSET,
|
||||||
|
data: { id: assetId, importPaths, exclusionPatterns },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async validateImportPath(importPath: string): Promise<ValidateLibraryImportPathResponseDto> {
|
private async validateImportPath(importPath: string): Promise<ValidateLibraryImportPathResponseDto> {
|
||||||
const validation = new ValidateLibraryImportPathResponseDto();
|
const validation = new ValidateLibraryImportPathResponseDto();
|
||||||
validation.importPath = importPath;
|
validation.importPath = importPath;
|
||||||
@@ -366,258 +372,182 @@ export class LibraryService {
|
|||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAssetRefresh(job: ILibraryFileJob): Promise<JobStatus> {
|
async handleSyncFile(job: ILibraryFileJob): Promise<JobStatus> {
|
||||||
|
// Only needs to handle new assets
|
||||||
const assetPath = path.normalize(job.assetPath);
|
const assetPath = path.normalize(job.assetPath);
|
||||||
|
|
||||||
const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath);
|
let asset = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath);
|
||||||
|
if (asset) {
|
||||||
let stats: Stats;
|
|
||||||
try {
|
|
||||||
stats = await this.storageRepository.stat(assetPath);
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
// Can't access file, probably offline
|
|
||||||
if (existingAssetEntity) {
|
|
||||||
// Mark asset as offline
|
|
||||||
this.logger.debug(`Marking asset as offline: ${assetPath}`);
|
|
||||||
|
|
||||||
await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: true });
|
|
||||||
return JobStatus.SUCCESS;
|
|
||||||
} else {
|
|
||||||
// File can't be accessed and does not already exist in db
|
|
||||||
throw new BadRequestException('Cannot access file', { cause: error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let doImport = false;
|
|
||||||
let doRefresh = false;
|
|
||||||
|
|
||||||
if (job.force) {
|
|
||||||
doRefresh = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalFileName = parse(assetPath).base;
|
|
||||||
|
|
||||||
if (!existingAssetEntity) {
|
|
||||||
// This asset is new to us, read it from disk
|
|
||||||
this.logger.debug(`Importing new asset: ${assetPath}`);
|
|
||||||
doImport = true;
|
|
||||||
} else if (stats.mtime.toISOString() !== existingAssetEntity.fileModifiedAt.toISOString()) {
|
|
||||||
// File modification time has changed since last time we checked, re-read from disk
|
|
||||||
this.logger.debug(
|
|
||||||
`File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`,
|
|
||||||
);
|
|
||||||
doRefresh = true;
|
|
||||||
} else if (existingAssetEntity.originalFileName !== originalFileName) {
|
|
||||||
// TODO: We can likely remove this check in the second half of 2024 when all assets have likely been re-imported by all users
|
|
||||||
this.logger.debug(
|
|
||||||
`Asset is missing file extension, re-importing: ${assetPath}. Current incorrect filename: ${existingAssetEntity.originalFileName}.`,
|
|
||||||
);
|
|
||||||
doRefresh = true;
|
|
||||||
} else if (!job.force && stats && !existingAssetEntity.isOffline) {
|
|
||||||
// Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing
|
|
||||||
this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats && existingAssetEntity?.isOffline) {
|
|
||||||
// File was previously offline but is now online
|
|
||||||
this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`);
|
|
||||||
await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: false });
|
|
||||||
doRefresh = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!doImport && !doRefresh) {
|
|
||||||
// If we don't import, exit here
|
|
||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
let assetType: AssetType;
|
let stat;
|
||||||
|
try {
|
||||||
if (mimeTypes.isImage(assetPath)) {
|
stat = await this.storageRepository.stat(assetPath);
|
||||||
assetType = AssetType.IMAGE;
|
} catch (error: any) {
|
||||||
} else if (mimeTypes.isVideo(assetPath)) {
|
if (error.code === 'ENOENT') {
|
||||||
assetType = AssetType.VIDEO;
|
this.logger.error(`File not found: ${assetPath}`);
|
||||||
} else {
|
return JobStatus.SKIPPED;
|
||||||
throw new BadRequestException(`Unsupported file type ${assetPath}`);
|
}
|
||||||
|
this.logger.error(`Error reading file: ${assetPath}. Error: ${error}`);
|
||||||
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Importing new library asset: ${assetPath}`);
|
||||||
|
|
||||||
|
const library = await this.repository.get(job.id, true);
|
||||||
|
if (!library || library.deletedAt) {
|
||||||
|
this.logger.error('Cannot import asset into deleted library');
|
||||||
|
return JobStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: device asset id is deprecated, remove it
|
||||||
|
const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, '');
|
||||||
|
|
||||||
|
const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`);
|
||||||
|
|
||||||
// TODO: doesn't xmp replace the file extension? Will need investigation
|
// TODO: doesn't xmp replace the file extension? Will need investigation
|
||||||
let sidecarPath: string | null = null;
|
let sidecarPath: string | null = null;
|
||||||
if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) {
|
if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) {
|
||||||
sidecarPath = `${assetPath}.xmp`;
|
sidecarPath = `${assetPath}.xmp`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: device asset id is deprecated, remove it
|
const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE;
|
||||||
const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, '');
|
|
||||||
|
|
||||||
let assetId;
|
const mtime = stat.mtime;
|
||||||
if (doImport) {
|
|
||||||
const library = await this.repository.get(job.id, true);
|
|
||||||
if (library?.deletedAt) {
|
|
||||||
this.logger.error('Cannot import asset into deleted library');
|
|
||||||
return JobStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`);
|
asset = await this.assetRepository.create({
|
||||||
|
ownerId: job.ownerId,
|
||||||
|
libraryId: job.id,
|
||||||
|
checksum: pathHash,
|
||||||
|
originalPath: assetPath,
|
||||||
|
deviceAssetId,
|
||||||
|
deviceId: 'Library Import',
|
||||||
|
fileCreatedAt: mtime,
|
||||||
|
fileModifiedAt: mtime,
|
||||||
|
localDateTime: mtime,
|
||||||
|
type: assetType,
|
||||||
|
originalFileName: parse(assetPath).base,
|
||||||
|
|
||||||
// TODO: In wait of refactoring the domain asset service, this function is just manually written like this
|
sidecarPath,
|
||||||
const addedAsset = await this.assetRepository.create({
|
isExternal: true,
|
||||||
ownerId: job.ownerId,
|
});
|
||||||
libraryId: job.id,
|
|
||||||
checksum: pathHash,
|
|
||||||
originalPath: assetPath,
|
|
||||||
deviceAssetId,
|
|
||||||
deviceId: 'Library Import',
|
|
||||||
fileCreatedAt: stats.mtime,
|
|
||||||
fileModifiedAt: stats.mtime,
|
|
||||||
localDateTime: stats.mtime,
|
|
||||||
type: assetType,
|
|
||||||
originalFileName,
|
|
||||||
sidecarPath,
|
|
||||||
isExternal: true,
|
|
||||||
});
|
|
||||||
assetId = addedAsset.id;
|
|
||||||
} else if (doRefresh && existingAssetEntity) {
|
|
||||||
assetId = existingAssetEntity.id;
|
|
||||||
await this.assetRepository.updateAll([existingAssetEntity.id], {
|
|
||||||
fileCreatedAt: stats.mtime,
|
|
||||||
fileModifiedAt: stats.mtime,
|
|
||||||
originalFileName,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Not importing and not refreshing, do nothing
|
|
||||||
return JobStatus.SKIPPED;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Queueing metadata extraction for: ${assetPath}`);
|
await this.queuePostSyncJobs(asset);
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } });
|
|
||||||
|
|
||||||
if (assetType === AssetType.VIDEO) {
|
|
||||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueScan(id: string, dto: ScanLibraryDto) {
|
async queuePostSyncJobs(asset: AssetEntity) {
|
||||||
|
this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`);
|
||||||
|
|
||||||
|
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
|
||||||
|
|
||||||
|
if (asset.type === AssetType.VIDEO) {
|
||||||
|
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueScan(id: string) {
|
||||||
await this.findOrFail(id);
|
await this.findOrFail(id);
|
||||||
|
|
||||||
await this.jobRepository.queue({
|
await this.jobRepository.queue({
|
||||||
name: JobName.LIBRARY_SCAN,
|
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
|
||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
refreshModifiedFiles: dto.refreshModifiedFiles ?? false,
|
|
||||||
refreshAllFiles: dto.refreshAllFiles ?? false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueRemoveOffline(id: string) {
|
async handleQueueSyncAll(): Promise<JobStatus> {
|
||||||
this.logger.verbose(`Queueing offline file removal from library ${id}`);
|
this.logger.debug(`Refreshing all external libraries`);
|
||||||
await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleQueueAllScan(job: IBaseJob): Promise<JobStatus> {
|
|
||||||
this.logger.debug(`Refreshing all external libraries: force=${job.force}`);
|
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} });
|
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} });
|
||||||
|
|
||||||
const libraries = await this.repository.getAll(true);
|
const libraries = await this.repository.getAll(true);
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
libraries.map((library) => ({
|
libraries.map((library) => ({
|
||||||
name: JobName.LIBRARY_SCAN,
|
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
|
||||||
|
data: {
|
||||||
|
id: library.id,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
await this.jobRepository.queueAll(
|
||||||
|
libraries.map((library) => ({
|
||||||
|
name: JobName.LIBRARY_QUEUE_SYNC_ASSETS,
|
||||||
data: {
|
data: {
|
||||||
id: library.id,
|
id: library.id,
|
||||||
refreshModifiedFiles: !job.force,
|
|
||||||
refreshAllFiles: job.force ?? false,
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleOfflineCheck(job: ILibraryOfflineJob): Promise<JobStatus> {
|
async handleSyncAsset(job: ILibraryAssetJob): Promise<JobStatus> {
|
||||||
const asset = await this.assetRepository.getById(job.id);
|
const asset = await this.assetRepository.getById(job.id);
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
// Asset is no longer in the database, skip
|
|
||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.isOffline) {
|
const markOffline = async (explanation: string) => {
|
||||||
this.logger.verbose(`Asset is already offline: ${asset.originalPath}`);
|
if (!asset.isOffline) {
|
||||||
return JobStatus.SUCCESS;
|
this.logger.debug(`${explanation}, removing: ${asset.originalPath}`);
|
||||||
}
|
await this.assetRepository.updateAll([asset.id], { isOffline: true, deletedAt: new Date() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path));
|
const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path));
|
||||||
if (!isInPath) {
|
if (!isInPath) {
|
||||||
this.logger.debug(`Asset is no longer in an import path, marking offline: ${asset.originalPath}`);
|
await markOffline('Asset is no longer in an import path');
|
||||||
await this.assetRepository.update({ id: asset.id, isOffline: true });
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern));
|
const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern));
|
||||||
if (isExcluded) {
|
if (isExcluded) {
|
||||||
this.logger.debug(`Asset is covered by an exclusion pattern, marking offline: ${asset.originalPath}`);
|
await markOffline('Asset is covered by an exclusion pattern');
|
||||||
await this.assetRepository.update({ id: asset.id, isOffline: true });
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK);
|
let stat;
|
||||||
if (!fileExists) {
|
try {
|
||||||
this.logger.debug(`Asset is no longer found on disk, marking offline: ${asset.originalPath}`);
|
stat = await this.storageRepository.stat(asset.originalPath);
|
||||||
await this.assetRepository.update({ id: asset.id, isOffline: true });
|
} catch {
|
||||||
|
await markOffline('Asset is no longer on disk or is inaccessible because of permissions');
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.verbose(
|
const mtime = stat.mtime;
|
||||||
`Asset is found on disk, not covered by an exclusion pattern, and is in an import path, keeping online: ${asset.originalPath}`,
|
const isAssetModified = mtime.toISOString() !== asset.fileModifiedAt.toISOString();
|
||||||
);
|
|
||||||
|
|
||||||
|
if (asset.isOffline || isAssetModified) {
|
||||||
|
this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`);
|
||||||
|
//TODO: When we have asset status, we need to leave deletedAt as is when status is trashed
|
||||||
|
await this.assetRepository.updateAll([asset.id], {
|
||||||
|
isOffline: false,
|
||||||
|
deletedAt: null,
|
||||||
|
fileCreatedAt: mtime,
|
||||||
|
fileModifiedAt: mtime,
|
||||||
|
originalFileName: parse(asset.originalPath).base,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAssetModified) {
|
||||||
|
this.logger.debug(`Asset was modified, queuing metadata extraction for: ${asset.originalPath}`);
|
||||||
|
await this.queuePostSyncJobs(asset);
|
||||||
|
}
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleRemoveOffline(job: IEntityJob): Promise<JobStatus> {
|
async handleQueueSyncFiles(job: IEntityJob): Promise<JobStatus> {
|
||||||
this.logger.debug(`Removing offline assets for library ${job.id}`);
|
|
||||||
|
|
||||||
const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) =>
|
|
||||||
this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id, true),
|
|
||||||
);
|
|
||||||
|
|
||||||
let offlineAssets = 0;
|
|
||||||
for await (const assets of assetPagination) {
|
|
||||||
offlineAssets += assets.length;
|
|
||||||
if (assets.length > 0) {
|
|
||||||
this.logger.debug(`Discovered ${offlineAssets} offline assets in library ${job.id}`);
|
|
||||||
await this.jobRepository.queueAll(
|
|
||||||
assets.map((asset) => ({
|
|
||||||
name: JobName.ASSET_DELETION,
|
|
||||||
data: {
|
|
||||||
id: asset.id,
|
|
||||||
deleteOnDisk: false,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
this.logger.verbose(`Queued deletion of ${assets.length} offline assets in library ${job.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offlineAssets) {
|
|
||||||
this.logger.debug(`Finished queueing deletion of ${offlineAssets} offline assets for library ${job.id}`);
|
|
||||||
} else {
|
|
||||||
this.logger.debug(`Found no offline assets to delete from library ${job.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise<JobStatus> {
|
|
||||||
const library = await this.repository.get(job.id);
|
const library = await this.repository.get(job.id);
|
||||||
if (!library) {
|
if (!library) {
|
||||||
|
this.logger.debug(`Library ${job.id} not found, skipping refresh`);
|
||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Refreshing library ${library.id}`);
|
this.logger.log(`Refreshing library ${library.id} for new assets`);
|
||||||
|
|
||||||
const validImportPaths: string[] = [];
|
const validImportPaths: string[] = [];
|
||||||
|
|
||||||
@@ -630,55 +560,66 @@ export class LibraryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validImportPaths.length === 0) {
|
if (validImportPaths) {
|
||||||
|
const assetsOnDisk = this.storageRepository.walk({
|
||||||
|
pathsToCrawl: validImportPaths,
|
||||||
|
includeHidden: false,
|
||||||
|
exclusionPatterns: library.exclusionPatterns,
|
||||||
|
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for await (const assetBatch of assetsOnDisk) {
|
||||||
|
count += assetBatch.length;
|
||||||
|
this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`);
|
||||||
|
await this.syncFiles(library, assetBatch);
|
||||||
|
this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this.logger.warn(`No valid import paths found for library ${library.id}`);
|
this.logger.warn(`No valid import paths found for library ${library.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetsOnDisk = this.storageRepository.walk({
|
await this.repository.update({ id: job.id, refreshedAt: new Date() });
|
||||||
pathsToCrawl: validImportPaths,
|
|
||||||
includeHidden: false,
|
|
||||||
exclusionPatterns: library.exclusionPatterns,
|
|
||||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
|
||||||
});
|
|
||||||
|
|
||||||
let crawledAssets = 0;
|
return JobStatus.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
for await (const assetBatch of assetsOnDisk) {
|
async handleQueueSyncAssets(job: IEntityJob): Promise<JobStatus> {
|
||||||
crawledAssets += assetBatch.length;
|
const library = await this.repository.get(job.id);
|
||||||
this.logger.debug(`Discovered ${crawledAssets} asset(s) on disk for library ${library.id}...`);
|
if (!library) {
|
||||||
await this.scanAssets(job.id, assetBatch, library.ownerId, job.refreshAllFiles ?? false);
|
return JobStatus.SKIPPED;
|
||||||
this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (crawledAssets) {
|
this.logger.log(`Scanning library ${library.id} for removed assets`);
|
||||||
this.logger.debug(`Finished queueing scan of ${crawledAssets} assets on disk for library ${library.id}`);
|
|
||||||
} else {
|
|
||||||
this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) =>
|
const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) =>
|
||||||
this.assetRepository.getWith(pagination, WithProperty.IS_ONLINE, job.id),
|
this.assetRepository.getAll(pagination, { libraryId: job.id }),
|
||||||
);
|
);
|
||||||
|
|
||||||
let onlineAssetCount = 0;
|
let assetCount = 0;
|
||||||
for await (const assets of onlineAssets) {
|
for await (const assets of onlineAssets) {
|
||||||
onlineAssetCount += assets.length;
|
assetCount += assets.length;
|
||||||
this.logger.debug(`Discovered ${onlineAssetCount} asset(s) in library ${library.id}...`);
|
this.logger.debug(`Discovered ${assetCount} asset(s) in library ${library.id}...`);
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
assets.map((asset) => ({
|
assets.map((asset) => ({
|
||||||
name: JobName.LIBRARY_CHECK_OFFLINE,
|
name: JobName.LIBRARY_SYNC_ASSET,
|
||||||
data: { id: asset.id, importPaths: validImportPaths, exclusionPatterns: library.exclusionPatterns },
|
data: { id: asset.id, importPaths: library.importPaths, exclusionPatterns: library.exclusionPatterns },
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
this.logger.debug(`Queued online check of ${assets.length} asset(s) in library ${library.id}...`);
|
this.logger.debug(`Queued check of ${assets.length} asset(s) in library ${library.id}...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onlineAssetCount) {
|
if (assetCount) {
|
||||||
this.logger.log(`Finished queueing online check of ${onlineAssetCount} assets for library ${library.id}`);
|
this.logger.log(`Finished queueing check of ${assetCount} assets for library ${library.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.repository.update({ id: job.id, refreshedAt: new Date() });
|
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should handle lists of numbers', async () => {
|
it('should handle lists of numbers', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
metadataMock.readTags.mockResolvedValue({ ISO: [160] as any });
|
metadataMock.readTags.mockResolvedValue({ ISO: [160] });
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||||
@@ -411,7 +411,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should extract tags from Keywords as a list with a number', async () => {
|
it('should extract tags from Keywords as a list with a number', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] as any[] });
|
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] });
|
||||||
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
|
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
@@ -467,6 +467,17 @@ describe(MetadataService.name, () => {
|
|||||||
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
|
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent', 2024] });
|
||||||
|
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
|
||||||
|
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
|
expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
|
||||||
|
expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Mom/Dad'] });
|
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Mom/Dad'] });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
|
|||||||
import { OnEmit } from 'src/decorators';
|
import { OnEmit } from 'src/decorators';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
import { ExifEntity } from 'src/entities/exif.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
import { AssetType, SourceType } from 'src/enum';
|
import { AssetType, SourceType } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
@@ -236,7 +237,7 @@ export class MetadataService {
|
|||||||
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
||||||
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
||||||
|
|
||||||
const exifData = {
|
const exifData: Partial<ExifEntity> = {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
|
||||||
// dates
|
// dates
|
||||||
@@ -264,7 +265,7 @@ export class MetadataService {
|
|||||||
make: exifTags.Make ?? null,
|
make: exifTags.Make ?? null,
|
||||||
model: exifTags.Model ?? null,
|
model: exifTags.Model ?? null,
|
||||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||||
iso: validate(exifTags.ISO),
|
iso: validate(exifTags.ISO) as number,
|
||||||
exposureTime: exifTags.ExposureTime ?? null,
|
exposureTime: exifTags.ExposureTime ?? null,
|
||||||
lensModel: exifTags.LensModel ?? null,
|
lensModel: exifTags.LensModel ?? null,
|
||||||
fNumber: validate(exifTags.FNumber),
|
fNumber: validate(exifTags.FNumber),
|
||||||
@@ -395,13 +396,13 @@ export class MetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
||||||
const tags: Array<string | number> = [];
|
const tags: string[] = [];
|
||||||
if (exifTags.TagsList) {
|
if (exifTags.TagsList) {
|
||||||
tags.push(...exifTags.TagsList);
|
tags.push(...exifTags.TagsList.map(String));
|
||||||
} else if (exifTags.HierarchicalSubject) {
|
} else if (exifTags.HierarchicalSubject) {
|
||||||
tags.push(
|
tags.push(
|
||||||
...exifTags.HierarchicalSubject.map((tag) =>
|
...exifTags.HierarchicalSubject.map((tag) =>
|
||||||
tag
|
String(tag)
|
||||||
// convert | to /
|
// convert | to /
|
||||||
.replaceAll('/', '<PLACEHOLDER>')
|
.replaceAll('/', '<PLACEHOLDER>')
|
||||||
.replaceAll('|', '/')
|
.replaceAll('|', '/')
|
||||||
@@ -413,10 +414,10 @@ export class MetadataService {
|
|||||||
if (!Array.isArray(keywords)) {
|
if (!Array.isArray(keywords)) {
|
||||||
keywords = [keywords];
|
keywords = [keywords];
|
||||||
}
|
}
|
||||||
tags.push(...keywords);
|
tags.push(...keywords.map(String));
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) });
|
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
||||||
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) });
|
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,12 +86,12 @@ export class MicroservicesService {
|
|||||||
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
|
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
|
||||||
[JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data),
|
[JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data),
|
||||||
[JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data),
|
[JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data),
|
||||||
[JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data),
|
[JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(),
|
||||||
[JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data),
|
[JobName.LIBRARY_QUEUE_SYNC_FILES]: (data) => this.libraryService.handleQueueSyncFiles(data), //Queues all files paths on disk
|
||||||
|
[JobName.LIBRARY_SYNC_FILE]: (data) => this.libraryService.handleSyncFile(data), //Handles a single path on disk //Watcher calls for new files
|
||||||
|
[JobName.LIBRARY_QUEUE_SYNC_ASSETS]: (data) => this.libraryService.handleQueueSyncAssets(data), //Queues all library assets
|
||||||
|
[JobName.LIBRARY_SYNC_ASSET]: (data) => this.libraryService.handleSyncAsset(data), //Handles all library assets // Watcher calls for unlink and changed
|
||||||
[JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data),
|
[JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data),
|
||||||
[JobName.LIBRARY_CHECK_OFFLINE]: (data) => this.libraryService.handleOfflineCheck(data),
|
|
||||||
[JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleRemoveOffline(data),
|
|
||||||
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
|
|
||||||
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
|
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
|
||||||
[JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
|
[JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
|
||||||
[JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data),
|
[JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data),
|
||||||
|
|||||||
@@ -616,11 +616,6 @@ describe(NotificationService.name, () => {
|
|||||||
await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED);
|
await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if email could not be sent', async () => {
|
|
||||||
systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true } } });
|
|
||||||
await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.FAILED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send mail successfully', async () => {
|
it('should send mail successfully', async () => {
|
||||||
systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } });
|
systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } });
|
||||||
notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' });
|
notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
|
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEmit } from 'src/decorators';
|
||||||
@@ -140,7 +140,7 @@ export class NotificationService {
|
|||||||
try {
|
try {
|
||||||
await this.notificationRepository.verifySmtp(dto.transport);
|
await this.notificationRepository.verifySmtp(dto.transport);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HttpException('Failed to verify SMTP configuration', HttpStatus.BAD_REQUEST, { cause: error });
|
throw new BadRequestException('Failed to verify SMTP configuration', { cause: error });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { server } = await this.configCore.getConfig({ withCache: false });
|
const { server } = await this.configCore.getConfig({ withCache: false });
|
||||||
@@ -152,7 +152,7 @@ export class NotificationService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.notificationRepository.sendEmail({
|
const { messageId } = await this.notificationRepository.sendEmail({
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Test email from Immich',
|
subject: 'Test email from Immich',
|
||||||
html,
|
html,
|
||||||
@@ -161,6 +161,8 @@ export class NotificationService {
|
|||||||
replyTo: dto.replyTo || dto.from,
|
replyTo: dto.replyTo || dto.from,
|
||||||
smtp: dto.transport,
|
smtp: dto.transport,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleUserSignup({ id, tempPassword }: INotifySignupJob) {
|
async handleUserSignup({ id, tempPassword }: INotifySignupJob) {
|
||||||
@@ -312,10 +314,6 @@ export class NotificationService {
|
|||||||
imageAttachments: data.imageAttachments,
|
imageAttachments: data.imageAttachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
return JobStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`);
|
this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`);
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe(TrashService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should restore', async () => {
|
it('should restore', async () => {
|
||||||
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
|
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false });
|
||||||
trashMock.restore.mockResolvedValue(1);
|
trashMock.restore.mockResolvedValue(1);
|
||||||
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 });
|
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 });
|
||||||
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
|
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
|
||||||
@@ -83,7 +83,7 @@ describe(TrashService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should empty the trash', async () => {
|
it('should empty the trash', async () => {
|
||||||
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
|
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false });
|
||||||
trashMock.empty.mockResolvedValue(1);
|
trashMock.empty.mockResolvedValue(1);
|
||||||
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
|
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
|
||||||
expect(trashMock.empty).toHaveBeenCalledWith('user-id');
|
expect(trashMock.empty).toHaveBeenCalledWith('user-id');
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function searchAssetBuilder(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = _.pick(options, ['isFavorite', 'isOffline', 'isVisible', 'type']);
|
const status = _.pick(options, ['isFavorite', 'isVisible', 'type']);
|
||||||
const {
|
const {
|
||||||
isArchived,
|
isArchived,
|
||||||
isEncoded,
|
isEncoded,
|
||||||
|
|||||||
214
server/test/fixtures/asset.stub.ts
vendored
214
server/test/fixtures/asset.stub.ts
vendored
@@ -70,9 +70,9 @@ export const assetStub = {
|
|||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
isOffline: false,
|
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
noWebpPath: Object.freeze<AssetEntity>({
|
noWebpPath: Object.freeze<AssetEntity>({
|
||||||
@@ -104,13 +104,13 @@ export const assetStub = {
|
|||||||
originalFileName: 'IMG_456.jpg',
|
originalFileName: 'IMG_456.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
isOffline: false,
|
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 123_000,
|
fileSizeInByte: 123_000,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
noThumbhash: Object.freeze<AssetEntity>({
|
noThumbhash: Object.freeze<AssetEntity>({
|
||||||
@@ -133,7 +133,6 @@ export const assetStub = {
|
|||||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isOffline: false,
|
|
||||||
duration: null,
|
duration: null,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
@@ -146,6 +145,7 @@ export const assetStub = {
|
|||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
primaryImage: Object.freeze<AssetEntity>({
|
primaryImage: Object.freeze<AssetEntity>({
|
||||||
@@ -173,7 +173,6 @@ export const assetStub = {
|
|||||||
isExternal: false,
|
isExternal: false,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
@@ -191,6 +190,7 @@ export const assetStub = {
|
|||||||
{ id: 'stack-child-asset-2' } as AssetEntity,
|
{ id: 'stack-child-asset-2' } as AssetEntity,
|
||||||
]),
|
]),
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
image: Object.freeze<AssetEntity>({
|
image: Object.freeze<AssetEntity>({
|
||||||
@@ -218,7 +218,6 @@ export const assetStub = {
|
|||||||
isExternal: false,
|
isExternal: false,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
@@ -231,9 +230,50 @@ export const assetStub = {
|
|||||||
exifImageWidth: 2160,
|
exifImageWidth: 2160,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
trashed: Object.freeze<AssetEntity>({
|
trashed: Object.freeze<AssetEntity>({
|
||||||
|
id: 'asset-id',
|
||||||
|
deviceAssetId: 'device-asset-id',
|
||||||
|
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
owner: userStub.user1,
|
||||||
|
ownerId: 'user-id',
|
||||||
|
deviceId: 'device-id',
|
||||||
|
originalPath: '/original/path.jpg',
|
||||||
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
|
type: AssetType.IMAGE,
|
||||||
|
files,
|
||||||
|
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||||
|
encodedVideoPath: null,
|
||||||
|
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
deletedAt: new Date('2023-02-24T05:06:29.716Z'),
|
||||||
|
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
isFavorite: false,
|
||||||
|
isArchived: false,
|
||||||
|
duration: null,
|
||||||
|
isVisible: true,
|
||||||
|
isExternal: false,
|
||||||
|
livePhotoVideo: null,
|
||||||
|
livePhotoVideoId: null,
|
||||||
|
tags: [],
|
||||||
|
sharedLinks: [],
|
||||||
|
originalFileName: 'asset-id.jpg',
|
||||||
|
faces: [],
|
||||||
|
sidecarPath: null,
|
||||||
|
exifInfo: {
|
||||||
|
fileSizeInByte: 5000,
|
||||||
|
exifImageHeight: 3840,
|
||||||
|
exifImageWidth: 2160,
|
||||||
|
} as ExifEntity,
|
||||||
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
|
status: AssetStatus.TRASHED,
|
||||||
|
}),
|
||||||
|
|
||||||
|
trashedOffline: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
status: AssetStatus.ACTIVE,
|
status: AssetStatus.ACTIVE,
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@@ -259,7 +299,6 @@ export const assetStub = {
|
|||||||
isExternal: false,
|
isExternal: false,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
@@ -271,8 +310,8 @@ export const assetStub = {
|
|||||||
exifImageWidth: 2160,
|
exifImageWidth: 2160,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
archived: Object.freeze<AssetEntity>({
|
archived: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
status: AssetStatus.ACTIVE,
|
status: AssetStatus.ACTIVE,
|
||||||
@@ -298,7 +337,6 @@ export const assetStub = {
|
|||||||
isExternal: false,
|
isExternal: false,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
@@ -311,6 +349,7 @@ export const assetStub = {
|
|||||||
exifImageWidth: 2160,
|
exifImageWidth: 2160,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
external: Object.freeze<AssetEntity>({
|
external: Object.freeze<AssetEntity>({
|
||||||
@@ -338,97 +377,19 @@ export const assetStub = {
|
|||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
|
libraryId: 'library-id',
|
||||||
|
library: libraryStub.externalLibrary1,
|
||||||
|
tags: [],
|
||||||
|
sharedLinks: [],
|
||||||
|
originalFileName: 'asset-id.jpg',
|
||||||
|
faces: [],
|
||||||
|
deletedAt: null,
|
||||||
|
sidecarPath: null,
|
||||||
|
exifInfo: {
|
||||||
|
fileSizeInByte: 5000,
|
||||||
|
} as ExifEntity,
|
||||||
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
libraryId: 'library-id',
|
|
||||||
library: libraryStub.externalLibrary1,
|
|
||||||
tags: [],
|
|
||||||
sharedLinks: [],
|
|
||||||
originalFileName: 'asset-id.jpg',
|
|
||||||
faces: [],
|
|
||||||
deletedAt: null,
|
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
|
||||||
fileSizeInByte: 5000,
|
|
||||||
} as ExifEntity,
|
|
||||||
duplicateId: null,
|
|
||||||
}),
|
|
||||||
|
|
||||||
offline: Object.freeze<AssetEntity>({
|
|
||||||
id: 'asset-id',
|
|
||||||
status: AssetStatus.ACTIVE,
|
|
||||||
deviceAssetId: 'device-asset-id',
|
|
||||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
owner: userStub.user1,
|
|
||||||
ownerId: 'user-id',
|
|
||||||
deviceId: 'device-id',
|
|
||||||
originalPath: '/original/path.jpg',
|
|
||||||
checksum: Buffer.from('file hash', 'utf8'),
|
|
||||||
type: AssetType.IMAGE,
|
|
||||||
files,
|
|
||||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
|
||||||
encodedVideoPath: null,
|
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
isFavorite: true,
|
|
||||||
isArchived: false,
|
|
||||||
isExternal: false,
|
|
||||||
duration: null,
|
|
||||||
isVisible: true,
|
|
||||||
livePhotoVideo: null,
|
|
||||||
livePhotoVideoId: null,
|
|
||||||
isOffline: true,
|
|
||||||
tags: [],
|
|
||||||
sharedLinks: [],
|
|
||||||
originalFileName: 'asset-id.jpg',
|
|
||||||
faces: [],
|
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
|
||||||
fileSizeInByte: 5000,
|
|
||||||
} as ExifEntity,
|
|
||||||
deletedAt: null,
|
|
||||||
duplicateId: null,
|
|
||||||
}),
|
|
||||||
|
|
||||||
externalOffline: Object.freeze<AssetEntity>({
|
|
||||||
id: 'asset-id',
|
|
||||||
status: AssetStatus.ACTIVE,
|
|
||||||
deviceAssetId: 'device-asset-id',
|
|
||||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
owner: userStub.user1,
|
|
||||||
ownerId: 'user-id',
|
|
||||||
deviceId: 'device-id',
|
|
||||||
originalPath: '/data/user1/photo.jpg',
|
|
||||||
checksum: Buffer.from('path hash', 'utf8'),
|
|
||||||
type: AssetType.IMAGE,
|
|
||||||
files,
|
|
||||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
|
||||||
encodedVideoPath: null,
|
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
isFavorite: true,
|
|
||||||
isArchived: false,
|
|
||||||
isExternal: true,
|
|
||||||
duration: null,
|
|
||||||
isVisible: true,
|
|
||||||
livePhotoVideo: null,
|
|
||||||
livePhotoVideoId: null,
|
|
||||||
isOffline: true,
|
|
||||||
libraryId: 'library-id',
|
|
||||||
library: libraryStub.externalLibrary1,
|
|
||||||
tags: [],
|
|
||||||
sharedLinks: [],
|
|
||||||
originalFileName: 'asset-id.jpg',
|
|
||||||
faces: [],
|
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
|
||||||
fileSizeInByte: 5000,
|
|
||||||
} as ExifEntity,
|
|
||||||
deletedAt: null,
|
|
||||||
duplicateId: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
image1: Object.freeze<AssetEntity>({
|
image1: Object.freeze<AssetEntity>({
|
||||||
@@ -457,7 +418,6 @@ export const assetStub = {
|
|||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
isOffline: false,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.ext',
|
originalFileName: 'asset-id.ext',
|
||||||
@@ -467,6 +427,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
imageFrom2015: Object.freeze<AssetEntity>({
|
imageFrom2015: Object.freeze<AssetEntity>({
|
||||||
@@ -490,7 +451,6 @@ export const assetStub = {
|
|||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
isOffline: false,
|
|
||||||
duration: null,
|
duration: null,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
@@ -505,6 +465,7 @@ export const assetStub = {
|
|||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
video: Object.freeze<AssetEntity>({
|
video: Object.freeze<AssetEntity>({
|
||||||
@@ -529,7 +490,6 @@ export const assetStub = {
|
|||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
isOffline: false,
|
|
||||||
duration: null,
|
duration: null,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
@@ -545,6 +505,7 @@ export const assetStub = {
|
|||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
livePhotoMotionAsset: Object.freeze({
|
livePhotoMotionAsset: Object.freeze({
|
||||||
@@ -664,7 +625,6 @@ export const assetStub = {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
isOffline: false,
|
|
||||||
duration: null,
|
duration: null,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
@@ -683,6 +643,7 @@ export const assetStub = {
|
|||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
sidecar: Object.freeze<AssetEntity>({
|
sidecar: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
@@ -705,7 +666,6 @@ export const assetStub = {
|
|||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
isOffline: false,
|
|
||||||
duration: null,
|
duration: null,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
@@ -717,6 +677,7 @@ export const assetStub = {
|
|||||||
sidecarPath: '/original/path.ext.xmp',
|
sidecarPath: '/original/path.ext.xmp',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
sidecarWithoutExt: Object.freeze<AssetEntity>({
|
sidecarWithoutExt: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
@@ -739,7 +700,6 @@ export const assetStub = {
|
|||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
isOffline: false,
|
|
||||||
duration: null,
|
duration: null,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
@@ -751,41 +711,7 @@ export const assetStub = {
|
|||||||
sidecarPath: '/original/path.xmp',
|
sidecarPath: '/original/path.xmp',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
}),
|
|
||||||
|
|
||||||
readOnly: Object.freeze<AssetEntity>({
|
|
||||||
id: 'read-only-asset',
|
|
||||||
status: AssetStatus.ACTIVE,
|
|
||||||
deviceAssetId: 'device-asset-id',
|
|
||||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
owner: userStub.user1,
|
|
||||||
ownerId: 'user-id',
|
|
||||||
deviceId: 'device-id',
|
|
||||||
originalPath: '/original/path.ext',
|
|
||||||
thumbhash: null,
|
|
||||||
checksum: Buffer.from('file hash', 'utf8'),
|
|
||||||
type: AssetType.IMAGE,
|
|
||||||
files: [previewFile],
|
|
||||||
encodedVideoPath: null,
|
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
isFavorite: true,
|
|
||||||
isArchived: false,
|
|
||||||
isExternal: false,
|
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
duration: null,
|
|
||||||
isVisible: true,
|
|
||||||
livePhotoVideo: null,
|
|
||||||
livePhotoVideoId: null,
|
|
||||||
tags: [],
|
|
||||||
sharedLinks: [],
|
|
||||||
originalFileName: 'asset-id.ext',
|
|
||||||
faces: [],
|
|
||||||
sidecarPath: '/original/path.ext.xmp',
|
|
||||||
deletedAt: null,
|
|
||||||
duplicateId: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
hasEncodedVideo: Object.freeze<AssetEntity>({
|
hasEncodedVideo: Object.freeze<AssetEntity>({
|
||||||
@@ -810,7 +736,6 @@ export const assetStub = {
|
|||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
isOffline: false,
|
|
||||||
duration: null,
|
duration: null,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
@@ -824,6 +749,7 @@ export const assetStub = {
|
|||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
missingFileExtension: Object.freeze<AssetEntity>({
|
missingFileExtension: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
@@ -850,7 +776,6 @@ export const assetStub = {
|
|||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
libraryId: 'library-id',
|
libraryId: 'library-id',
|
||||||
library: libraryStub.externalLibrary1,
|
library: libraryStub.externalLibrary1,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -863,6 +788,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
hasFileExtension: Object.freeze<AssetEntity>({
|
hasFileExtension: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
@@ -889,7 +815,6 @@ export const assetStub = {
|
|||||||
isVisible: true,
|
isVisible: true,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
libraryId: 'library-id',
|
libraryId: 'library-id',
|
||||||
library: libraryStub.externalLibrary1,
|
library: libraryStub.externalLibrary1,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -902,6 +827,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
imageDng: Object.freeze<AssetEntity>({
|
imageDng: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
@@ -928,7 +854,6 @@ export const assetStub = {
|
|||||||
isExternal: false,
|
isExternal: false,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
@@ -941,6 +866,7 @@ export const assetStub = {
|
|||||||
bitsPerSample: 14,
|
bitsPerSample: 14,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
hasEmbedding: Object.freeze<AssetEntity>({
|
hasEmbedding: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id-embedding',
|
id: 'asset-id-embedding',
|
||||||
@@ -967,7 +893,6 @@ export const assetStub = {
|
|||||||
isExternal: false,
|
isExternal: false,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
@@ -982,6 +907,7 @@ export const assetStub = {
|
|||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
embedding: Array.from({ length: 512 }, Math.random),
|
embedding: Array.from({ length: 512 }, Math.random),
|
||||||
},
|
},
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
hasDupe: Object.freeze<AssetEntity>({
|
hasDupe: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id-dupe',
|
id: 'asset-id-dupe',
|
||||||
@@ -1008,7 +934,6 @@ export const assetStub = {
|
|||||||
isExternal: false,
|
isExternal: false,
|
||||||
livePhotoVideo: null,
|
livePhotoVideo: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
isOffline: false,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
@@ -1023,5 +948,6 @@ export const assetStub = {
|
|||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
embedding: Array.from({ length: 512 }, Math.random),
|
embedding: Array.from({ length: 512 }, Math.random),
|
||||||
},
|
},
|
||||||
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
|
|||||||
getLivePhotoCount: vitest.fn(),
|
getLivePhotoCount: vitest.fn(),
|
||||||
updateAll: vitest.fn(),
|
updateAll: vitest.fn(),
|
||||||
updateDuplicates: vitest.fn(),
|
updateDuplicates: vitest.fn(),
|
||||||
getExternalLibraryAssetPaths: vitest.fn(),
|
|
||||||
getByLibraryIdAndOriginalPath: vitest.fn(),
|
getByLibraryIdAndOriginalPath: vitest.fn(),
|
||||||
deleteAll: vitest.fn(),
|
deleteAll: vitest.fn(),
|
||||||
update: vitest.fn(),
|
update: vitest.fn(),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Mocked } from 'vitest';
|
|||||||
export const newNotificationRepositoryMock = (): Mocked<INotificationRepository> => {
|
export const newNotificationRepositoryMock = (): Mocked<INotificationRepository> => {
|
||||||
return {
|
return {
|
||||||
renderEmail: vitest.fn(),
|
renderEmail: vitest.fn(),
|
||||||
sendEmail: vitest.fn(),
|
sendEmail: vitest.fn().mockResolvedValue({ messageId: 'message-1' }),
|
||||||
verifySmtp: vitest.fn(),
|
verifySmtp: vitest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 85,
|
functions: 80,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.7.8",
|
"@formatjs/icu-messageformat-parser": "^2.7.8",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.115.0",
|
"version": "1.116.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ interface Options {
|
|||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls a function when a click occurs outside of the element, or when the escape key is pressed.
|
||||||
|
* @param node
|
||||||
|
* @param options Object containing onOutclick and onEscape functions
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn {
|
export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn {
|
||||||
const { onOutclick, onEscape } = options;
|
const { onOutclick, onEscape } = options;
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
|
const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
|
||||||
const { selectionChanged, container, openDropdown } = options;
|
const { selectionChanged, container, openDropdown, isOpen } = options;
|
||||||
|
if (!isOpen) {
|
||||||
|
// reset the scroll position before opening the menu
|
||||||
|
container?.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
if (openDropdown) {
|
if (openDropdown) {
|
||||||
openDropdown(event);
|
openDropdown(event);
|
||||||
await tick();
|
await tick();
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ interface Options {
|
|||||||
onFocusOut?: (event: FocusEvent) => void;
|
onFocusOut?: (event: FocusEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls a function when focus leaves the element.
|
||||||
|
* @param node
|
||||||
|
* @param options Object containing onFocusOut function
|
||||||
|
*/
|
||||||
export function focusOutside(node: HTMLElement, options: Options = {}) {
|
export function focusOutside(node: HTMLElement, options: Options = {}) {
|
||||||
const { onFocusOut } = options;
|
const { onFocusOut } = options;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** Focus the given element when it is mounted. */
|
||||||
export const initInput = (element: HTMLInputElement) => {
|
export const initInput = (element: HTMLInputElement) => {
|
||||||
element.focus();
|
element.focus();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem
|
|||||||
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
||||||
type IntersectionObserverActionProperties = {
|
type IntersectionObserverActionProperties = {
|
||||||
key?: string;
|
key?: string;
|
||||||
|
/** Function to execute when the element leaves the viewport */
|
||||||
onSeparate?: OnSeparateCallback;
|
onSeparate?: OnSeparateCallback;
|
||||||
|
/** Function to execute when the element enters the viewport */
|
||||||
onIntersect?: OnIntersectCallback;
|
onIntersect?: OnIntersectCallback;
|
||||||
|
|
||||||
root?: Element | Document | null;
|
root?: Element | Document | null;
|
||||||
@@ -112,6 +114,12 @@ function _intersectionObserver(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
|
||||||
|
* @param element
|
||||||
|
* @param properties One or multiple configurations for the IntersectionObserver(s)
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
export function intersectionObserver(
|
export function intersectionObserver(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import type { Action } from 'svelte/action';
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables keyboard navigation (up and down arrows) for a list of elements.
|
||||||
|
* @param node Element which listens for keyboard events
|
||||||
|
* @param container Element containing the list of elements
|
||||||
|
*/
|
||||||
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
|
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
|
||||||
const moveFocus = (direction: 'up' | 'down') => {
|
const moveFocus = (direction: 'up' | 'down') => {
|
||||||
const children = Array.from(container?.children);
|
const children = Array.from(container?.children);
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ export type Shortcut = {
|
|||||||
|
|
||||||
export type ShortcutOptions<T = HTMLElement> = {
|
export type ShortcutOptions<T = HTMLElement> = {
|
||||||
shortcut: Shortcut;
|
shortcut: Shortcut;
|
||||||
|
/** If true, the event handler will not execute if the event comes from an input field */
|
||||||
ignoreInputFields?: boolean;
|
ignoreInputFields?: boolean;
|
||||||
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
|
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
|
||||||
preventDefault?: boolean;
|
preventDefault?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Determines whether an event should be ignored. The event will be ignored if:
|
||||||
|
* - The element dispatching the event is not the same as the element which the event listener is attached to
|
||||||
|
* - The element dispatching the event is an input field
|
||||||
|
*/
|
||||||
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
|
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
|
||||||
if (event.target === event.currentTarget) {
|
if (event.target === event.currentTarget) {
|
||||||
return false;
|
return false;
|
||||||
@@ -33,6 +38,7 @@ export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Bind a single keyboard shortcut to node. */
|
||||||
export const shortcut = <T extends HTMLElement>(
|
export const shortcut = <T extends HTMLElement>(
|
||||||
node: T,
|
node: T,
|
||||||
option: ShortcutOptions<T>,
|
option: ShortcutOptions<T>,
|
||||||
@@ -47,6 +53,7 @@ export const shortcut = <T extends HTMLElement>(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Binds multiple keyboard shortcuts to node */
|
||||||
export const shortcuts = <T extends HTMLElement>(
|
export const shortcuts = <T extends HTMLElement>(
|
||||||
node: T,
|
node: T,
|
||||||
options: ShortcutOptions<T>[],
|
options: ShortcutOptions<T>[],
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { decodeBase64 } from '$lib/utils';
|
import { decodeBase64 } from '$lib/utils';
|
||||||
import { thumbHashToRGBA } from 'thumbhash';
|
import { thumbHashToRGBA } from 'thumbhash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a thumbnail onto a canvas from a base64 encoded hash.
|
||||||
|
* @param canvas
|
||||||
|
* @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString)
|
||||||
|
*/
|
||||||
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
|
|||||||
@@ -109,7 +109,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isOwned}
|
{#if isOwned}
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} size="20" title={$t('options')}>
|
<ButtonContextMenu
|
||||||
|
icon={mdiDotsVertical}
|
||||||
|
size="20"
|
||||||
|
title={$t('options')}
|
||||||
|
direction="right"
|
||||||
|
align="top-left"
|
||||||
|
>
|
||||||
{#if role === AlbumUserRole.Viewer}
|
{#if role === AlbumUserRole.Viewer}
|
||||||
<MenuOption onClick={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
<MenuOption onClick={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -186,13 +186,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||||
<div class="mr-4">
|
<div class="mr-4">
|
||||||
<ButtonContextMenu
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('comment_options')} size="16">
|
||||||
icon={mdiDotsVertical}
|
|
||||||
title={$t('comment_options')}
|
|
||||||
align="top-right"
|
|
||||||
direction="left"
|
|
||||||
size="16"
|
|
||||||
>
|
|
||||||
<MenuOption
|
<MenuOption
|
||||||
activeColor="bg-red-200"
|
activeColor="bg-red-200"
|
||||||
icon={mdiDeleteOutline}
|
icon={mdiDeleteOutline}
|
||||||
@@ -239,13 +233,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||||
<div class="mr-4">
|
<div class="mr-4">
|
||||||
<ButtonContextMenu
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('reaction_options')} size="16">
|
||||||
icon={mdiDotsVertical}
|
|
||||||
title={$t('reaction_options')}
|
|
||||||
align="top-right"
|
|
||||||
direction="left"
|
|
||||||
size="16"
|
|
||||||
>
|
|
||||||
<MenuOption
|
<MenuOption
|
||||||
activeColor="bg-red-200"
|
activeColor="bg-red-200"
|
||||||
icon={mdiDeleteOutline}
|
icon={mdiDeleteOutline}
|
||||||
|
|||||||
@@ -59,7 +59,6 @@
|
|||||||
export let onClose: () => void;
|
export let onClose: () => void;
|
||||||
|
|
||||||
const sharedLink = getSharedLink();
|
const sharedLink = getSharedLink();
|
||||||
|
|
||||||
$: isOwner = $user && asset.ownerId === $user?.id;
|
$: isOwner = $user && asset.ownerId === $user?.id;
|
||||||
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
|
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
|
||||||
// $: showEditorButton =
|
// $: showEditorButton =
|
||||||
@@ -87,7 +86,7 @@
|
|||||||
<ShareAction {asset} />
|
<ShareAction {asset} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.isOffline}
|
{#if asset.isOffline}
|
||||||
<CircleIconButton color="opaque" icon={mdiAlertOutline} on:click={onShowDetail} title={$t('asset_offline')} />
|
<CircleIconButton color="alert" icon={mdiAlertOutline} on:click={onShowDetail} title={$t('asset_offline')} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.livePhotoVideoId}
|
{#if asset.livePhotoVideoId}
|
||||||
<slot name="motion-photo" />
|
<slot name="motion-photo" />
|
||||||
@@ -129,7 +128,7 @@
|
|||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<DeleteAction {asset} {onAction} />
|
<DeleteAction {asset} {onAction} />
|
||||||
|
|
||||||
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
<ButtonContextMenu color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
||||||
{#if showSlideshow}
|
{#if showSlideshow}
|
||||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
|
import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
|
||||||
|
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { isSharedLink } from '$lib/utils';
|
import { isSharedLink } from '$lib/utils';
|
||||||
import { removeTag, tagAssets } from '$lib/utils/asset-utils';
|
import { removeTag, tagAssets } from '$lib/utils/asset-utils';
|
||||||
@@ -76,5 +77,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
|
<Portal>
|
||||||
|
<TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
|
||||||
|
</Portal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||||
|
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||||
|
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
export let albums: AlbumResponseDto[] = [];
|
export let albums: AlbumResponseDto[] = [];
|
||||||
@@ -147,12 +148,21 @@
|
|||||||
{#if asset.isOffline}
|
{#if asset.isOffline}
|
||||||
<section class="px-4 py-4">
|
<section class="px-4 py-4">
|
||||||
<div role="alert">
|
<div role="alert">
|
||||||
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">{$t('asset_offline')}</div>
|
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">
|
||||||
<div class="rounded-b border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
{$t('asset_offline')}
|
||||||
|
</div>
|
||||||
|
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||||
<p>
|
<p>
|
||||||
{$t('asset_offline_description')}
|
{#if $user?.isAdmin}
|
||||||
|
<p>{$t('admin.asset_offline_description')}</p>
|
||||||
|
{:else}
|
||||||
|
{$t('asset_offline_description')}
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded-b bg-red-500 px-4 py-2 text-white text-sm">
|
||||||
|
<p>{asset.originalPath}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -325,12 +335,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isShowChangeDate}
|
{#if isShowChangeDate}
|
||||||
<ChangeDate
|
<Portal>
|
||||||
initialDate={dateTime}
|
<ChangeDate
|
||||||
initialTimeZone={timeZone ?? ''}
|
initialDate={dateTime}
|
||||||
onConfirm={handleConfirmChangeDate}
|
initialTimeZone={timeZone ?? ''}
|
||||||
onCancel={() => (isShowChangeDate = false)}
|
onConfirm={handleConfirmChangeDate}
|
||||||
/>
|
onCancel={() => (isShowChangeDate = false)}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if asset.exifInfo?.fileSizeInByte}
|
{#if asset.exifInfo?.fileSizeInByte}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" context="module">
|
<script lang="ts" context="module">
|
||||||
import type { HTMLButtonAttributes, HTMLLinkAttributes } from 'svelte/elements';
|
import type { HTMLButtonAttributes, HTMLLinkAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
|
export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'alert';
|
||||||
export type Padding = '1' | '2' | '3';
|
export type Padding = '1' | '2' | '3';
|
||||||
|
|
||||||
type BaseProps = {
|
type BaseProps = {
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white',
|
opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white',
|
||||||
light: 'bg-white hover:bg-[#d3d3d3]',
|
light: 'bg-white hover:bg-[#d3d3d3]',
|
||||||
dark: 'bg-[#202123] hover:bg-[#d3d3d3]',
|
dark: 'bg-[#202123] hover:bg-[#d3d3d3]',
|
||||||
|
alert: 'text-[#ff0000] hover:text-white',
|
||||||
gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black',
|
gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black',
|
||||||
primary:
|
primary:
|
||||||
'bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 hover:dark:bg-immich-dark-primary/80 text-white dark:text-immich-dark-gray',
|
'bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 hover:dark:bg-immich-dark-primary/80 text-white dark:text-immich-dark-gray',
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
size="20"
|
size="20"
|
||||||
icon={mdiDotsVertical}
|
icon={mdiDotsVertical}
|
||||||
title={$t('show_person_options')}
|
title={$t('show_person_options')}
|
||||||
|
direction="right"
|
||||||
|
align="top-left"
|
||||||
>
|
>
|
||||||
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
|
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
|
||||||
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
|
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
|
||||||
|
|||||||
@@ -237,7 +237,7 @@
|
|||||||
|
|
||||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={handleUpdate} />
|
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={handleUpdate} />
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
||||||
import { tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import type { FormEventHandler } from 'svelte/elements';
|
import type { FormEventHandler } from 'svelte/elements';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { focusOutside } from '$lib/actions/focus-outside';
|
import { focusOutside } from '$lib/actions/focus-outside';
|
||||||
@@ -53,8 +53,28 @@
|
|||||||
let selectedIndex: number | undefined;
|
let selectedIndex: number | undefined;
|
||||||
let optionRefs: HTMLElement[] = [];
|
let optionRefs: HTMLElement[] = [];
|
||||||
let input: HTMLInputElement;
|
let input: HTMLInputElement;
|
||||||
|
let bounds: DOMRect | undefined;
|
||||||
|
let dropdownDirection: 'bottom' | 'top' = 'bottom';
|
||||||
|
|
||||||
const inputId = `combobox-${id}`;
|
const inputId = `combobox-${id}`;
|
||||||
const listboxId = `listbox-${id}`;
|
const listboxId = `listbox-${id}`;
|
||||||
|
/**
|
||||||
|
* Buffer distance between the dropdown and top/bottom of the viewport.
|
||||||
|
*/
|
||||||
|
const dropdownOffset = 15;
|
||||||
|
/**
|
||||||
|
* Minimum space required for the dropdown to be displayed at the bottom of the input.
|
||||||
|
*/
|
||||||
|
const bottomBreakpoint = 225;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const inputEntry = entries[0];
|
||||||
|
if (inputEntry.intersectionRatio < 1) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 },
|
||||||
|
);
|
||||||
|
|
||||||
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
|
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
@@ -62,6 +82,23 @@
|
|||||||
searchQuery = selectedOption ? selectedOption.label : '';
|
searchQuery = selectedOption ? selectedOption.label : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: position = calculatePosition(bounds);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
observer.observe(input);
|
||||||
|
const scrollableAncestor = input.closest('.overflow-y-auto, .overflow-y-scroll');
|
||||||
|
scrollableAncestor?.addEventListener('scroll', onPositionChange);
|
||||||
|
window.visualViewport?.addEventListener('resize', onPositionChange);
|
||||||
|
window.visualViewport?.addEventListener('scroll', onPositionChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
scrollableAncestor?.removeEventListener('scroll', onPositionChange);
|
||||||
|
window.visualViewport?.removeEventListener('resize', onPositionChange);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', onPositionChange);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const activate = () => {
|
const activate = () => {
|
||||||
isActive = true;
|
isActive = true;
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
@@ -76,6 +113,7 @@
|
|||||||
|
|
||||||
const openDropdown = () => {
|
const openDropdown = () => {
|
||||||
isOpen = true;
|
isOpen = true;
|
||||||
|
bounds = getInputPosition();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDropdown = () => {
|
const closeDropdown = () => {
|
||||||
@@ -116,8 +154,67 @@
|
|||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
onSelect(selectedOption);
|
onSelect(selectedOption);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const calculatePosition = (boundary: DOMRect | undefined) => {
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
dropdownDirection = getComboboxDirection(boundary, visualViewport);
|
||||||
|
|
||||||
|
if (!boundary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = boundary.left + (visualViewport?.offsetLeft || 0);
|
||||||
|
const offsetTop = visualViewport?.offsetTop || 0;
|
||||||
|
|
||||||
|
if (dropdownDirection === 'top') {
|
||||||
|
return {
|
||||||
|
bottom: `${window.innerHeight - boundary.top - offsetTop}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${boundary.width}px`,
|
||||||
|
maxHeight: maxHeight(boundary.top - dropdownOffset),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportHeight = visualViewport?.height || 0;
|
||||||
|
const availableHeight = viewportHeight - boundary.bottom;
|
||||||
|
return {
|
||||||
|
top: `${boundary.bottom + offsetTop}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${boundary.width}px`,
|
||||||
|
maxHeight: maxHeight(availableHeight - dropdownOffset),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxHeight = (size: number) => `min(${size}px,18rem)`;
|
||||||
|
|
||||||
|
const onPositionChange = () => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bounds = getInputPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getComboboxDirection = (
|
||||||
|
boundary: DOMRect | undefined,
|
||||||
|
visualViewport: VisualViewport | null,
|
||||||
|
): 'bottom' | 'top' => {
|
||||||
|
if (!boundary) {
|
||||||
|
return 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
const visualHeight = visualViewport?.height || 0;
|
||||||
|
const heightBelow = visualHeight - boundary.bottom;
|
||||||
|
const heightAbove = boundary.top;
|
||||||
|
|
||||||
|
const isViewportScaled = visualHeight && Math.floor(visualHeight) !== Math.floor(window.innerHeight);
|
||||||
|
|
||||||
|
return heightBelow <= bottomBreakpoint && heightAbove > heightBelow && !isViewportScaled ? 'top' : 'bottom';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInputPosition = () => input?.getBoundingClientRect();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:resize={onPositionChange} />
|
||||||
<label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
|
<label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
|
||||||
<div
|
<div
|
||||||
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||||
@@ -150,7 +247,8 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:this={input}
|
bind:this={input}
|
||||||
class:!pl-8={isActive}
|
class:!pl-8={isActive}
|
||||||
class:!rounded-b-none={isOpen}
|
class:!rounded-b-none={isOpen && dropdownDirection === 'bottom'}
|
||||||
|
class:!rounded-t-none={isOpen && dropdownDirection === 'top'}
|
||||||
class:cursor-pointer={!isActive}
|
class:cursor-pointer={!isActive}
|
||||||
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
||||||
id={inputId}
|
id={inputId}
|
||||||
@@ -217,8 +315,16 @@
|
|||||||
role="listbox"
|
role="listbox"
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
transition:fly={{ duration: 250 }}
|
transition:fly={{ duration: 250 }}
|
||||||
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-[10000]"
|
class="fixed text-left text-sm w-full overflow-y-auto bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-900 z-[10000]"
|
||||||
|
class:rounded-b-xl={dropdownDirection === 'bottom'}
|
||||||
|
class:rounded-t-xl={dropdownDirection === 'top'}
|
||||||
|
class:shadow={dropdownDirection === 'bottom'}
|
||||||
class:border={isOpen}
|
class:border={isOpen}
|
||||||
|
style:top={position?.top}
|
||||||
|
style:bottom={position?.bottom}
|
||||||
|
style:left={position?.left}
|
||||||
|
style:width={position?.width}
|
||||||
|
style:max-height={position?.maxHeight}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
@@ -228,7 +334,7 @@
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected={selectedIndex === 0}
|
aria-selected={selectedIndex === 0}
|
||||||
aria-disabled={true}
|
aria-disabled={true}
|
||||||
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700"
|
class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700"
|
||||||
id={`${listboxId}-${0}`}
|
id={`${listboxId}-${0}`}
|
||||||
on:click={() => closeDropdown()}
|
on:click={() => closeDropdown()}
|
||||||
>
|
>
|
||||||
@@ -240,7 +346,7 @@
|
|||||||
<li
|
<li
|
||||||
aria-selected={index === selectedIndex}
|
aria-selected={index === selectedIndex}
|
||||||
bind:this={optionRefs[index]}
|
bind:this={optionRefs[index]}
|
||||||
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700"
|
class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
|
||||||
id={`${listboxId}-${index}`}
|
id={`${listboxId}-${index}`}
|
||||||
on:click={() => handleSelect(option)}
|
on:click={() => handleSelect(option)}
|
||||||
role="option"
|
role="option"
|
||||||
|
|||||||
@@ -20,11 +20,11 @@
|
|||||||
/**
|
/**
|
||||||
* The alignment of the context menu relative to the button.
|
* The alignment of the context menu relative to the button.
|
||||||
*/
|
*/
|
||||||
export let align: Align = 'top-left';
|
export let align: Align = 'top-right';
|
||||||
/**
|
/**
|
||||||
* The direction in which the context menu should open.
|
* The direction in which the context menu should open.
|
||||||
*/
|
*/
|
||||||
export let direction: 'left' | 'right' = 'right';
|
export let direction: 'left' | 'right' = 'left';
|
||||||
export let color: Color = 'transparent';
|
export let color: Color = 'transparent';
|
||||||
export let size: string | undefined = undefined;
|
export let size: string | undefined = undefined;
|
||||||
export let padding: Padding | undefined = undefined;
|
export let padding: Padding | undefined = undefined;
|
||||||
|
|||||||
@@ -18,25 +18,26 @@
|
|||||||
let left: number;
|
let left: number;
|
||||||
let top: number;
|
let top: number;
|
||||||
|
|
||||||
// We need to bind clientHeight since the bounding box may return a height
|
|
||||||
// of zero when starting the 'slide' animation.
|
|
||||||
let height: number;
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (menuElement) {
|
if (menuElement) {
|
||||||
const rect = menuElement.getBoundingClientRect();
|
const rect = menuElement.getBoundingClientRect();
|
||||||
const directionWidth = direction === 'left' ? rect.width : 0;
|
const directionWidth = direction === 'left' ? rect.width : 0;
|
||||||
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
|
const menuHeight = menuElement.clientHeight || 0;
|
||||||
|
|
||||||
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
|
const calcLeft = Math.min(window.innerWidth - rect.width, x - directionWidth);
|
||||||
|
left = Math.max(0, calcLeft);
|
||||||
top = Math.min(window.innerHeight - menuHeight, y);
|
top = Math.min(window.innerHeight - menuHeight, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:clientHeight={height}
|
class="fixed z-10 overflow-hidden rounded-lg duration-[250ms] ease-in {isVisible
|
||||||
class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
? 'shadow-lg transition-shadow'
|
||||||
|
: 'shadow-none transition-none'}"
|
||||||
|
class:shadow-none={!isVisible}
|
||||||
|
class:shadow-lg={isVisible}
|
||||||
|
class:transition-none={!isVisible}
|
||||||
style:left="{left}px"
|
style:left="{left}px"
|
||||||
style:top="{top}px"
|
style:top="{top}px"
|
||||||
transition:slide={{ duration: 250, easing: quintOut }}
|
transition:slide={{ duration: 250, easing: quintOut }}
|
||||||
@@ -48,9 +49,9 @@
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class:max-h-[100vh]={isVisible}
|
class="flex flex-col transition-all duration-[250ms] ease-in-out outline-none immich-scrollbar bg-slate-100 relative min-w-[200px] max-w-[200px] sm:max-w-[256px] rounded-lg {isVisible
|
||||||
class:max-h-0={!isVisible}
|
? 'translate-x-0 max-h-dvh overflow-y-auto'
|
||||||
class="flex flex-col transition-all duration-[250ms] ease-in-out outline-none"
|
: `${direction === 'left' ? 'translate-x-28' : '-translate-x-28'} max-h-0 overflow-y-hidden`}"
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
{#if icon}
|
{#if icon}
|
||||||
<Icon path={icon} ariaHidden={true} size="18" />
|
<div class="flex-none">
|
||||||
|
<Icon path={icon} ariaHidden={true} size="18" />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -68,28 +68,24 @@
|
|||||||
use:focusTrap
|
use:focusTrap
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="z-[9999] max-w-[95vw] {modalWidth} overflow-hidden rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg pt-3 pb-4"
|
class="flex flex-col max-h-[min(95dvh,60rem)] z-[9999] max-w-[95vw] {modalWidth} overflow-hidden rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg pt-3 pb-4"
|
||||||
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
|
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
>
|
>
|
||||||
<div
|
<div class="immich-scrollbar overflow-y-auto pt-1" class:pb-4={isStickyBottom}>
|
||||||
class="immich-scrollbar overflow-y-auto max-h-[min(92dvh,64rem)] py-1"
|
|
||||||
class:scroll-pb-40={isStickyBottom}
|
|
||||||
class:sm:scroll-p-24={isStickyBottom}
|
|
||||||
>
|
|
||||||
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
|
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
|
||||||
<div class="px-5 pt-0">
|
<div class="px-5 pt-0">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
{#if isStickyBottom}
|
|
||||||
<div
|
|
||||||
class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky -bottom-[4px] py-2 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500 shadow z-[9999]"
|
|
||||||
>
|
|
||||||
<slot name="sticky-bottom" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if isStickyBottom}
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky pt-4 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500"
|
||||||
|
>
|
||||||
|
<slot name="sticky-bottom" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -107,6 +107,8 @@
|
|||||||
size="24"
|
size="24"
|
||||||
padding="3"
|
padding="3"
|
||||||
hideContent
|
hideContent
|
||||||
|
direction="right"
|
||||||
|
align="top-left"
|
||||||
>
|
>
|
||||||
<SharedLinkEdit menuItem {onEdit} />
|
<SharedLinkEdit menuItem {onEdit} />
|
||||||
<SharedLinkCopy menuItem {link} />
|
<SharedLinkCopy menuItem {link} />
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
"refreshing_all_libraries": "تحديث كافة المكتبات",
|
"refreshing_all_libraries": "تحديث كافة المكتبات",
|
||||||
"registration": "تسجيل المدير",
|
"registration": "تسجيل المدير",
|
||||||
"registration_description": "بما أنك أول مستخدم في النظام، سيتم تعيينك كمسؤول وستكون مسؤولًا عن المهام الإدارية، وسيتم إنشاء مستخدمين إضافيين بواسطتك.",
|
"registration_description": "بما أنك أول مستخدم في النظام، سيتم تعيينك كمسؤول وستكون مسؤولًا عن المهام الإدارية، وسيتم إنشاء مستخدمين إضافيين بواسطتك.",
|
||||||
"removing_offline_files": "إزالة الملفات غير المتصلة",
|
"removing_deleted_files": "إزالة الملفات غير المتصلة",
|
||||||
"repair_all": "إصلاح الكل",
|
"repair_all": "إصلاح الكل",
|
||||||
"repair_matched_items": "تمت مطابقة {count, plural, one {# عنصر} other {# عناصر}}",
|
"repair_matched_items": "تمت مطابقة {count, plural, one {# عنصر} other {# عناصر}}",
|
||||||
"repaired_items": "تم إصلاح {count, plural, one {# عنصر} other {# عناصر}}",
|
"repaired_items": "تم إصلاح {count, plural, one {# عنصر} other {# عناصر}}",
|
||||||
@@ -671,8 +671,8 @@
|
|||||||
"unable_to_remove_api_key": "تعذر إزالة مفتاح API",
|
"unable_to_remove_api_key": "تعذر إزالة مفتاح API",
|
||||||
"unable_to_remove_assets_from_shared_link": "غير قادر على إزالة المحتويات من الرابط المشترك",
|
"unable_to_remove_assets_from_shared_link": "غير قادر على إزالة المحتويات من الرابط المشترك",
|
||||||
"unable_to_remove_comment": "",
|
"unable_to_remove_comment": "",
|
||||||
|
"unable_to_remove_deleted_assets": "غير قادر على إزالة الملفات غير المتصلة",
|
||||||
"unable_to_remove_library": "غير قادر على إزالة المكتبة",
|
"unable_to_remove_library": "غير قادر على إزالة المكتبة",
|
||||||
"unable_to_remove_offline_files": "غير قادر على إزالة الملفات غير المتصلة",
|
|
||||||
"unable_to_remove_partner": "غير قادر على إزالة الشريك",
|
"unable_to_remove_partner": "غير قادر على إزالة الشريك",
|
||||||
"unable_to_remove_reaction": "غير قادر على إزالة رد الفعل",
|
"unable_to_remove_reaction": "غير قادر على إزالة رد الفعل",
|
||||||
"unable_to_remove_user": "",
|
"unable_to_remove_user": "",
|
||||||
@@ -1072,10 +1072,10 @@
|
|||||||
"remove_assets_shared_link_confirmation": "هل أنت متأكد أنك تريد إزالة {count, plural, one {# المحتوى} other {# المحتويات}} من رابط المشاركة هذا؟",
|
"remove_assets_shared_link_confirmation": "هل أنت متأكد أنك تريد إزالة {count, plural, one {# المحتوى} other {# المحتويات}} من رابط المشاركة هذا؟",
|
||||||
"remove_assets_title": "هل تريد إزالة المحتويات؟",
|
"remove_assets_title": "هل تريد إزالة المحتويات؟",
|
||||||
"remove_custom_date_range": "إزالة النطاق الزمني المخصص",
|
"remove_custom_date_range": "إزالة النطاق الزمني المخصص",
|
||||||
|
"remove_deleted_assets": "إزالة الملفات الغير متصلة",
|
||||||
"remove_from_album": "إزالة من الألبوم",
|
"remove_from_album": "إزالة من الألبوم",
|
||||||
"remove_from_favorites": "إزالة من المفضلة",
|
"remove_from_favorites": "إزالة من المفضلة",
|
||||||
"remove_from_shared_link": "إزالة من الرابط المشترك",
|
"remove_from_shared_link": "إزالة من الرابط المشترك",
|
||||||
"remove_offline_files": "إزالة الملفات الغير متصلة",
|
|
||||||
"remove_user": "إزالة المستخدم",
|
"remove_user": "إزالة المستخدم",
|
||||||
"removed_api_key": "تم إزالة مفتاح API: {name}",
|
"removed_api_key": "تم إزالة مفتاح API: {name}",
|
||||||
"removed_from_archive": "تمت إزالتها من الأرشيف",
|
"removed_from_archive": "تمت إزالتها من الأرشيف",
|
||||||
|
|||||||
@@ -12,19 +12,19 @@
|
|||||||
"add_a_description": "Добави описание",
|
"add_a_description": "Добави описание",
|
||||||
"add_a_location": "Добави местоположение",
|
"add_a_location": "Добави местоположение",
|
||||||
"add_a_name": "Добави име",
|
"add_a_name": "Добави име",
|
||||||
"add_a_title": "Добави заглавие",
|
"add_a_title": "Добавете заглавие",
|
||||||
"add_exclusion_pattern": "Добави модел за изключване",
|
"add_exclusion_pattern": "Добави модел за изключване",
|
||||||
"add_import_path": "Добави път за импортиране",
|
"add_import_path": "Добави път за импортиране",
|
||||||
"add_location": "Добави местоположение",
|
"add_location": "Добавете местоположение",
|
||||||
"add_more_users": "Добави още потребители",
|
"add_more_users": "Добавете още потребители",
|
||||||
"add_partner": "Добави партньор",
|
"add_partner": "Добавете партньор",
|
||||||
"add_path": "Добави път",
|
"add_path": "Добави път",
|
||||||
"add_photos": "Добави снимки",
|
"add_photos": "Добавете снимки",
|
||||||
"add_to": "Добави към...",
|
"add_to": "Добави към...",
|
||||||
"add_to_album": "Добави към албум",
|
"add_to_album": "Добави към албум",
|
||||||
"add_to_shared_album": "Добави към споделен албум",
|
"add_to_shared_album": "Добави към споделен албум",
|
||||||
"added_to_archive": "Добавено в архива",
|
"added_to_archive": "Добавено към архива",
|
||||||
"added_to_favorites": "Добавено към любими",
|
"added_to_favorites": "Добавени към любимите ви",
|
||||||
"added_to_favorites_count": "Добавени {count, number} към любими",
|
"added_to_favorites_count": "Добавени {count, number} към любими",
|
||||||
"admin": {
|
"admin": {
|
||||||
"add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".",
|
"add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".",
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
"refreshing_all_libraries": "Опресняване на всички библиотеки",
|
"refreshing_all_libraries": "Опресняване на всички библиотеки",
|
||||||
"registration": "Администраторска регистрация",
|
"registration": "Администраторска регистрация",
|
||||||
"registration_description": "Тъй като сте първият потребител в системата, ще бъдете назначен като администратор и ще отговаряте за административните задачи, а допълнителните потребители ще бъдат създадени от вас.",
|
"registration_description": "Тъй като сте първият потребител в системата, ще бъдете назначен като администратор и ще отговаряте за административните задачи, а допълнителните потребители ще бъдат създадени от вас.",
|
||||||
"removing_offline_files": "Премахване на офлайн файлове",
|
"removing_deleted_files": "Премахване на офлайн файлове",
|
||||||
"repair_all": "Поправяне на всичко",
|
"repair_all": "Поправяне на всичко",
|
||||||
"repair_matched_items": "{count, plural, one {Съвпадащ елемент (#)} other {Съвпадащи елементи (#)}}",
|
"repair_matched_items": "{count, plural, one {Съвпадащ елемент (#)} other {Съвпадащи елементи (#)}}",
|
||||||
"repaired_items": "{count, plural, one {Поправен елемент (#)} other {Поправени елементи (#)}}",
|
"repaired_items": "{count, plural, one {Поправен елемент (#)} other {Поправени елементи (#)}}",
|
||||||
@@ -605,8 +605,8 @@
|
|||||||
"unable_to_refresh_user": "",
|
"unable_to_refresh_user": "",
|
||||||
"unable_to_remove_album_users": "",
|
"unable_to_remove_album_users": "",
|
||||||
"unable_to_remove_api_key": "",
|
"unable_to_remove_api_key": "",
|
||||||
|
"unable_to_remove_deleted_assets": "",
|
||||||
"unable_to_remove_library": "",
|
"unable_to_remove_library": "",
|
||||||
"unable_to_remove_offline_files": "",
|
|
||||||
"unable_to_remove_partner": "",
|
"unable_to_remove_partner": "",
|
||||||
"unable_to_remove_reaction": "",
|
"unable_to_remove_reaction": "",
|
||||||
"unable_to_repair_items": "",
|
"unable_to_repair_items": "",
|
||||||
@@ -895,10 +895,10 @@
|
|||||||
"refreshed": "Опреснено",
|
"refreshed": "Опреснено",
|
||||||
"refreshes_every_file": "",
|
"refreshes_every_file": "",
|
||||||
"remove": "Премахни",
|
"remove": "Премахни",
|
||||||
|
"remove_deleted_assets": "",
|
||||||
"remove_from_album": "",
|
"remove_from_album": "",
|
||||||
"remove_from_favorites": "",
|
"remove_from_favorites": "",
|
||||||
"remove_from_shared_link": "",
|
"remove_from_shared_link": "",
|
||||||
"remove_offline_files": "",
|
|
||||||
"removed_api_key": "",
|
"removed_api_key": "",
|
||||||
"rename": "Преименувай",
|
"rename": "Преименувай",
|
||||||
"repair": "Поправи",
|
"repair": "Поправи",
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
"paths_validated_successfully": "",
|
"paths_validated_successfully": "",
|
||||||
"quota_size_gib": "",
|
"quota_size_gib": "",
|
||||||
"refreshing_all_libraries": "",
|
"refreshing_all_libraries": "",
|
||||||
"removing_offline_files": "",
|
"removing_deleted_files": "",
|
||||||
"repair_all": "",
|
"repair_all": "",
|
||||||
"repair_matched_items": "",
|
"repair_matched_items": "",
|
||||||
"repaired_items": "",
|
"repaired_items": "",
|
||||||
@@ -485,8 +485,8 @@
|
|||||||
"unable_to_refresh_user": "",
|
"unable_to_refresh_user": "",
|
||||||
"unable_to_remove_album_users": "",
|
"unable_to_remove_album_users": "",
|
||||||
"unable_to_remove_api_key": "",
|
"unable_to_remove_api_key": "",
|
||||||
|
"unable_to_remove_deleted_assets": "",
|
||||||
"unable_to_remove_library": "",
|
"unable_to_remove_library": "",
|
||||||
"unable_to_remove_offline_files": "",
|
|
||||||
"unable_to_remove_partner": "",
|
"unable_to_remove_partner": "",
|
||||||
"unable_to_remove_reaction": "",
|
"unable_to_remove_reaction": "",
|
||||||
"unable_to_repair_items": "",
|
"unable_to_repair_items": "",
|
||||||
@@ -718,10 +718,10 @@
|
|||||||
"refreshed": "",
|
"refreshed": "",
|
||||||
"refreshes_every_file": "",
|
"refreshes_every_file": "",
|
||||||
"remove": "",
|
"remove": "",
|
||||||
|
"remove_deleted_assets": "",
|
||||||
"remove_from_album": "",
|
"remove_from_album": "",
|
||||||
"remove_from_favorites": "",
|
"remove_from_favorites": "",
|
||||||
"remove_from_shared_link": "",
|
"remove_from_shared_link": "",
|
||||||
"remove_offline_files": "",
|
|
||||||
"removed_api_key": "",
|
"removed_api_key": "",
|
||||||
"rename": "",
|
"rename": "",
|
||||||
"repair": "",
|
"repair": "",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"active": "Actiu",
|
"active": "Actiu",
|
||||||
"activity": "Activitat",
|
"activity": "Activitat",
|
||||||
"activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}",
|
"activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}",
|
||||||
"add": "Afig",
|
"add": "Afegir",
|
||||||
"add_a_description": "Afegiu una descripció",
|
"add_a_description": "Afegiu una descripció",
|
||||||
"add_a_location": "Afegiu una ubicació",
|
"add_a_location": "Afegiu una ubicació",
|
||||||
"add_a_name": "Afegir un nom",
|
"add_a_name": "Afegir un nom",
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"confirm_email_below": "Per a confirmar, escriviu \"{email}\" a sota",
|
"confirm_email_below": "Per a confirmar, escriviu \"{email}\" a sota",
|
||||||
"confirm_reprocess_all_faces": "Esteu segur que voleu reprocessar totes les cares? Això també esborrarà la gent que heu anomenat.",
|
"confirm_reprocess_all_faces": "Esteu segur que voleu reprocessar totes les cares? Això també esborrarà la gent que heu anomenat.",
|
||||||
"confirm_user_password_reset": "Esteu segur que voleu reinicialitzar la contrasenya de l'usuari {user}?",
|
"confirm_user_password_reset": "Esteu segur que voleu reinicialitzar la contrasenya de l'usuari {user}?",
|
||||||
|
"create_job": "Crear tasca",
|
||||||
"crontab_guru": "Crontab Guru",
|
"crontab_guru": "Crontab Guru",
|
||||||
"disable_login": "Deshabiliteu l'inici de sessió",
|
"disable_login": "Deshabiliteu l'inici de sessió",
|
||||||
"disabled": "Deshabilitat",
|
"disabled": "Deshabilitat",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"image_thumbnail_resolution": "Resolució de la miniatura",
|
"image_thumbnail_resolution": "Resolució de la miniatura",
|
||||||
"image_thumbnail_resolution_description": "S'empra per a veure grups de fotos (cronologia, vista d'àlbum, etc.). L'alta resolució pot preservar més detalls però triguen més en codificar-se, tenen fitxers més pesats i poden reduir la reactivitat de l'aplicació.",
|
"image_thumbnail_resolution_description": "S'empra per a veure grups de fotos (cronologia, vista d'àlbum, etc.). L'alta resolució pot preservar més detalls però triguen més en codificar-se, tenen fitxers més pesats i poden reduir la reactivitat de l'aplicació.",
|
||||||
"job_concurrency": "{job} concurrència",
|
"job_concurrency": "{job} concurrència",
|
||||||
|
"job_created": "Tasca creada",
|
||||||
"job_not_concurrency_safe": "Aquesta tasca no és segura per a la conconcurrència.",
|
"job_not_concurrency_safe": "Aquesta tasca no és segura per a la conconcurrència.",
|
||||||
"job_settings": "Configuració de les tasques",
|
"job_settings": "Configuració de les tasques",
|
||||||
"job_settings_description": "Gestiona la concurrència de tasques",
|
"job_settings_description": "Gestiona la concurrència de tasques",
|
||||||
@@ -198,11 +200,12 @@
|
|||||||
"password_settings": "Inici de sessió amb contrasenya",
|
"password_settings": "Inici de sessió amb contrasenya",
|
||||||
"password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya",
|
"password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya",
|
||||||
"paths_validated_successfully": "Tots els camins han estat validats amb èxit",
|
"paths_validated_successfully": "Tots els camins han estat validats amb èxit",
|
||||||
|
"person_cleanup_job": "Neteja de persona",
|
||||||
"quota_size_gib": "Tamany de la quota (GiB)",
|
"quota_size_gib": "Tamany de la quota (GiB)",
|
||||||
"refreshing_all_libraries": "Actualitzant totes les biblioteques",
|
"refreshing_all_libraries": "Actualitzant totes les biblioteques",
|
||||||
"registration": "Registre d'administrador",
|
"registration": "Registre d'administrador",
|
||||||
"registration_description": "Com que ets el primer usuari del sistema, seràs designat com a administrador i seràs responsable de les tasques administratives. També seràs l'encarregat de crear usuaris addicionals.",
|
"registration_description": "Com que ets el primer usuari del sistema, seràs designat com a administrador i seràs responsable de les tasques administratives. També seràs l'encarregat de crear usuaris addicionals.",
|
||||||
"removing_offline_files": "Eliminant fitxers fora de línia",
|
"removing_deleted_files": "Eliminant fitxers fora de línia",
|
||||||
"repair_all": "Reparar tot",
|
"repair_all": "Reparar tot",
|
||||||
"repair_matched_items": "Coincidència {count, plural, one {# element} other {# elements}}",
|
"repair_matched_items": "Coincidència {count, plural, one {# element} other {# elements}}",
|
||||||
"repaired_items": "Corregit {count, plural, one {# element} other {# elements}}",
|
"repaired_items": "Corregit {count, plural, one {# element} other {# elements}}",
|
||||||
@@ -211,6 +214,7 @@
|
|||||||
"reset_settings_to_recent_saved": "Restablir la configuració guardada més recent",
|
"reset_settings_to_recent_saved": "Restablir la configuració guardada més recent",
|
||||||
"scanning_library_for_changed_files": "Escanejant llibreria per trobar fitxers modificats",
|
"scanning_library_for_changed_files": "Escanejant llibreria per trobar fitxers modificats",
|
||||||
"scanning_library_for_new_files": "Escanejant llibreria per trobar fitxers nous",
|
"scanning_library_for_new_files": "Escanejant llibreria per trobar fitxers nous",
|
||||||
|
"search_jobs": "Tasques de cerca...",
|
||||||
"send_welcome_email": "Enviar correu electrònic de benvinguda",
|
"send_welcome_email": "Enviar correu electrònic de benvinguda",
|
||||||
"server_external_domain_settings": "Domini extern",
|
"server_external_domain_settings": "Domini extern",
|
||||||
"server_external_domain_settings_description": "Domini per enllaços públics compartits, incloent http(s)://",
|
"server_external_domain_settings_description": "Domini per enllaços públics compartits, incloent http(s)://",
|
||||||
@@ -238,6 +242,7 @@
|
|||||||
"storage_template_settings_description": "Gestiona l'estructura de les carpetes i el nom del fitxers dels elements pujats",
|
"storage_template_settings_description": "Gestiona l'estructura de les carpetes i el nom del fitxers dels elements pujats",
|
||||||
"storage_template_user_label": "<code>{label}</code> és l'etiqueta d'emmagatzematge de l'usuari",
|
"storage_template_user_label": "<code>{label}</code> és l'etiqueta d'emmagatzematge de l'usuari",
|
||||||
"system_settings": "Configuració del sistema",
|
"system_settings": "Configuració del sistema",
|
||||||
|
"tag_cleanup_job": "Neteja d'etiqueta",
|
||||||
"theme_custom_css_settings": "CSS personalitzat",
|
"theme_custom_css_settings": "CSS personalitzat",
|
||||||
"theme_custom_css_settings_description": "Els Fulls d'Estil en Cascada permeten personalitzar el disseny d'Immich.",
|
"theme_custom_css_settings_description": "Els Fulls d'Estil en Cascada permeten personalitzar el disseny d'Immich.",
|
||||||
"theme_settings": "Configuració del tema",
|
"theme_settings": "Configuració del tema",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"trash_settings_description": "Gestiona la configuració de la paperera",
|
"trash_settings_description": "Gestiona la configuració de la paperera",
|
||||||
"untracked_files": "Fitxers sense seguiment",
|
"untracked_files": "Fitxers sense seguiment",
|
||||||
"untracked_files_description": "L'aplicació no fa un seguiment d'aquests fitxers. Poden ser el resultat de moviments fallits, càrregues interrompudes o deixades enrere a causa d'un error",
|
"untracked_files_description": "L'aplicació no fa un seguiment d'aquests fitxers. Poden ser el resultat de moviments fallits, càrregues interrompudes o deixades enrere a causa d'un error",
|
||||||
|
"user_cleanup_job": "Neteja d'usuari",
|
||||||
"user_delete_delay": "El compte i els recursos de <b>{user}</b> es programaran per a la supressió permanent en {delay, plural, one {# dia} other {# dies}}.",
|
"user_delete_delay": "El compte i els recursos de <b>{user}</b> es programaran per a la supressió permanent en {delay, plural, one {# dia} other {# dies}}.",
|
||||||
"user_delete_delay_settings": "Retard de la supressió",
|
"user_delete_delay_settings": "Retard de la supressió",
|
||||||
"user_delete_delay_settings_description": "Nombre de dies després de la supressió per eliminar permanentment el compte i els elements d'un usuari. El treball de supressió d'usuaris s'executa a mitjanit per comprovar si hi ha usuaris preparats per eliminar. Els canvis en aquesta configuració s'avaluaran en la propera execució.",
|
"user_delete_delay_settings_description": "Nombre de dies després de la supressió per eliminar permanentment el compte i els elements d'un usuari. El treball de supressió d'usuaris s'executa a mitjanit per comprovar si hi ha usuaris preparats per eliminar. Els canvis en aquesta configuració s'avaluaran en la propera execució.",
|
||||||
@@ -677,8 +683,8 @@
|
|||||||
"unable_to_remove_api_key": "No es pot eliminar la clau de l'API",
|
"unable_to_remove_api_key": "No es pot eliminar la clau de l'API",
|
||||||
"unable_to_remove_assets_from_shared_link": "No es poden eliminar recursos de l'enllaç compartit",
|
"unable_to_remove_assets_from_shared_link": "No es poden eliminar recursos de l'enllaç compartit",
|
||||||
"unable_to_remove_comment": "",
|
"unable_to_remove_comment": "",
|
||||||
|
"unable_to_remove_deleted_assets": "No es poden eliminar els fitxers fora de línia",
|
||||||
"unable_to_remove_library": "No es pot eliminar la biblioteca",
|
"unable_to_remove_library": "No es pot eliminar la biblioteca",
|
||||||
"unable_to_remove_offline_files": "No es poden eliminar els fitxers fora de línia",
|
|
||||||
"unable_to_remove_partner": "No es pot eliminar company/a",
|
"unable_to_remove_partner": "No es pot eliminar company/a",
|
||||||
"unable_to_remove_reaction": "No es pot eliminar la reacció",
|
"unable_to_remove_reaction": "No es pot eliminar la reacció",
|
||||||
"unable_to_remove_user": "",
|
"unable_to_remove_user": "",
|
||||||
@@ -925,7 +931,7 @@
|
|||||||
"offline_paths_description": "Aquests resultats poden ser deguts a la supressió manual de fitxers que no formen part d'una biblioteca externa.",
|
"offline_paths_description": "Aquests resultats poden ser deguts a la supressió manual de fitxers que no formen part d'una biblioteca externa.",
|
||||||
"ok": "D'acord",
|
"ok": "D'acord",
|
||||||
"oldest_first": "El més vell primer",
|
"oldest_first": "El més vell primer",
|
||||||
"onboarding": "Onboarding",
|
"onboarding": "Incorporació",
|
||||||
"onboarding_privacy_description": "Les següents funcions (opcionals) depenen de serveis externs i poden desactivarse en qualsevol moment de dels ajustos.",
|
"onboarding_privacy_description": "Les següents funcions (opcionals) depenen de serveis externs i poden desactivarse en qualsevol moment de dels ajustos.",
|
||||||
"onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.",
|
"onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.",
|
||||||
"onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.",
|
"onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.",
|
||||||
@@ -1065,10 +1071,10 @@
|
|||||||
"remove_assets_shared_link_confirmation": "Esteu segur que voleu eliminar {count, plural, one {# recurs} other {# recursos}} d'aquest enllaç compartit?",
|
"remove_assets_shared_link_confirmation": "Esteu segur que voleu eliminar {count, plural, one {# recurs} other {# recursos}} d'aquest enllaç compartit?",
|
||||||
"remove_assets_title": "Eliminar els elements?",
|
"remove_assets_title": "Eliminar els elements?",
|
||||||
"remove_custom_date_range": "Elimina l'interval de dates personalitzat",
|
"remove_custom_date_range": "Elimina l'interval de dates personalitzat",
|
||||||
|
"remove_deleted_assets": "Suprimeix fitxers fora de línia",
|
||||||
"remove_from_album": "Treu de l'àlbum",
|
"remove_from_album": "Treu de l'àlbum",
|
||||||
"remove_from_favorites": "Eliminar dels preferits",
|
"remove_from_favorites": "Eliminar dels preferits",
|
||||||
"remove_from_shared_link": "Eliminar de l'enllaç compartit",
|
"remove_from_shared_link": "Eliminar de l'enllaç compartit",
|
||||||
"remove_offline_files": "Suprimeix fitxers fora de línia",
|
|
||||||
"remove_user": "Eliminar l'usuari",
|
"remove_user": "Eliminar l'usuari",
|
||||||
"removed_api_key": "Eliminada la clau d'API: {name}",
|
"removed_api_key": "Eliminada la clau d'API: {name}",
|
||||||
"removed_from_archive": "Eliminat de l'arxiu",
|
"removed_from_archive": "Eliminat de l'arxiu",
|
||||||
@@ -1113,7 +1119,7 @@
|
|||||||
"search_albums": "Buscar àlbums",
|
"search_albums": "Buscar àlbums",
|
||||||
"search_by_context": "Buscar per context",
|
"search_by_context": "Buscar per context",
|
||||||
"search_by_filename": "Cerca per nom de fitxer o extensió",
|
"search_by_filename": "Cerca per nom de fitxer o extensió",
|
||||||
"search_by_filename_example": "i.e. IMG_1234.JPG or PNG",
|
"search_by_filename_example": "per exemple IMG_1234.JPG o PNG",
|
||||||
"search_camera_make": "Buscar per fabricant de càmara...",
|
"search_camera_make": "Buscar per fabricant de càmara...",
|
||||||
"search_camera_model": "Buscar per model de càmera...",
|
"search_camera_model": "Buscar per model de càmera...",
|
||||||
"search_city": "Buscar per ciutat...",
|
"search_city": "Buscar per ciutat...",
|
||||||
@@ -1124,6 +1130,7 @@
|
|||||||
"search_options": "Opcions de cerca",
|
"search_options": "Opcions de cerca",
|
||||||
"search_people": "Buscar persones",
|
"search_people": "Buscar persones",
|
||||||
"search_places": "Buscar llocs",
|
"search_places": "Buscar llocs",
|
||||||
|
"search_settings": "Configuració de cerca",
|
||||||
"search_state": "Buscar per regió...",
|
"search_state": "Buscar per regió...",
|
||||||
"search_tags": "Cercant etiquetes...",
|
"search_tags": "Cercant etiquetes...",
|
||||||
"search_timezone": "Buscar per fus horari...",
|
"search_timezone": "Buscar per fus horari...",
|
||||||
@@ -1240,7 +1247,7 @@
|
|||||||
"tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques",
|
"tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques",
|
||||||
"tag_not_found_question": "No trobeu una etiqueta? Creeu-ne una <link>aquí</link>",
|
"tag_not_found_question": "No trobeu una etiqueta? Creeu-ne una <link>aquí</link>",
|
||||||
"tag_updated": "Etiqueta actualizada: {tag}",
|
"tag_updated": "Etiqueta actualizada: {tag}",
|
||||||
"tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}",
|
"tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}",
|
||||||
"tags": "Etiquetes",
|
"tags": "Etiquetes",
|
||||||
"template": "Plantilla",
|
"template": "Plantilla",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"confirm_email_below": "Pro potvrzení zadejte níže \"{email}\"",
|
"confirm_email_below": "Pro potvrzení zadejte níže \"{email}\"",
|
||||||
"confirm_reprocess_all_faces": "Opravdu chcete znovu zpracovat všechny obličeje? Tím se vymažou i pojmenované osoby.",
|
"confirm_reprocess_all_faces": "Opravdu chcete znovu zpracovat všechny obličeje? Tím se vymažou i pojmenované osoby.",
|
||||||
"confirm_user_password_reset": "Opravdu chcete obnovit heslo uživatele {user}?",
|
"confirm_user_password_reset": "Opravdu chcete obnovit heslo uživatele {user}?",
|
||||||
|
"create_job": "Vytvořit úlohu",
|
||||||
"crontab_guru": "Crontab Guru",
|
"crontab_guru": "Crontab Guru",
|
||||||
"disable_login": "Zakázat přihlášení",
|
"disable_login": "Zakázat přihlášení",
|
||||||
"disabled": "Zakázáno",
|
"disabled": "Zakázáno",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"image_thumbnail_resolution": "Rozlišení miniatur",
|
"image_thumbnail_resolution": "Rozlišení miniatur",
|
||||||
"image_thumbnail_resolution_description": "Používá se při prohlížení skupin fotografií (hlavní časová osa, zobrazení alba atd.). Vyšší rozlišení může zachovat více detailů, ale trvá déle, než se zakóduje, má větší velikost souboru a může snížit odezvu aplikace.",
|
"image_thumbnail_resolution_description": "Používá se při prohlížení skupin fotografií (hlavní časová osa, zobrazení alba atd.). Vyšší rozlišení může zachovat více detailů, ale trvá déle, než se zakóduje, má větší velikost souboru a může snížit odezvu aplikace.",
|
||||||
"job_concurrency": "Souběžnost {job}",
|
"job_concurrency": "Souběžnost {job}",
|
||||||
|
"job_created": "Úloha vytvořena",
|
||||||
"job_not_concurrency_safe": "Tato úloha není bezpečená pro souběh.",
|
"job_not_concurrency_safe": "Tato úloha není bezpečená pro souběh.",
|
||||||
"job_settings": "Úlohy",
|
"job_settings": "Úlohy",
|
||||||
"job_settings_description": "Správa souběžnosti úloh",
|
"job_settings_description": "Správa souběžnosti úloh",
|
||||||
@@ -198,11 +200,12 @@
|
|||||||
"password_settings": "Přihlášení heslem",
|
"password_settings": "Přihlášení heslem",
|
||||||
"password_settings_description": "Správa nastavení přihlašování pomocí hesla",
|
"password_settings_description": "Správa nastavení přihlašování pomocí hesla",
|
||||||
"paths_validated_successfully": "Všechny cesty byly úspěšně ověřeny",
|
"paths_validated_successfully": "Všechny cesty byly úspěšně ověřeny",
|
||||||
|
"person_cleanup_job": "Promazání osob",
|
||||||
"quota_size_gib": "Velikost kvóty (GiB)",
|
"quota_size_gib": "Velikost kvóty (GiB)",
|
||||||
"refreshing_all_libraries": "Obnovení všech knihoven",
|
"refreshing_all_libraries": "Obnovení všech knihoven",
|
||||||
"registration": "Registrace správce",
|
"registration": "Registrace správce",
|
||||||
"registration_description": "Vzhledem k tomu, že jste prvním uživatelem v systému, budete přiřazen jako správce a budete zodpovědný za úkoly správy a další uživatelé budou vytvořeni vámi.",
|
"registration_description": "Vzhledem k tomu, že jste prvním uživatelem v systému, budete přiřazen jako správce a budete zodpovědný za úkoly správy a další uživatelé budou vytvořeni vámi.",
|
||||||
"removing_offline_files": "Odstranění offline souborů",
|
"removing_deleted_files": "Odstranění offline souborů",
|
||||||
"repair_all": "Opravit vše",
|
"repair_all": "Opravit vše",
|
||||||
"repair_matched_items": "Shoda {count, plural, one {# položky} other {# položek}}",
|
"repair_matched_items": "Shoda {count, plural, one {# položky} other {# položek}}",
|
||||||
"repaired_items": "{count, plural, one {Opravena # položka} few {Opraveny # položky} other {Opraveno # položek}}",
|
"repaired_items": "{count, plural, one {Opravena # položka} few {Opraveny # položky} other {Opraveno # položek}}",
|
||||||
@@ -211,6 +214,7 @@
|
|||||||
"reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení",
|
"reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení",
|
||||||
"scanning_library_for_changed_files": "Hledání změněných souborů v knihovně",
|
"scanning_library_for_changed_files": "Hledání změněných souborů v knihovně",
|
||||||
"scanning_library_for_new_files": "Hledání nových souborů v knihovně",
|
"scanning_library_for_new_files": "Hledání nových souborů v knihovně",
|
||||||
|
"search_jobs": "Hledat úlohy...",
|
||||||
"send_welcome_email": "Odeslat uvítací e-mail",
|
"send_welcome_email": "Odeslat uvítací e-mail",
|
||||||
"server_external_domain_settings": "Externí doména",
|
"server_external_domain_settings": "Externí doména",
|
||||||
"server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://",
|
"server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://",
|
||||||
@@ -238,6 +242,7 @@
|
|||||||
"storage_template_settings_description": "Správa struktury složek a názvů nahraných souborů",
|
"storage_template_settings_description": "Správa struktury složek a názvů nahraných souborů",
|
||||||
"storage_template_user_label": "<code>{label}</code> je štítek úložiště uživatele",
|
"storage_template_user_label": "<code>{label}</code> je štítek úložiště uživatele",
|
||||||
"system_settings": "Systémová nastavení",
|
"system_settings": "Systémová nastavení",
|
||||||
|
"tag_cleanup_job": "Promazání značek",
|
||||||
"theme_custom_css_settings": "Vlastní CSS",
|
"theme_custom_css_settings": "Vlastní CSS",
|
||||||
"theme_custom_css_settings_description": "Kaskádové styly umožňují přizpůsobit design aplikace Immich.",
|
"theme_custom_css_settings_description": "Kaskádové styly umožňují přizpůsobit design aplikace Immich.",
|
||||||
"theme_settings": "Motivy",
|
"theme_settings": "Motivy",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"trash_settings_description": "Správa nastavení koše",
|
"trash_settings_description": "Správa nastavení koše",
|
||||||
"untracked_files": "Neznámé soubory",
|
"untracked_files": "Neznámé soubory",
|
||||||
"untracked_files_description": "Tyto soubory nejsou aplikaci známy. Mohou být výsledkem neúspěšných přesunů, přerušeného nahrávání nebo mohou zůstat pozadu kvůli chybě",
|
"untracked_files_description": "Tyto soubory nejsou aplikaci známy. Mohou být výsledkem neúspěšných přesunů, přerušeného nahrávání nebo mohou zůstat pozadu kvůli chybě",
|
||||||
|
"user_cleanup_job": "Promazání uživatelů",
|
||||||
"user_delete_delay": "Účet a položky uživatele <b>{user}</b> budou trvale smazány za {delay, plural, one {# den} few {# dny} other {# dní}}.",
|
"user_delete_delay": "Účet a položky uživatele <b>{user}</b> budou trvale smazány za {delay, plural, one {# den} few {# dny} other {# dní}}.",
|
||||||
"user_delete_delay_settings": "Odložení odstranění",
|
"user_delete_delay_settings": "Odložení odstranění",
|
||||||
"user_delete_delay_settings_description": "Počet dní po odstranění, po kterých bude odstraněn účet a položky uživatele. Úloha odstraňování uživatelů se spouští o půlnoci a kontroluje uživatele, kteří jsou připraveni k odstranění. Změny tohoto nastavení se vyhodnotí při dalším spuštění.",
|
"user_delete_delay_settings_description": "Počet dní po odstranění, po kterých bude odstraněn účet a položky uživatele. Úloha odstraňování uživatelů se spouští o půlnoci a kontroluje uživatele, kteří jsou připraveni k odstranění. Změny tohoto nastavení se vyhodnotí při dalším spuštění.",
|
||||||
@@ -678,8 +684,8 @@
|
|||||||
"unable_to_remove_api_key": "Nelze odstranit API klíč",
|
"unable_to_remove_api_key": "Nelze odstranit API klíč",
|
||||||
"unable_to_remove_assets_from_shared_link": "Nelze odstranit položky ze sdíleného odkazu",
|
"unable_to_remove_assets_from_shared_link": "Nelze odstranit položky ze sdíleného odkazu",
|
||||||
"unable_to_remove_comment": "Nelze odstranit komentář",
|
"unable_to_remove_comment": "Nelze odstranit komentář",
|
||||||
|
"unable_to_remove_deleted_assets": "Nelze odstranit offline soubory",
|
||||||
"unable_to_remove_library": "Nelze odstranit knihovnu",
|
"unable_to_remove_library": "Nelze odstranit knihovnu",
|
||||||
"unable_to_remove_offline_files": "Nelze odstranit offline soubory",
|
|
||||||
"unable_to_remove_partner": "Nelze odebrat partnera",
|
"unable_to_remove_partner": "Nelze odebrat partnera",
|
||||||
"unable_to_remove_reaction": "Nelze odstranit reakci",
|
"unable_to_remove_reaction": "Nelze odstranit reakci",
|
||||||
"unable_to_remove_user": "Nelze odebrat uživatele",
|
"unable_to_remove_user": "Nelze odebrat uživatele",
|
||||||
@@ -1083,10 +1089,10 @@
|
|||||||
"remove_assets_shared_link_confirmation": "Opravdu chcete ze sdíleného odkazu odstranit {count, plural, one {# položku} few {# položky} other {# položek}}?",
|
"remove_assets_shared_link_confirmation": "Opravdu chcete ze sdíleného odkazu odstranit {count, plural, one {# položku} few {# položky} other {# položek}}?",
|
||||||
"remove_assets_title": "Odstranit položky?",
|
"remove_assets_title": "Odstranit položky?",
|
||||||
"remove_custom_date_range": "Odstranit vlastní rozsah datumů",
|
"remove_custom_date_range": "Odstranit vlastní rozsah datumů",
|
||||||
|
"remove_deleted_assets": "Odstranit offline soubory",
|
||||||
"remove_from_album": "Odstranit z alba",
|
"remove_from_album": "Odstranit z alba",
|
||||||
"remove_from_favorites": "Odstranit z oblíbených",
|
"remove_from_favorites": "Odstranit z oblíbených",
|
||||||
"remove_from_shared_link": "Odstranit ze sdíleného odkazu",
|
"remove_from_shared_link": "Odstranit ze sdíleného odkazu",
|
||||||
"remove_offline_files": "Odstranit offline soubory",
|
|
||||||
"remove_user": "Odebrat uživatele",
|
"remove_user": "Odebrat uživatele",
|
||||||
"removed_api_key": "Odstraněn API klíč: {name}",
|
"removed_api_key": "Odstraněn API klíč: {name}",
|
||||||
"removed_from_archive": "Odstraněno z archivu",
|
"removed_from_archive": "Odstraněno z archivu",
|
||||||
@@ -1142,6 +1148,7 @@
|
|||||||
"search_options": "Možnosti vyhledávání",
|
"search_options": "Možnosti vyhledávání",
|
||||||
"search_people": "Vyhledat lidi",
|
"search_people": "Vyhledat lidi",
|
||||||
"search_places": "Vyhledat místa",
|
"search_places": "Vyhledat místa",
|
||||||
|
"search_settings": "Hledat nastavení",
|
||||||
"search_state": "Vyhledat stát...",
|
"search_state": "Vyhledat stát...",
|
||||||
"search_tags": "Vyhledávat značky...",
|
"search_tags": "Vyhledávat značky...",
|
||||||
"search_timezone": "Vyhledat časové pásmo...",
|
"search_timezone": "Vyhledat časové pásmo...",
|
||||||
|
|||||||
@@ -202,7 +202,7 @@
|
|||||||
"refreshing_all_libraries": "Opdaterer alle biblioteker",
|
"refreshing_all_libraries": "Opdaterer alle biblioteker",
|
||||||
"registration": "Administratorregistrering",
|
"registration": "Administratorregistrering",
|
||||||
"registration_description": "Da du er den første bruger i systemet, får du tildelt rollen som administrator og ansvar for administration og oprettelsen af nye brugere.",
|
"registration_description": "Da du er den første bruger i systemet, får du tildelt rollen som administrator og ansvar for administration og oprettelsen af nye brugere.",
|
||||||
"removing_offline_files": "Fjerner offline-filer",
|
"removing_deleted_files": "Fjerner offline-filer",
|
||||||
"repair_all": "Reparér alle",
|
"repair_all": "Reparér alle",
|
||||||
"repair_matched_items": "Har parret {count, plural, one {# element} other {# elementer}}",
|
"repair_matched_items": "Har parret {count, plural, one {# element} other {# elementer}}",
|
||||||
"repaired_items": "Reparerede {count, plural, one {# element} other {# elementer}}",
|
"repaired_items": "Reparerede {count, plural, one {# element} other {# elementer}}",
|
||||||
@@ -563,8 +563,8 @@
|
|||||||
"unable_to_remove_album_users": "Ikke i stand til at fjerne brugere fra album",
|
"unable_to_remove_album_users": "Ikke i stand til at fjerne brugere fra album",
|
||||||
"unable_to_remove_api_key": "Kunne ikke fjerne API-nøgle",
|
"unable_to_remove_api_key": "Kunne ikke fjerne API-nøgle",
|
||||||
"unable_to_remove_comment": "",
|
"unable_to_remove_comment": "",
|
||||||
|
"unable_to_remove_deleted_assets": "Kunne ikke fjerne offlinefiler",
|
||||||
"unable_to_remove_library": "Ikke i stand til at fjerne bibliotek",
|
"unable_to_remove_library": "Ikke i stand til at fjerne bibliotek",
|
||||||
"unable_to_remove_offline_files": "Kunne ikke fjerne offlinefiler",
|
|
||||||
"unable_to_remove_partner": "Ikke i stand til at fjerne partner",
|
"unable_to_remove_partner": "Ikke i stand til at fjerne partner",
|
||||||
"unable_to_remove_reaction": "Ikke i stand til at reaktion",
|
"unable_to_remove_reaction": "Ikke i stand til at reaktion",
|
||||||
"unable_to_remove_user": "",
|
"unable_to_remove_user": "",
|
||||||
@@ -811,10 +811,10 @@
|
|||||||
"refreshed": "Opdateret",
|
"refreshed": "Opdateret",
|
||||||
"refreshes_every_file": "Opdaterer alle filer",
|
"refreshes_every_file": "Opdaterer alle filer",
|
||||||
"remove": "Fjern",
|
"remove": "Fjern",
|
||||||
|
"remove_deleted_assets": "Fjern fra offlinefiler",
|
||||||
"remove_from_album": "Fjern fra album",
|
"remove_from_album": "Fjern fra album",
|
||||||
"remove_from_favorites": "Fjern fra favoritter",
|
"remove_from_favorites": "Fjern fra favoritter",
|
||||||
"remove_from_shared_link": "Fjern fra delt link",
|
"remove_from_shared_link": "Fjern fra delt link",
|
||||||
"remove_offline_files": "Fjern fra offlinefiler",
|
|
||||||
"removed_api_key": "Fjernede API-nøgle: {name}",
|
"removed_api_key": "Fjernede API-nøgle: {name}",
|
||||||
"rename": "Omdøb",
|
"rename": "Omdøb",
|
||||||
"repair": "Reparér",
|
"repair": "Reparér",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"confirm_email_below": "Bestätige, indem du \"{email}\" unten eingibst",
|
"confirm_email_below": "Bestätige, indem du \"{email}\" unten eingibst",
|
||||||
"confirm_reprocess_all_faces": "Bist du sicher, dass du alle Gesichter erneut verarbeiten möchtest? Dies löscht auch alle bereits benannten Personen.",
|
"confirm_reprocess_all_faces": "Bist du sicher, dass du alle Gesichter erneut verarbeiten möchtest? Dies löscht auch alle bereits benannten Personen.",
|
||||||
"confirm_user_password_reset": "Bist du sicher, dass du das Passwort für {user} zurücksetzen möchtest?",
|
"confirm_user_password_reset": "Bist du sicher, dass du das Passwort für {user} zurücksetzen möchtest?",
|
||||||
|
"create_job": "Job erstellen",
|
||||||
"crontab_guru": "Crontab Guru",
|
"crontab_guru": "Crontab Guru",
|
||||||
"disable_login": "Login deaktvieren",
|
"disable_login": "Login deaktvieren",
|
||||||
"disabled": "Deaktiviert",
|
"disabled": "Deaktiviert",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"image_thumbnail_resolution": "Miniaturansichts-Auflösung",
|
"image_thumbnail_resolution": "Miniaturansichts-Auflösung",
|
||||||
"image_thumbnail_resolution_description": "Dies wird bei der Anzeige von Bildergruppen („Zeitleiste“, „Albumansicht“ usw.) verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.",
|
"image_thumbnail_resolution_description": "Dies wird bei der Anzeige von Bildergruppen („Zeitleiste“, „Albumansicht“ usw.) verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.",
|
||||||
"job_concurrency": "{job} - (Anzahl gleichzeitiger Prozesse)",
|
"job_concurrency": "{job} - (Anzahl gleichzeitiger Prozesse)",
|
||||||
|
"job_created": "Job erstellt",
|
||||||
"job_not_concurrency_safe": "Dieser Job ist nicht parallelisierungssicher.",
|
"job_not_concurrency_safe": "Dieser Job ist nicht parallelisierungssicher.",
|
||||||
"job_settings": "Job-Einstellungen",
|
"job_settings": "Job-Einstellungen",
|
||||||
"job_settings_description": "Gleichzeitige Job-Prozessen verwalten",
|
"job_settings_description": "Gleichzeitige Job-Prozessen verwalten",
|
||||||
@@ -198,11 +200,12 @@
|
|||||||
"password_settings": "Passwort Login",
|
"password_settings": "Passwort Login",
|
||||||
"password_settings_description": "Passwort-Anmeldeeinstellungen verwalten",
|
"password_settings_description": "Passwort-Anmeldeeinstellungen verwalten",
|
||||||
"paths_validated_successfully": "Alle Pfade wurden erfolgreich validiert",
|
"paths_validated_successfully": "Alle Pfade wurden erfolgreich validiert",
|
||||||
|
"person_cleanup_job": "Personen aufräumen",
|
||||||
"quota_size_gib": "Kontingent (GiB)",
|
"quota_size_gib": "Kontingent (GiB)",
|
||||||
"refreshing_all_libraries": "Alle Bibliotheken aktualisieren",
|
"refreshing_all_libraries": "Alle Bibliotheken aktualisieren",
|
||||||
"registration": "Admin-Registrierung",
|
"registration": "Admin-Registrierung",
|
||||||
"registration_description": "Da du der erste Benutzer im System bist, wirst du als Admin zugewiesen und bist für administrative Aufgaben zuständig. Weitere Benutzer werden von dir erstellt.",
|
"registration_description": "Da du der erste Benutzer im System bist, wirst du als Admin zugewiesen und bist für administrative Aufgaben zuständig. Weitere Benutzer werden von dir erstellt.",
|
||||||
"removing_offline_files": "Offline-Dateien entfernen",
|
"removing_deleted_files": "Offline-Dateien entfernen",
|
||||||
"repair_all": "Alle reparieren",
|
"repair_all": "Alle reparieren",
|
||||||
"repair_matched_items": "{count, plural, one {# Eintrag} other {# Einträge}} gefunden",
|
"repair_matched_items": "{count, plural, one {# Eintrag} other {# Einträge}} gefunden",
|
||||||
"repaired_items": "{count, plural, one {# Eintrag} other {# Einträge}} repariert",
|
"repaired_items": "{count, plural, one {# Eintrag} other {# Einträge}} repariert",
|
||||||
@@ -211,6 +214,7 @@
|
|||||||
"reset_settings_to_recent_saved": "Einstellungen auf die zuletzt gespeicherten Einstellungen zurücksetzen",
|
"reset_settings_to_recent_saved": "Einstellungen auf die zuletzt gespeicherten Einstellungen zurücksetzen",
|
||||||
"scanning_library_for_changed_files": "Untersuche Bibliothek auf geänderte Dateien",
|
"scanning_library_for_changed_files": "Untersuche Bibliothek auf geänderte Dateien",
|
||||||
"scanning_library_for_new_files": "Untersuche Bibliothek auf neue Dateien",
|
"scanning_library_for_new_files": "Untersuche Bibliothek auf neue Dateien",
|
||||||
|
"search_jobs": "Jobs suchen...",
|
||||||
"send_welcome_email": "Begrüssungsmail senden",
|
"send_welcome_email": "Begrüssungsmail senden",
|
||||||
"server_external_domain_settings": "Externe Domain",
|
"server_external_domain_settings": "Externe Domain",
|
||||||
"server_external_domain_settings_description": "Domäne für öffentlich freigegebene Links, einschließlich http(s)://",
|
"server_external_domain_settings_description": "Domäne für öffentlich freigegebene Links, einschließlich http(s)://",
|
||||||
@@ -238,6 +242,7 @@
|
|||||||
"storage_template_settings_description": "Die Ordnerstruktur und den Dateinamen der hochgeladenen Datei verwalten",
|
"storage_template_settings_description": "Die Ordnerstruktur und den Dateinamen der hochgeladenen Datei verwalten",
|
||||||
"storage_template_user_label": "<code>{label}</code> is das Speicher-Label des Benutzers",
|
"storage_template_user_label": "<code>{label}</code> is das Speicher-Label des Benutzers",
|
||||||
"system_settings": "Systemeinstellungen",
|
"system_settings": "Systemeinstellungen",
|
||||||
|
"tag_cleanup_job": "Tags aufräumen",
|
||||||
"theme_custom_css_settings": "Benutzerdefiniertes CSS",
|
"theme_custom_css_settings": "Benutzerdefiniertes CSS",
|
||||||
"theme_custom_css_settings_description": "Mit Cascading Style Sheets (CSS) kann das Design von Immich angepasst werden.",
|
"theme_custom_css_settings_description": "Mit Cascading Style Sheets (CSS) kann das Design von Immich angepasst werden.",
|
||||||
"theme_settings": "Theme-Einstellungen",
|
"theme_settings": "Theme-Einstellungen",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"trash_settings_description": "Papierkorb-Einstellungen verwalten",
|
"trash_settings_description": "Papierkorb-Einstellungen verwalten",
|
||||||
"untracked_files": "Unverfolgte Dateien",
|
"untracked_files": "Unverfolgte Dateien",
|
||||||
"untracked_files_description": "Diese Dateien werden nicht von der Application getrackt. Sie können das Ergebnis fehlgeschlagener Verschiebungen, unterbrochener Uploads oder aufgrund eines Fehlers sein",
|
"untracked_files_description": "Diese Dateien werden nicht von der Application getrackt. Sie können das Ergebnis fehlgeschlagener Verschiebungen, unterbrochener Uploads oder aufgrund eines Fehlers sein",
|
||||||
|
"user_cleanup_job": "Benutzer aufräumen",
|
||||||
"user_delete_delay": "Das Konto und die Dateien von <b>{user}</b> werden in {delay, plural, one {einem Tag} other {# Tagen}} für eine permanente Löschung geplant.",
|
"user_delete_delay": "Das Konto und die Dateien von <b>{user}</b> werden in {delay, plural, one {einem Tag} other {# Tagen}} für eine permanente Löschung geplant.",
|
||||||
"user_delete_delay_settings": "Verzögerung für das Löschen von Benutzern",
|
"user_delete_delay_settings": "Verzögerung für das Löschen von Benutzern",
|
||||||
"user_delete_delay_settings_description": "Gibt die Anzahl der Tage bis zur endgültigen Löschung eines Kontos und seiner Dateien an. Der Benutzerlöschauftrag wird täglich um Mitternacht ausgeführt, um zu überprüfen, ob Nutzer zur Löschung bereit sind. Änderungen an dieser Einstellung werden erst bei der nächsten Ausführung berücksichtigt.",
|
"user_delete_delay_settings_description": "Gibt die Anzahl der Tage bis zur endgültigen Löschung eines Kontos und seiner Dateien an. Der Benutzerlöschauftrag wird täglich um Mitternacht ausgeführt, um zu überprüfen, ob Nutzer zur Löschung bereit sind. Änderungen an dieser Einstellung werden erst bei der nächsten Ausführung berücksichtigt.",
|
||||||
@@ -678,8 +684,8 @@
|
|||||||
"unable_to_remove_api_key": "API-Schlüssel konnte nicht entfernt werden",
|
"unable_to_remove_api_key": "API-Schlüssel konnte nicht entfernt werden",
|
||||||
"unable_to_remove_assets_from_shared_link": "Dateien konnten nicht von geteiltem Link entfernt werden",
|
"unable_to_remove_assets_from_shared_link": "Dateien konnten nicht von geteiltem Link entfernt werden",
|
||||||
"unable_to_remove_comment": "Kommentar kann nicht entfernt werden",
|
"unable_to_remove_comment": "Kommentar kann nicht entfernt werden",
|
||||||
|
"unable_to_remove_deleted_assets": "Offline-Dateien konnten nicht entfernt werden",
|
||||||
"unable_to_remove_library": "Bibliothek kann nicht entfernt werden",
|
"unable_to_remove_library": "Bibliothek kann nicht entfernt werden",
|
||||||
"unable_to_remove_offline_files": "Offline-Dateien konnten nicht entfernt werden",
|
|
||||||
"unable_to_remove_partner": "Partner kann nicht entfernt werden",
|
"unable_to_remove_partner": "Partner kann nicht entfernt werden",
|
||||||
"unable_to_remove_reaction": "Reaktion kann nicht entfernt werden",
|
"unable_to_remove_reaction": "Reaktion kann nicht entfernt werden",
|
||||||
"unable_to_remove_user": "Benutzer kann nicht entfernt werden",
|
"unable_to_remove_user": "Benutzer kann nicht entfernt werden",
|
||||||
@@ -1082,10 +1088,10 @@
|
|||||||
"remove_assets_shared_link_confirmation": "Bist du sicher, dass du {count, plural, one {# Datei} other {# Dateien}} von diesem geteilten Link entfernen willst?",
|
"remove_assets_shared_link_confirmation": "Bist du sicher, dass du {count, plural, one {# Datei} other {# Dateien}} von diesem geteilten Link entfernen willst?",
|
||||||
"remove_assets_title": "Dateien entfernen?",
|
"remove_assets_title": "Dateien entfernen?",
|
||||||
"remove_custom_date_range": "Benutzerdefinierten Datumsbereich entfernen",
|
"remove_custom_date_range": "Benutzerdefinierten Datumsbereich entfernen",
|
||||||
|
"remove_deleted_assets": "Offline-Dateien entfernen",
|
||||||
"remove_from_album": "Aus Album entfernen",
|
"remove_from_album": "Aus Album entfernen",
|
||||||
"remove_from_favorites": "Aus Favoriten entfernen",
|
"remove_from_favorites": "Aus Favoriten entfernen",
|
||||||
"remove_from_shared_link": "Aus geteilten Link entfernen",
|
"remove_from_shared_link": "Aus geteilten Link entfernen",
|
||||||
"remove_offline_files": "Offline-Dateien entfernen",
|
|
||||||
"remove_user": "Nutzer entfernen",
|
"remove_user": "Nutzer entfernen",
|
||||||
"removed_api_key": "API-Schlüssel {name} wurde entfernt",
|
"removed_api_key": "API-Schlüssel {name} wurde entfernt",
|
||||||
"removed_from_archive": "Aus dem Archiv entfernt",
|
"removed_from_archive": "Aus dem Archiv entfernt",
|
||||||
@@ -1141,6 +1147,7 @@
|
|||||||
"search_options": "Suchoptionen",
|
"search_options": "Suchoptionen",
|
||||||
"search_people": "Suche nach Personen",
|
"search_people": "Suche nach Personen",
|
||||||
"search_places": "Suche nach Orten",
|
"search_places": "Suche nach Orten",
|
||||||
|
"search_settings": "Suche nach Einstellungen",
|
||||||
"search_state": "Suche nach Bundesland / Provinz...",
|
"search_state": "Suche nach Bundesland / Provinz...",
|
||||||
"search_tags": "Sache nach Tags...",
|
"search_tags": "Sache nach Tags...",
|
||||||
"search_timezone": "Suche nach Zeitzone...",
|
"search_timezone": "Suche nach Zeitzone...",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user