1
0
mirror of https://github.com/immich-app/immich.git synced 2025-04-08 16:54:19 +02:00

refactor(server): library syncing (#12220)

* refactor: library scanning

fix tests

remove offline files step

cleanup library service

improve tests

cleanup tests

add db migration

fix e2e

cleanup openapi

fix tests

fix tests

update docs

update docs

update mobile code

fix formatting

don't remove assets from library with invalid import path

use trash for offline files

add migration

simplify scan endpoint

cleanup library panel

fix library tests

e2e lint

fix e2e

trash e2e

fix lint

add asset trash tests

add more tests

ensure thumbs are generated

cleanup svelte

cleanup queue names

fix tests

fix lint

add warning due to trash

fix trash tests

fix lint

fix tests

Admin message for offline asset

fix comments

Update web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

add permission to library scan endpoint

revert asset interface sort

add trash reason to shared link stub

improve path view in offline

update docs

improve trash performance

fix comments

remove stray comment

* refactor: add back isOffline and remove trashReason from asset, change sync job flow

* chore(server): drop coverage to 80% for functions

* chore: rebase and generated files

---------

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
Jonathan Jogenfors 2024-09-25 19:26:19 +02:00 committed by GitHub
parent 1ef2834603
commit b2f2be3485
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 941 additions and 1926 deletions

View File

@ -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
@ -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
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`
- `**/*.{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)
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.
@ -84,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up.
### 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
@ -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._
:::
### Create External Libraries
### Create A New Library
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.
- Click save
- 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.
@ -161,7 +145,7 @@ If you get an error here, please rename the other external library to something
- Click on Add Path
- Enter `/mnt/media/videos` then click Add
- 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.

View File

@ -1,11 +1,4 @@
import {
LibraryResponseDto,
LoginResponseDto,
ScanLibraryDto,
getAllLibraries,
removeOfflineFiles,
scanLibrary,
} from '@immich/sdk';
import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk';
import { cpSync, existsSync } from 'node:fs';
import { Socket } from 'socket.io-client';
import { userDto, uuidDto } from 'src/fixtures';
@ -15,8 +8,7 @@ import request from 'supertest';
import { utimes } from 'utimes';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) =>
scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) });
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
describe('/libraries', () => {
let admin: LoginResponseDto;
@ -293,14 +285,19 @@ describe('/libraries', () => {
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, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/directoryA`],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 });
const { status } = await request(app)
.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, {
originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,
@ -315,8 +312,13 @@ describe('/libraries', () => {
exclusionPatterns: ['**/directoryA'],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 });
const { status } = await request(app)
.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 });
@ -330,8 +332,13 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
const { status } = await request(app)
.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 });
@ -340,95 +347,144 @@ describe('/libraries', () => {
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, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(2);
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
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.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);
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
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(1);
});
it('should offline a file missing from disk', async () => {
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
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);
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 utils.waitForQueueFinish(admin.accessToken, 'library');
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');
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 });
expect(newAssets.count).toBe(3);
expect(newAssets.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: true,
originalFileName: 'assetC.png',
}),
]),
);
expect(newAssets.items).toEqual([]);
});
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, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`],
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);
utils.createDirectory(`${testAssetDir}/temp/another-path/`);
await request(app)
.put(`/libraries/${library.id}`)
.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');
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(
expect.arrayContaining([
expect.objectContaining({
isOffline: false,
originalFileName: 'assetA.png',
}),
expect.objectContaining({
isOffline: true,
originalFileName: 'assetB.png',
}),
]),
);
const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(newAssets.items).toEqual([]);
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
utils.removeDirectory(`${testAssetDir}/temp/another-path/`);
});
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, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`],
@ -437,6 +493,12 @@ describe('/libraries', () => {
await scan(admin.accessToken, library.id);
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)
.put(`/libraries/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
@ -445,282 +507,21 @@ describe('/libraries', () => {
await scan(admin.accessToken, library.id);
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.arrayContaining([
expect.objectContaining({
isOffline: false,
originalFileName: 'assetA.png',
}),
expect.objectContaining({
isOffline: true,
originalFileName: 'assetB.png',
}),
]),
);
expect(newAssets.items).toEqual([
expect.objectContaining({
originalFileName: 'assetA.png',
}),
]);
});
it('should not try to delete offline files', 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 () => {
it('should not trash an online asset', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`],
@ -733,10 +534,11 @@ describe('/libraries', () => {
expect(assetsBefore.count).toBeGreaterThan(1);
const { status } = await request(app)
.post(`/libraries/${library.id}/removeOffline`)
.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 });
@ -828,7 +630,7 @@ describe('/libraries', () => {
});
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)
.delete(`/libraries/${library.id}`)

View File

@ -181,7 +181,7 @@ describe('/search', () => {
dto: { size: -1.5 },
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`,
dto: { [value]: 'immich' },
expected: [`${value} must be a boolean value`],

View File

@ -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 { 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
describe('/trash', () => {
let admin: LoginResponseDto;
let ws: Socket;
@ -44,6 +47,8 @@ describe('/trash', () => {
const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
expect(after.total).toBe(0);
expect(existsSync(before.originalPath)).toBe(false);
});
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) });
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) });
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', () => {
@ -118,5 +194,38 @@ describe('/trash', () => {
const after = await utils.getAssetInfo(admin.accessToken, assetId);
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);
});
});
});

View File

@ -372,6 +372,12 @@ export const utils = {
writeFileSync(path, makeRandomImage());
},
createDirectory: (path: string) => {
if (!existsSync(dirname(path))) {
mkdirSync(dirname(path), { recursive: true });
}
},
removeImageFile: (path: string) => {
if (!existsSync(path)) {
return;
@ -380,6 +386,14 @@ export const utils = {
rmSync(path);
},
removeDirectory: (path: string) => {
if (!existsSync(path)) {
return;
}
rmSync(path);
},
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>

View File

@ -57,69 +57,64 @@ const AssetSchema = CollectionSchema(
name: r'isFavorite',
type: IsarType.bool,
),
r'isOffline': PropertySchema(
id: 8,
name: r'isOffline',
type: IsarType.bool,
),
r'isTrashed': PropertySchema(
id: 9,
id: 8,
name: r'isTrashed',
type: IsarType.bool,
),
r'livePhotoVideoId': PropertySchema(
id: 10,
id: 9,
name: r'livePhotoVideoId',
type: IsarType.string,
),
r'localId': PropertySchema(
id: 11,
id: 10,
name: r'localId',
type: IsarType.string,
),
r'ownerId': PropertySchema(
id: 12,
id: 11,
name: r'ownerId',
type: IsarType.long,
),
r'remoteId': PropertySchema(
id: 13,
id: 12,
name: r'remoteId',
type: IsarType.string,
),
r'stackCount': PropertySchema(
id: 14,
id: 13,
name: r'stackCount',
type: IsarType.long,
),
r'stackId': PropertySchema(
id: 15,
id: 14,
name: r'stackId',
type: IsarType.string,
),
r'stackPrimaryAssetId': PropertySchema(
id: 16,
id: 15,
name: r'stackPrimaryAssetId',
type: IsarType.string,
),
r'thumbhash': PropertySchema(
id: 17,
id: 16,
name: r'thumbhash',
type: IsarType.string,
),
r'type': PropertySchema(
id: 18,
id: 17,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 19,
id: 18,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
id: 20,
id: 19,
name: r'width',
type: IsarType.int,
)
@ -244,19 +239,18 @@ void _assetSerialize(
writer.writeInt(offsets[5], object.height);
writer.writeBool(offsets[6], object.isArchived);
writer.writeBool(offsets[7], object.isFavorite);
writer.writeBool(offsets[8], object.isOffline);
writer.writeBool(offsets[9], object.isTrashed);
writer.writeString(offsets[10], object.livePhotoVideoId);
writer.writeString(offsets[11], object.localId);
writer.writeLong(offsets[12], object.ownerId);
writer.writeString(offsets[13], object.remoteId);
writer.writeLong(offsets[14], object.stackCount);
writer.writeString(offsets[15], object.stackId);
writer.writeString(offsets[16], object.stackPrimaryAssetId);
writer.writeString(offsets[17], object.thumbhash);
writer.writeByte(offsets[18], object.type.index);
writer.writeDateTime(offsets[19], object.updatedAt);
writer.writeInt(offsets[20], object.width);
writer.writeBool(offsets[8], object.isTrashed);
writer.writeString(offsets[9], object.livePhotoVideoId);
writer.writeString(offsets[10], object.localId);
writer.writeLong(offsets[11], object.ownerId);
writer.writeString(offsets[12], object.remoteId);
writer.writeLong(offsets[13], object.stackCount);
writer.writeString(offsets[14], object.stackId);
writer.writeString(offsets[15], object.stackPrimaryAssetId);
writer.writeString(offsets[16], object.thumbhash);
writer.writeByte(offsets[17], object.type.index);
writer.writeDateTime(offsets[18], object.updatedAt);
writer.writeInt(offsets[19], object.width);
}
Asset _assetDeserialize(
@ -275,20 +269,19 @@ Asset _assetDeserialize(
id: id,
isArchived: reader.readBoolOrNull(offsets[6]) ?? false,
isFavorite: reader.readBoolOrNull(offsets[7]) ?? false,
isOffline: reader.readBoolOrNull(offsets[8]) ?? false,
isTrashed: reader.readBoolOrNull(offsets[9]) ?? false,
livePhotoVideoId: reader.readStringOrNull(offsets[10]),
localId: reader.readStringOrNull(offsets[11]),
ownerId: reader.readLong(offsets[12]),
remoteId: reader.readStringOrNull(offsets[13]),
stackCount: reader.readLongOrNull(offsets[14]) ?? 0,
stackId: reader.readStringOrNull(offsets[15]),
stackPrimaryAssetId: reader.readStringOrNull(offsets[16]),
thumbhash: reader.readStringOrNull(offsets[17]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
isTrashed: reader.readBoolOrNull(offsets[8]) ?? false,
livePhotoVideoId: reader.readStringOrNull(offsets[9]),
localId: reader.readStringOrNull(offsets[10]),
ownerId: reader.readLong(offsets[11]),
remoteId: reader.readStringOrNull(offsets[12]),
stackCount: reader.readLongOrNull(offsets[13]) ?? 0,
stackId: reader.readStringOrNull(offsets[14]),
stackPrimaryAssetId: reader.readStringOrNull(offsets[15]),
thumbhash: reader.readStringOrNull(offsets[16]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[19]),
width: reader.readIntOrNull(offsets[20]),
updatedAt: reader.readDateTime(offsets[18]),
width: reader.readIntOrNull(offsets[19]),
);
return object;
}
@ -319,29 +312,27 @@ P _assetDeserializeProp<P>(
case 8:
return (reader.readBoolOrNull(offset) ?? false) as P;
case 9:
return (reader.readBoolOrNull(offset) ?? false) as P;
return (reader.readStringOrNull(offset)) as P;
case 10:
return (reader.readStringOrNull(offset)) as P;
case 11:
return (reader.readStringOrNull(offset)) as P;
case 12:
return (reader.readLong(offset)) as P;
case 13:
case 12:
return (reader.readStringOrNull(offset)) as P;
case 14:
case 13:
return (reader.readLongOrNull(offset) ?? 0) as P;
case 14:
return (reader.readStringOrNull(offset)) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
case 16:
return (reader.readStringOrNull(offset)) as P;
case 17:
return (reader.readStringOrNull(offset)) as P;
case 18:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 19:
case 18:
return (reader.readDateTime(offset)) as P;
case 20:
case 19:
return (reader.readIntOrNull(offset)) as P;
default:
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(
bool value) {
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() {
return QueryBuilder.apply(this, (query) {
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() {
return QueryBuilder.apply(this, (query) {
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() {
return QueryBuilder.apply(this, (query) {
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() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isTrashed');

View File

@ -72,13 +72,14 @@ extension AssetListExtension on Iterable<Asset> {
}
/// 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({
void Function()? errorCallback,
}) {
final bool onlyLive = every((e) => !e.isOffline);
final bool onlyLive = every((e) => false);
if (!onlyLive) {
if (errorCallback != null) errorCallback();
return where((a) => !a.isOffline);
return where((a) => false);
}
return this;
}

View File

@ -172,29 +172,12 @@ class BottomGalleryBar extends ConsumerWidget {
}
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);
}
void handleEdit() async {
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(
MaterialPageRoute(
builder: (context) => EditImagePage(
@ -219,16 +202,6 @@ class BottomGalleryBar extends ConsumerWidget {
if (asset.isLocal) {
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(
asset,
context,

View File

@ -183,8 +183,7 @@ class TopControlAppBar extends HookConsumerWidget {
if (asset.isRemote && isOwner) buildFavoriteButton(a),
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
buildDownloadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
buildAddToAlbumButton(),
if (asset.isTrashed) buildRestoreButton(),

View File

@ -133,7 +133,6 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries |
*LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} |
*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* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} |
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate |
@ -385,7 +384,6 @@ Class | Method | HTTP request | Description
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [ScanLibraryDto](doc//ScanLibraryDto.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
- [SearchExploreItem](doc//SearchExploreItem.md)

View File

@ -197,7 +197,6 @@ part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.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_asset_response_dto.dart';
part 'model/search_explore_item.dart';

View File

@ -833,14 +833,12 @@ class AssetsApi {
///
/// * [bool] isFavorite:
///
/// * [bool] isOffline:
///
/// * [bool] isVisible:
///
/// * [String] livePhotoVideoId:
///
/// * [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
final path = r'/assets';
@ -896,10 +894,6 @@ class AssetsApi {
hasFields = true;
mp.fields[r'isFavorite'] = parameterToString(isFavorite);
}
if (isOffline != null) {
hasFields = true;
mp.fields[r'isOffline'] = parameterToString(isOffline);
}
if (isVisible != null) {
hasFields = true;
mp.fields[r'isVisible'] = parameterToString(isVisible);
@ -951,15 +945,13 @@ class AssetsApi {
///
/// * [bool] isFavorite:
///
/// * [bool] isOffline:
///
/// * [bool] isVisible:
///
/// * [String] livePhotoVideoId:
///
/// * [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 {
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, );
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, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@ -243,13 +243,13 @@ class LibrariesApi {
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:
///
/// * [String] id (required):
Future<Response> removeOfflineFilesWithHttpInfo(String id,) async {
Future<Response> scanLibraryWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final path = r'/libraries/{id}/removeOffline'
final path = r'/libraries/{id}/scan'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
@ -276,52 +276,8 @@ class LibrariesApi {
/// Parameters:
///
/// * [String] id (required):
Future<void> removeOfflineFiles(String id,) async {
final response = await removeOfflineFilesWithHttpInfo(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,);
Future<void> scanLibrary(String id,) async {
final response = await scanLibraryWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@ -448,8 +448,6 @@ class ApiClient {
return ReactionTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value);
case 'ScanLibraryDto':
return ScanLibraryDto.fromJson(value);
case 'SearchAlbumResponseDto':
return SearchAlbumResponseDto.fromJson(value);
case 'SearchAssetResponseDto':

View File

@ -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>{
};
}

View File

@ -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": {
"post": {
"operationId": "scanLibrary",
@ -2902,16 +2867,6 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScanLibraryDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
@ -8287,9 +8242,6 @@
"isFavorite": {
"type": "boolean"
},
"isOffline": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
@ -10628,17 +10580,6 @@
],
"type": "object"
},
"ScanLibraryDto": {
"properties": {
"refreshAllFiles": {
"type": "boolean"
},
"refreshModifiedFiles": {
"type": "boolean"
}
},
"type": "object"
},
"SearchAlbumResponseDto": {
"properties": {
"count": {

View File

@ -366,7 +366,6 @@ export type AssetMediaCreateDto = {
fileModifiedAt: string;
isArchived?: boolean;
isFavorite?: boolean;
isOffline?: boolean;
isVisible?: boolean;
livePhotoVideoId?: string;
sidecarData?: Blob;
@ -579,10 +578,6 @@ export type UpdateLibraryDto = {
importPaths?: string[];
name?: string;
};
export type ScanLibraryDto = {
refreshAllFiles?: boolean;
refreshModifiedFiles?: boolean;
};
export type LibraryStatsResponseDto = {
photos: number;
total: number;
@ -2066,24 +2061,14 @@ export function updateLibrary({ id, updateLibraryDto }: {
body: updateLibraryDto
})));
}
export function removeOfflineFiles({ id }: {
export function scanLibrary({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/removeOffline`, {
return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, {
...opts,
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 }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {

View File

@ -4,7 +4,6 @@ import {
CreateLibraryDto,
LibraryResponseDto,
LibraryStatsResponseDto,
ScanLibraryDto,
UpdateLibraryDto,
ValidateLibraryDto,
ValidateLibraryResponseDto,
@ -43,6 +42,13 @@ export class LibraryController {
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')
@HttpCode(200)
@Authenticated({ admin: true })
@ -51,13 +57,6 @@ export class LibraryController {
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')
@Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true })
getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
@ -66,15 +65,8 @@ export class LibraryController {
@Post(':id/scan')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ admin: true })
scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
return this.service.queueScan(id, dto);
}
@Post(':id/removeOffline')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ admin: true })
removeOfflineFiles(@Param() { id }: UUIDParamDto) {
return this.service.queueRemoveOffline(id);
@Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true })
scanLibrary(@Param() { id }: UUIDParamDto) {
return this.service.queueScan(id);
}
}

View File

@ -56,9 +56,6 @@ export class AssetMediaCreateDto extends AssetMediaBase {
@ValidateBoolean({ optional: true })
isVisible?: boolean;
@ValidateBoolean({ optional: true })
isOffline?: boolean;
@ValidateUUID({ optional: true })
livePhotoVideoId?: string;

View File

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
import { LibraryEntity } from 'src/entities/library.entity';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
import { Optional, ValidateUUID } from 'src/validation';
export class CreateLibraryDto {
@ValidateUUID()
@ -89,14 +89,6 @@ export class LibrarySearchDto {
userId?: string;
}
export class ScanLibraryDto {
@ValidateBoolean({ optional: true })
refreshModifiedFiles?: boolean;
@ValidateBoolean({ optional: true })
refreshAllFiles?: boolean;
}
export class LibraryResponseDto {
id!: string;
ownerId!: string;

View File

@ -36,8 +36,6 @@ export enum WithoutProperty {
export enum WithProperty {
SIDECAR = 'sidecar',
IS_ONLINE = 'isOnline',
IS_OFFLINE = 'isOffline',
}
export enum TimeBucketSize {
@ -176,7 +174,6 @@ export interface IAssetRepository {
): Paginated<AssetEntity>;
getRandom(userIds: string[], count: number): Promise<AssetEntity[]>;
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity>;
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;

View File

@ -76,12 +76,12 @@ export enum JobName {
FACIAL_RECOGNITION = 'facial-recognition',
// library management
LIBRARY_SCAN = 'library-refresh',
LIBRARY_SCAN_ASSET = 'library-refresh-asset',
LIBRARY_REMOVE_OFFLINE = 'library-remove-offline',
LIBRARY_CHECK_OFFLINE = 'library-check-offline',
LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files',
LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets',
LIBRARY_SYNC_FILE = 'library-sync-file',
LIBRARY_SYNC_ASSET = 'library-sync-asset',
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',
// cleanup
@ -137,16 +137,11 @@ export interface ILibraryFileJob extends IEntityJob {
assetPath: string;
}
export interface ILibraryOfflineJob extends IEntityJob {
export interface ILibraryAssetJob extends IEntityJob {
importPaths: string[];
exclusionPatterns: string[];
}
export interface ILibraryRefreshJob extends IEntityJob {
refreshModifiedFiles: boolean;
refreshAllFiles: boolean;
}
export interface IBulkEntityJob extends IBaseJob {
ids: string[];
}
@ -277,12 +272,12 @@ export type JobItem =
| { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob }
// Library Management
| { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob }
| { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob }
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
| { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_FILES; 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_QUEUE_SCAN_ALL; data: IBaseJob }
| { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
// Notification

View File

@ -268,35 +268,6 @@ DELETE FROM "assets"
WHERE
"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
SELECT DISTINCT
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
@ -366,18 +337,6 @@ WHERE
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
SELECT
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",

View File

@ -13,7 +13,6 @@ import {
AssetDeltaSyncOptions,
AssetExploreFieldOptions,
AssetFullSyncOptions,
AssetPathEntity,
AssetStats,
AssetStatsOptions,
AssetUpdateAllOptions,
@ -177,14 +176,6 @@ export class AssetRepository implements IAssetRepository {
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] })
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null> {
return this.repository.findOne({
@ -198,24 +189,16 @@ export class AssetRepository implements IAssetRepository {
async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise<string[]> {
const result = await this.repository.query(
`
WITH paths AS (SELECT unnest($2::text[]) AS path)
SELECT path FROM paths
WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path);
`,
WITH paths AS (SELECT unnest($2::text[]) AS path)
SELECT path
FROM paths
WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path);
`,
[libraryId, originalPaths],
);
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> {
let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files');
builder = searchAssetBuilder(builder, options);
@ -373,12 +356,10 @@ export class AssetRepository implements IAssetRepository {
}
@GenerateSql(
...Object.values(WithProperty)
.filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE)
.map((property) => ({
name: property,
params: [DummyValue.PAGINATION, property],
})),
...Object.values(WithProperty).map((property) => ({
name: property,
params: [DummyValue.PAGINATION, property],
})),
)
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity> {
let relations: FindOptionsRelations<AssetEntity> = {};
@ -531,26 +512,16 @@ export class AssetRepository implements IAssetRepository {
where = [{ sidecarPath: Not(IsNull()), isVisible: true }];
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: {
throw new Error(`Invalid getWith property: ${property}`);
}
}
if (libraryId) {
where = [{ ...where, libraryId }];
}
return paginate(this.repository, pagination, {
where,
withDeleted,
@ -750,7 +721,10 @@ export class AssetRepository implements IAssetRepository {
builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
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 });
}
}

View File

@ -79,12 +79,12 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.SIDECAR_WRITE]: QueueName.SIDECAR,
// Library management
[JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY,
[JobName.LIBRARY_SCAN]: QueueName.LIBRARY,
[JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY,
[JobName.LIBRARY_DELETE]: QueueName.LIBRARY,
[JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY,
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
[JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
// Notification

View File

@ -3,7 +3,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus } from 'src/enum';
import { ITrashRepository } from 'src/interfaces/trash.interface';
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 {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
@ -26,7 +26,7 @@ export class TrashRepository implements ITrashRepository {
async restore(userId: string): Promise<number> {
const result = await this.assetRepository.update(
{ ownerId: userId, deletedAt: Not(IsNull()) },
{ ownerId: userId, status: AssetStatus.TRASHED },
{ status: AssetStatus.ACTIVE, deletedAt: null },
);
@ -35,7 +35,7 @@ export class TrashRepository implements ITrashRepository {
async empty(userId: string): Promise<number> {
const result = await this.assetRepository.update(
{ ownerId: userId, deletedAt: Not(IsNull()), status: AssetStatus.TRASHED },
{ ownerId: userId, status: AssetStatus.TRASHED },
{ status: AssetStatus.DELETED },
);
@ -43,7 +43,10 @@ export class TrashRepository implements ITrashRepository {
}
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;
}
}

View File

@ -427,7 +427,6 @@ export class AssetMediaService {
livePhotoVideoId: dto.livePhotoVideoId,
originalFileName: file.originalName,
sidecarPath: sidecarFile?.originalPath,
isOffline: dto.isOffline ?? false,
});
if (sidecarFile) {

View File

@ -164,7 +164,7 @@ export class JobService {
}
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: {

View File

@ -10,9 +10,8 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import {
IJobRepository,
ILibraryAssetJob,
ILibraryFileJob,
ILibraryOfflineJob,
ILibraryRefreshJob,
JobName,
JOBS_LIBRARY_PAGINATION_SIZE,
JobStatus,
@ -37,6 +36,10 @@ import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/sto
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest';
async function* mockWalk() {
yield await Promise.resolve(['/data/user1/photo.jpg']);
}
describe(LibraryService.name, () => {
let sut: LibraryService;
@ -91,7 +94,7 @@ describe(LibraryService.name, () => {
enabled: true,
cronExpression: '0 1 * * *',
},
watch: { enabled: false },
watch: { enabled: true },
},
} as SystemConfig);
@ -163,102 +166,29 @@ describe(LibraryService.name, () => {
describe('handleQueueAssetRefresh', () => {
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 });
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 });
storageMock.walk.mockImplementation(mockWalk);
await sut.handleQueueAssetRefresh(mockLibraryJob);
await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id });
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
name: JobName.LIBRARY_SYNC_FILE,
data: {
id: libraryStub.externalLibrary1.id,
ownerId: libraryStub.externalLibrary1.owner.id,
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 () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(null);
await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).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,
},
},
]);
await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED);
});
it('should ignore import paths that do not exist', async () => {
@ -276,16 +206,9 @@ describe(LibraryService.name, () => {
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibraryWithImportPaths1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
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({
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 () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
@ -306,41 +256,31 @@ describe(LibraryService.name, () => {
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();
});
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();
expect(assetMock.remove).not.toHaveBeenCalled();
});
it('should offline assets no longer on disk', async () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
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 () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: ['**/user1/**'],
@ -348,13 +288,15 @@ describe(LibraryService.name, () => {
assetMock.getById.mockResolvedValue(assetStub.external);
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
isOffline: true,
deletedAt: expect.any(Date),
});
});
it('should set assets outside of import paths as offline', async () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/data/user2'],
exclusionPatterns: [],
@ -363,28 +305,74 @@ describe(LibraryService.name, () => {
assetMock.getById.mockResolvedValue(assetStub.external);
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 () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
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;
beforeEach(() => {
@ -397,42 +385,18 @@ describe(LibraryService.name, () => {
} as Stats);
});
it('should reject an unknown file extension', 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 () => {
it('should import a new asset', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
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([
[
@ -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 = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image);
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([
[
@ -514,18 +478,18 @@ describe(LibraryService.name, () => {
]);
});
it('should add a new video', async () => {
it('should import a new video', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/video.mp4',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
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([
[
@ -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 = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image);
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([]);
});
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 = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: assetStub.hasFileExtension.originalPath,
force: false,
};
storageMock.stat.mockResolvedValue({
@ -601,190 +563,52 @@ describe(LibraryService.name, () => {
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.queueAll).not.toHaveBeenCalled();
});
it('should import an asset when mtime differs from db asset', async () => {
it('should skip existing asset', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.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(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,
},
});
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
});
it('should import an asset that is missing a file extension', async () => {
// This tests for the case where the file extension is missing from the asset path.
// This happened in previous versions of Immich
it('should not refresh an asset trashed by user', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.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,
force: true,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension);
assetMock.create.mockResolvedValue(assetStub.hasFileExtension);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed);
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], {
fileCreatedAt: new Date('2023-01-01'),
fileModifiedAt: new Date('2023-01-01'),
originalFileName: assetStub.hasFileExtension.originalFileName,
});
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
});
it('should refresh an existing asset with modified mtime', 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 () => {
it('should throw BadRequestException when asset does not exist', async () => {
storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'"));
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: userStub.admin.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
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', () => {
it('should return library statistics', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({
photos: 10,
@ -1092,12 +915,11 @@ describe(LibraryService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
name: JobName.LIBRARY_SYNC_FILE,
data: {
id: libraryStub.externalLibraryWithImportPaths1.id,
assetPath: '/foo/photo.jpg',
ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id,
force: false,
},
},
]);
@ -1114,30 +936,16 @@ describe(LibraryService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
name: JobName.LIBRARY_SYNC_FILE,
data: {
id: libraryStub.externalLibraryWithImportPaths1.id,
assetPath: '/foo/photo.jpg',
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 () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external);
@ -1232,72 +1040,23 @@ describe(LibraryService.name, () => {
});
describe('queueScan', () => {
it('should queue a library scan of external library', async () => {
it('should queue a library scan', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(libraryStub.externalLibrary1.id, {});
await sut.queueScan(libraryStub.externalLibrary1.id);
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.LIBRARY_SCAN,
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
data: {
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,
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,
name: JobName.LIBRARY_QUEUE_SYNC_ASSETS,
data: {
id: libraryStub.externalLibrary1.id,
},
@ -1311,7 +1070,7 @@ describe(LibraryService.name, () => {
it('should queue the refresh job', async () => {
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([
[
@ -1323,48 +1082,32 @@ describe(LibraryService.name, () => {
]);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN,
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
data: {
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', () => {
it('should queue trash deletion jobs', async () => {
assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
describe('handleQueueAssetOfflineCheck', () => {
it('should queue removal jobs', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
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([
{ 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,
},
},
]);
});
});

View File

@ -1,6 +1,5 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { R_OK } from 'node:constants';
import { Stats } from 'node:fs';
import path, { basename, parse } from 'node:path';
import picomatch from 'picomatch';
import { StorageCore } from 'src/cores/storage.core';
@ -10,27 +9,26 @@ import {
CreateLibraryDto,
LibraryResponseDto,
LibraryStatsResponseDto,
ScanLibraryDto,
mapLibrary,
UpdateLibraryDto,
ValidateLibraryDto,
ValidateLibraryImportPathResponseDto,
ValidateLibraryResponseDto,
mapLibrary,
} 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 { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import {
IBaseJob,
IEntityJob,
IJobRepository,
ILibraryAssetJob,
ILibraryFileJob,
ILibraryOfflineJob,
ILibraryRefreshJob,
JOBS_LIBRARY_PAGINATION_SIZE,
JobName,
JOBS_LIBRARY_PAGINATION_SIZE,
JobStatus,
} from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
@ -78,11 +76,7 @@ export class LibraryService {
this.jobRepository.addCronJob(
'libraryScan',
scan.cronExpression,
() =>
handlePromiseError(
this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }),
this.logger,
),
() => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger),
scan.enabled,
);
@ -143,7 +137,7 @@ export class LibraryService {
const handler = async () => {
this.logger.debug(`File add event received for ${path} in library ${library.id}}`);
if (matcher(path)) {
await this.scanAssets(library.id, [path], library.ownerId, false);
await this.syncFiles(library, [path]);
}
};
return handlePromiseError(handler(), this.logger);
@ -151,9 +145,13 @@ export class LibraryService {
onChange: (path) => {
const handler = async () => {
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)) {
// 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);
@ -162,8 +160,8 @@ export class LibraryService {
const handler = async () => {
this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`);
const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path);
if (asset && matcher(path)) {
await this.assetRepository.update({ id: asset.id, isOffline: true });
if (asset) {
await this.syncAssets(library, [asset.id]);
}
};
return handlePromiseError(handler(), this.logger);
@ -216,7 +214,7 @@ export class LibraryService {
async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
const statistics = await this.repository.getStatistics(id);
if (!statistics) {
throw new BadRequestException('Library not found');
throw new BadRequestException(`Library ${id} not found`);
}
return statistics;
}
@ -250,20 +248,28 @@ export class LibraryService {
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(
assetPaths.map((assetPath) => ({
name: JobName.LIBRARY_SCAN_ASSET,
name: JobName.LIBRARY_SYNC_FILE,
data: {
id: libraryId,
id,
assetPath,
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> {
const validation = new ValidateLibraryImportPathResponseDto();
validation.importPath = importPath;
@ -366,258 +372,182 @@ export class LibraryService {
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 existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath);
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
let asset = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath);
if (asset) {
return JobStatus.SKIPPED;
}
let assetType: AssetType;
if (mimeTypes.isImage(assetPath)) {
assetType = AssetType.IMAGE;
} else if (mimeTypes.isVideo(assetPath)) {
assetType = AssetType.VIDEO;
} else {
throw new BadRequestException(`Unsupported file type ${assetPath}`);
let stat;
try {
stat = await this.storageRepository.stat(assetPath);
} catch (error: any) {
if (error.code === 'ENOENT') {
this.logger.error(`File not found: ${assetPath}`);
return JobStatus.SKIPPED;
}
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
let sidecarPath: string | null = null;
if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) {
sidecarPath = `${assetPath}.xmp`;
}
// TODO: device asset id is deprecated, remove it
const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, '');
const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE;
let assetId;
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 mtime = stat.mtime;
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
const addedAsset = await this.assetRepository.create({
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;
}
sidecarPath,
isExternal: true,
});
this.logger.debug(`Queueing metadata extraction for: ${assetPath}`);
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 } });
}
await this.queuePostSyncJobs(asset);
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.jobRepository.queue({
name: JobName.LIBRARY_SCAN,
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
data: {
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) {
this.logger.verbose(`Queueing offline file removal from library ${id}`);
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}`);
async handleQueueSyncAll(): Promise<JobStatus> {
this.logger.debug(`Refreshing all external libraries`);
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} });
const libraries = await this.repository.getAll(true);
await this.jobRepository.queueAll(
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: {
id: library.id,
refreshModifiedFiles: !job.force,
refreshAllFiles: job.force ?? false,
},
})),
);
return JobStatus.SUCCESS;
}
async handleOfflineCheck(job: ILibraryOfflineJob): Promise<JobStatus> {
async handleSyncAsset(job: ILibraryAssetJob): Promise<JobStatus> {
const asset = await this.assetRepository.getById(job.id);
if (!asset) {
// Asset is no longer in the database, skip
return JobStatus.SKIPPED;
}
if (asset.isOffline) {
this.logger.verbose(`Asset is already offline: ${asset.originalPath}`);
return JobStatus.SUCCESS;
}
const markOffline = async (explanation: string) => {
if (!asset.isOffline) {
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));
if (!isInPath) {
this.logger.debug(`Asset is no longer in an import path, marking offline: ${asset.originalPath}`);
await this.assetRepository.update({ id: asset.id, isOffline: true });
await markOffline('Asset is no longer in an import path');
return JobStatus.SUCCESS;
}
const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern));
if (isExcluded) {
this.logger.debug(`Asset is covered by an exclusion pattern, marking offline: ${asset.originalPath}`);
await this.assetRepository.update({ id: asset.id, isOffline: true });
await markOffline('Asset is covered by an exclusion pattern');
return JobStatus.SUCCESS;
}
const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK);
if (!fileExists) {
this.logger.debug(`Asset is no longer found on disk, marking offline: ${asset.originalPath}`);
await this.assetRepository.update({ id: asset.id, isOffline: true });
let stat;
try {
stat = await this.storageRepository.stat(asset.originalPath);
} catch {
await markOffline('Asset is no longer on disk or is inaccessible because of permissions');
return JobStatus.SUCCESS;
}
this.logger.verbose(
`Asset is found on disk, not covered by an exclusion pattern, and is in an import path, keeping online: ${asset.originalPath}`,
);
const mtime = stat.mtime;
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;
}
async handleRemoveOffline(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> {
async handleQueueSyncFiles(job: IEntityJob): Promise<JobStatus> {
const library = await this.repository.get(job.id);
if (!library) {
this.logger.debug(`Library ${job.id} not found, skipping refresh`);
return JobStatus.SKIPPED;
}
this.logger.log(`Refreshing library ${library.id}`);
this.logger.log(`Refreshing library ${library.id} for new assets`);
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}`);
}
const assetsOnDisk = this.storageRepository.walk({
pathsToCrawl: validImportPaths,
includeHidden: false,
exclusionPatterns: library.exclusionPatterns,
take: JOBS_LIBRARY_PAGINATION_SIZE,
});
await this.repository.update({ id: job.id, refreshedAt: new Date() });
let crawledAssets = 0;
return JobStatus.SUCCESS;
}
for await (const assetBatch of assetsOnDisk) {
crawledAssets += assetBatch.length;
this.logger.debug(`Discovered ${crawledAssets} asset(s) on disk for library ${library.id}...`);
await this.scanAssets(job.id, assetBatch, library.ownerId, job.refreshAllFiles ?? false);
this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`);
async handleQueueSyncAssets(job: IEntityJob): Promise<JobStatus> {
const library = await this.repository.get(job.id);
if (!library) {
return JobStatus.SKIPPED;
}
if (crawledAssets) {
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}`);
}
this.logger.log(`Scanning library ${library.id} for removed assets`);
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) {
onlineAssetCount += assets.length;
this.logger.debug(`Discovered ${onlineAssetCount} asset(s) in library ${library.id}...`);
assetCount += assets.length;
this.logger.debug(`Discovered ${assetCount} asset(s) in library ${library.id}...`);
await this.jobRepository.queueAll(
assets.map((asset) => ({
name: JobName.LIBRARY_CHECK_OFFLINE,
data: { id: asset.id, importPaths: validImportPaths, exclusionPatterns: library.exclusionPatterns },
name: JobName.LIBRARY_SYNC_ASSET,
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) {
this.logger.log(`Finished queueing online check of ${onlineAssetCount} assets for library ${library.id}`);
if (assetCount) {
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;
}

View File

@ -86,12 +86,12 @@ export class MicroservicesService {
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data),
[JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data),
[JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data),
[JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data),
[JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(),
[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_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.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
[JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data),

View File

@ -67,7 +67,7 @@ describe(TrashService.name, () => {
});
it('should restore', async () => {
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false });
trashMock.restore.mockResolvedValue(1);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 });
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
@ -83,7 +83,7 @@ describe(TrashService.name, () => {
});
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);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
expect(trashMock.empty).toHaveBeenCalledWith('user-id');

View File

@ -80,7 +80,7 @@ export function searchAssetBuilder(
});
}
const status = _.pick(options, ['isFavorite', 'isOffline', 'isVisible', 'type']);
const status = _.pick(options, ['isFavorite', 'isVisible', 'type']);
const {
isArchived,
isEncoded,

View File

@ -70,9 +70,9 @@ export const assetStub = {
faces: [],
sidecarPath: null,
deletedAt: null,
isOffline: false,
isExternal: false,
duplicateId: null,
isOffline: false,
}),
noWebpPath: Object.freeze<AssetEntity>({
@ -104,13 +104,13 @@ export const assetStub = {
originalFileName: 'IMG_456.jpg',
faces: [],
sidecarPath: null,
isOffline: false,
isExternal: false,
exifInfo: {
fileSizeInByte: 123_000,
} as ExifEntity,
deletedAt: null,
duplicateId: null,
isOffline: false,
}),
noThumbhash: Object.freeze<AssetEntity>({
@ -133,7 +133,6 @@ export const assetStub = {
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isOffline: false,
duration: null,
isVisible: true,
isExternal: false,
@ -146,6 +145,7 @@ export const assetStub = {
sidecarPath: null,
deletedAt: null,
duplicateId: null,
isOffline: false,
}),
primaryImage: Object.freeze<AssetEntity>({
@ -173,7 +173,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -191,6 +190,7 @@ export const assetStub = {
{ id: 'stack-child-asset-2' } as AssetEntity,
]),
duplicateId: null,
isOffline: false,
}),
image: Object.freeze<AssetEntity>({
@ -218,7 +218,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -231,9 +230,50 @@ export const assetStub = {
exifImageWidth: 2160,
} as ExifEntity,
duplicateId: null,
isOffline: false,
}),
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',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
@ -259,7 +299,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -271,8 +310,8 @@ export const assetStub = {
exifImageWidth: 2160,
} as ExifEntity,
duplicateId: null,
isOffline: true,
}),
archived: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
@ -298,7 +337,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -311,6 +349,7 @@ export const assetStub = {
exifImageWidth: 2160,
} as ExifEntity,
duplicateId: null,
isOffline: false,
}),
external: Object.freeze<AssetEntity>({
@ -338,97 +377,19 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: 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,
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>({
@ -457,7 +418,6 @@ export const assetStub = {
livePhotoVideo: null,
livePhotoVideoId: null,
isExternal: false,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
@ -467,6 +427,7 @@ export const assetStub = {
fileSizeInByte: 5000,
} as ExifEntity,
duplicateId: null,
isOffline: false,
}),
imageFrom2015: Object.freeze<AssetEntity>({
@ -490,7 +451,6 @@ export const assetStub = {
isFavorite: true,
isArchived: false,
isExternal: false,
isOffline: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -505,6 +465,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
duplicateId: null,
isOffline: false,
}),
video: Object.freeze<AssetEntity>({
@ -529,7 +490,6 @@ export const assetStub = {
isFavorite: true,
isArchived: false,
isExternal: false,
isOffline: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -545,6 +505,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
duplicateId: null,
isOffline: false,
}),
livePhotoMotionAsset: Object.freeze({
@ -664,7 +625,6 @@ export const assetStub = {
isFavorite: false,
isArchived: false,
isExternal: false,
isOffline: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -683,6 +643,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
duplicateId: null,
isOffline: false,
}),
sidecar: Object.freeze<AssetEntity>({
id: 'asset-id',
@ -705,7 +666,6 @@ export const assetStub = {
isFavorite: true,
isArchived: false,
isExternal: false,
isOffline: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -717,6 +677,7 @@ export const assetStub = {
sidecarPath: '/original/path.ext.xmp',
deletedAt: null,
duplicateId: null,
isOffline: false,
}),
sidecarWithoutExt: Object.freeze<AssetEntity>({
id: 'asset-id',
@ -739,7 +700,6 @@ export const assetStub = {
isFavorite: true,
isArchived: false,
isExternal: false,
isOffline: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -751,41 +711,7 @@ export const assetStub = {
sidecarPath: '/original/path.xmp',
deletedAt: 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,
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>({
@ -810,7 +736,6 @@ export const assetStub = {
isFavorite: true,
isArchived: false,
isExternal: false,
isOffline: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -824,6 +749,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
duplicateId: null,
isOffline: false,
}),
missingFileExtension: Object.freeze<AssetEntity>({
id: 'asset-id',
@ -850,7 +776,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [],
@ -863,6 +788,7 @@ export const assetStub = {
fileSizeInByte: 5000,
} as ExifEntity,
duplicateId: null,
isOffline: false,
}),
hasFileExtension: Object.freeze<AssetEntity>({
id: 'asset-id',
@ -889,7 +815,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [],
@ -902,6 +827,7 @@ export const assetStub = {
fileSizeInByte: 5000,
} as ExifEntity,
duplicateId: null,
isOffline: false,
}),
imageDng: Object.freeze<AssetEntity>({
id: 'asset-id',
@ -928,7 +854,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -941,6 +866,7 @@ export const assetStub = {
bitsPerSample: 14,
} as ExifEntity,
duplicateId: null,
isOffline: false,
}),
hasEmbedding: Object.freeze<AssetEntity>({
id: 'asset-id-embedding',
@ -967,7 +893,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -982,6 +907,7 @@ export const assetStub = {
assetId: 'asset-id',
embedding: Array.from({ length: 512 }, Math.random),
},
isOffline: false,
}),
hasDupe: Object.freeze<AssetEntity>({
id: 'asset-id-dupe',
@ -1008,7 +934,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -1023,5 +948,6 @@ export const assetStub = {
assetId: 'asset-id',
embedding: Array.from({ length: 512 }, Math.random),
},
isOffline: false,
}),
};

View File

@ -25,7 +25,6 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
getLivePhotoCount: vitest.fn(),
updateAll: vitest.fn(),
updateDuplicates: vitest.fn(),
getExternalLibraryAssetPaths: vitest.fn(),
getByLibraryIdAndOriginalPath: vitest.fn(),
deleteAll: vitest.fn(),
update: vitest.fn(),

View File

@ -13,7 +13,7 @@ export default defineConfig({
lines: 80,
statements: 80,
branches: 85,
functions: 85,
functions: 80,
},
},
server: {

View File

@ -59,7 +59,6 @@
export let onClose: () => void;
const sharedLink = getSharedLink();
$: isOwner = $user && asset.ownerId === $user?.id;
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
// $: showEditorButton =
@ -87,7 +86,7 @@
<ShareAction {asset} />
{/if}
{#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 asset.livePhotoVideoId}
<slot name="motion-photo" />

View File

@ -148,12 +148,21 @@
{#if asset.isOffline}
<section class="px-4 py-4">
<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-b border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">
{$t('asset_offline')}
</div>
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>
{$t('asset_offline_description')}
{#if $user?.isAdmin}
<p>{$t('admin.asset_offline_description')}</p>
{:else}
{$t('asset_offline_description')}
{/if}
</p>
</div>
<div class="rounded-b bg-red-500 px-4 py-2 text-white text-sm">
<p>{asset.originalPath}</p>
</div>
</div>
</section>
{/if}

View File

@ -1,7 +1,7 @@
<script lang="ts" context="module">
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';
type BaseProps = {
@ -65,6 +65,7 @@
opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white',
light: 'bg-white 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',
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',

View File

@ -198,7 +198,7 @@
"refreshing_all_libraries": "تحديث كافة المكتبات",
"registration": "تسجيل المدير",
"registration_description": "بما أنك أول مستخدم في النظام، سيتم تعيينك كمسؤول وستكون مسؤولًا عن المهام الإدارية، وسيتم إنشاء مستخدمين إضافيين بواسطتك.",
"removing_offline_files": "إزالة الملفات غير المتصلة",
"removing_deleted_files": "إزالة الملفات غير المتصلة",
"repair_all": "إصلاح الكل",
"repair_matched_items": "تمت مطابقة {count, plural, one {# عنصر} other {# عناصر}}",
"repaired_items": "تم إصلاح {count, plural, one {# عنصر} other {# عناصر}}",
@ -671,8 +671,8 @@
"unable_to_remove_api_key": "تعذر إزالة مفتاح API",
"unable_to_remove_assets_from_shared_link": "غير قادر على إزالة المحتويات من الرابط المشترك",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "غير قادر على إزالة الملفات غير المتصلة",
"unable_to_remove_library": "غير قادر على إزالة المكتبة",
"unable_to_remove_offline_files": "غير قادر على إزالة الملفات غير المتصلة",
"unable_to_remove_partner": "غير قادر على إزالة الشريك",
"unable_to_remove_reaction": "غير قادر على إزالة رد الفعل",
"unable_to_remove_user": "",
@ -1072,10 +1072,10 @@
"remove_assets_shared_link_confirmation": "هل أنت متأكد أنك تريد إزالة {count, plural, one {# المحتوى} other {# المحتويات}} من رابط المشاركة هذا؟",
"remove_assets_title": "هل تريد إزالة المحتويات؟",
"remove_custom_date_range": "إزالة النطاق الزمني المخصص",
"remove_deleted_assets": "إزالة الملفات الغير متصلة",
"remove_from_album": "إزالة من الألبوم",
"remove_from_favorites": "إزالة من المفضلة",
"remove_from_shared_link": "إزالة من الرابط المشترك",
"remove_offline_files": "إزالة الملفات الغير متصلة",
"remove_user": "إزالة المستخدم",
"removed_api_key": "تم إزالة مفتاح API: {name}",
"removed_from_archive": "تمت إزالتها من الأرشيف",

View File

@ -200,7 +200,7 @@
"refreshing_all_libraries": "Опресняване на всички библиотеки",
"registration": "Администраторска регистрация",
"registration_description": "Тъй като сте първият потребител в системата, ще бъдете назначен като администратор и ще отговаряте за административните задачи, а допълнителните потребители ще бъдат създадени от вас.",
"removing_offline_files": "Премахване на офлайн файлове",
"removing_deleted_files": "Премахване на офлайн файлове",
"repair_all": "Поправяне на всичко",
"repair_matched_items": "{count, plural, one {Съвпадащ елемент (#)} other {Съвпадащи елементи (#)}}",
"repaired_items": "{count, plural, one {Поправен елемент (#)} other {Поправени елементи (#)}}",
@ -605,8 +605,8 @@
"unable_to_refresh_user": "",
"unable_to_remove_album_users": "",
"unable_to_remove_api_key": "",
"unable_to_remove_deleted_assets": "",
"unable_to_remove_library": "",
"unable_to_remove_offline_files": "",
"unable_to_remove_partner": "",
"unable_to_remove_reaction": "",
"unable_to_repair_items": "",
@ -895,10 +895,10 @@
"refreshed": "Опреснено",
"refreshes_every_file": "",
"remove": "Премахни",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"removed_api_key": "",
"rename": "Преименувай",
"repair": "Поправи",

View File

@ -172,7 +172,7 @@
"paths_validated_successfully": "",
"quota_size_gib": "",
"refreshing_all_libraries": "",
"removing_offline_files": "",
"removing_deleted_files": "",
"repair_all": "",
"repair_matched_items": "",
"repaired_items": "",
@ -485,8 +485,8 @@
"unable_to_refresh_user": "",
"unable_to_remove_album_users": "",
"unable_to_remove_api_key": "",
"unable_to_remove_deleted_assets": "",
"unable_to_remove_library": "",
"unable_to_remove_offline_files": "",
"unable_to_remove_partner": "",
"unable_to_remove_reaction": "",
"unable_to_repair_items": "",
@ -718,10 +718,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"removed_api_key": "",
"rename": "",
"repair": "",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Actualitzant totes les biblioteques",
"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.",
"removing_offline_files": "Eliminant fitxers fora de línia",
"removing_deleted_files": "Eliminant fitxers fora de línia",
"repair_all": "Reparar tot",
"repair_matched_items": "Coincidència {count, plural, one {# element} other {# elements}}",
"repaired_items": "Corregit {count, plural, one {# element} other {# elements}}",
@ -683,8 +683,8 @@
"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_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_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_reaction": "No es pot eliminar la reacció",
"unable_to_remove_user": "",
@ -1071,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_title": "Eliminar els elements?",
"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_favorites": "Eliminar dels preferits",
"remove_from_shared_link": "Eliminar de l'enllaç compartit",
"remove_offline_files": "Suprimeix fitxers fora de línia",
"remove_user": "Eliminar l'usuari",
"removed_api_key": "Eliminada la clau d'API: {name}",
"removed_from_archive": "Eliminat de l'arxiu",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Obnovení všech knihoven",
"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.",
"removing_offline_files": "Odstranění offline souborů",
"removing_deleted_files": "Odstranění offline souborů",
"repair_all": "Opravit vše",
"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}}",
@ -684,8 +684,8 @@
"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_comment": "Nelze odstranit komentář",
"unable_to_remove_deleted_assets": "Nelze odstranit offline soubory",
"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_reaction": "Nelze odstranit reakci",
"unable_to_remove_user": "Nelze odebrat uživatele",
@ -1089,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_title": "Odstranit položky?",
"remove_custom_date_range": "Odstranit vlastní rozsah datumů",
"remove_deleted_assets": "Odstranit offline soubory",
"remove_from_album": "Odstranit z alba",
"remove_from_favorites": "Odstranit z oblíbených",
"remove_from_shared_link": "Odstranit ze sdíleného odkazu",
"remove_offline_files": "Odstranit offline soubory",
"remove_user": "Odebrat uživatele",
"removed_api_key": "Odstraněn API klíč: {name}",
"removed_from_archive": "Odstraněno z archivu",

View File

@ -202,7 +202,7 @@
"refreshing_all_libraries": "Opdaterer alle biblioteker",
"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.",
"removing_offline_files": "Fjerner offline-filer",
"removing_deleted_files": "Fjerner offline-filer",
"repair_all": "Reparér alle",
"repair_matched_items": "Har parret {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_api_key": "Kunne ikke fjerne API-nøgle",
"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_offline_files": "Kunne ikke fjerne offlinefiler",
"unable_to_remove_partner": "Ikke i stand til at fjerne partner",
"unable_to_remove_reaction": "Ikke i stand til at reaktion",
"unable_to_remove_user": "",
@ -811,10 +811,10 @@
"refreshed": "Opdateret",
"refreshes_every_file": "Opdaterer alle filer",
"remove": "Fjern",
"remove_deleted_assets": "Fjern fra offlinefiler",
"remove_from_album": "Fjern fra album",
"remove_from_favorites": "Fjern fra favoritter",
"remove_from_shared_link": "Fjern fra delt link",
"remove_offline_files": "Fjern fra offlinefiler",
"removed_api_key": "Fjernede API-nøgle: {name}",
"rename": "Omdøb",
"repair": "Reparér",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Alle Bibliotheken aktualisieren",
"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.",
"removing_offline_files": "Offline-Dateien entfernen",
"removing_deleted_files": "Offline-Dateien entfernen",
"repair_all": "Alle reparieren",
"repair_matched_items": "{count, plural, one {# Eintrag} other {# Einträge}} gefunden",
"repaired_items": "{count, plural, one {# Eintrag} other {# Einträge}} repariert",
@ -684,8 +684,8 @@
"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_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_offline_files": "Offline-Dateien konnten nicht entfernt werden",
"unable_to_remove_partner": "Partner kann nicht entfernt werden",
"unable_to_remove_reaction": "Reaktion kann nicht entfernt werden",
"unable_to_remove_user": "Benutzer kann nicht entfernt werden",
@ -1088,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_title": "Dateien entfernen?",
"remove_custom_date_range": "Benutzerdefinierten Datumsbereich entfernen",
"remove_deleted_assets": "Offline-Dateien entfernen",
"remove_from_album": "Aus Album entfernen",
"remove_from_favorites": "Aus Favoriten entfernen",
"remove_from_shared_link": "Aus geteilten Link entfernen",
"remove_offline_files": "Offline-Dateien entfernen",
"remove_user": "Nutzer entfernen",
"removed_api_key": "API-Schlüssel {name} wurde entfernt",
"removed_from_archive": "Aus dem Archiv entfernt",

View File

@ -28,6 +28,7 @@
"added_to_favorites_count": "Added {count, number} to favorites",
"admin": {
"add_exclusion_pattern_description": "Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named \"Raw\", use \"**/Raw/**\". To ignore all files ending in \".tif\", use \"**/*.tif\". To ignore an absolute path, use \"/path/to/ignore/**\".",
"asset_offline_description": "This external library asset is no longer found on disk and has been moved to trash. If the file was moved within the library, check your timeline for the new corresponding asset. To restore this asset, please ensure that the file path below can be accessed by Immich and scan the library.",
"authentication_settings": "Authentication Settings",
"authentication_settings_description": "Manage password, OAuth, and other authentication settings",
"authentication_settings_disable_all": "Are you sure you want to disable all login methods? Login will be completely disabled.",
@ -203,15 +204,13 @@
"refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"removing_offline_files": "Removing Offline Files",
"repair_all": "Repair All",
"repair_matched_items": "Matched {count, plural, one {# item} other {# items}}",
"repaired_items": "Repaired {count, plural, one {# item} other {# items}}",
"require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default",
"reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
"scanning_library_for_changed_files": "Scanning library for changed files",
"scanning_library_for_new_files": "Scanning library for new files",
"scanning_library": "Scanning library",
"search_jobs": "Search jobs...",
"send_welcome_email": "Send welcome email",
"server_external_domain_settings": "External domain",
@ -390,8 +389,8 @@
"asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces",
"asset_hashing": "Hashing...",
"asset_offline": "Asset offline",
"asset_offline_description": "This asset is offline. Immich can not access its file location. Please ensure the asset is available and then rescan the library.",
"asset_offline": "Asset Offline",
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
"asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash",
"asset_uploaded": "Uploaded",
@ -404,7 +403,7 @@
"assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
"assets_permanently_deleted_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
"assets_removed_count": "Removed {count, plural, one {# asset} other {# assets}}",
"assets_restore_confirmation": "Are you sure you want to restore all your trashed assets? You cannot undo this action!",
"assets_restore_confirmation": "Are you sure you want to restore all your trashed assets? You cannot undo this action! Note that any offline assets cannot be restored this way.",
"assets_restored_count": "Restored {count, plural, one {# asset} other {# assets}}",
"assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
@ -507,13 +506,14 @@
"delete_api_key_prompt": "Are you sure you want to delete this API key?",
"delete_duplicates_confirmation": "Are you sure you want to permanently delete these duplicates?",
"delete_key": "Delete key",
"delete_library": "Delete library",
"delete_library": "Delete Library",
"delete_link": "Delete link",
"delete_shared_link": "Delete shared link",
"delete_tag": "Delete tag",
"delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?",
"delete_user": "Delete user",
"deleted_shared_link": "Deleted shared link",
"deletes_missing_assets": "Deletes assets missing from disk",
"description": "Description",
"details": "Details",
"direction": "Direction",
@ -663,8 +663,8 @@
"unable_to_remove_album_users": "Unable to remove users from album",
"unable_to_remove_api_key": "Unable to remove API Key",
"unable_to_remove_assets_from_shared_link": "Unable to remove assets from shared link",
"unable_to_remove_deleted_assets": "Unable to remove offline files",
"unable_to_remove_library": "Unable to remove library",
"unable_to_remove_offline_files": "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",
@ -725,7 +725,6 @@
"fix_incorrect_match": "Fix incorrect match",
"folders": "Folders",
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
"force_re-scan_library_files": "Force Re-scan All Library Files",
"forward": "Forward",
"general": "General",
"get_help": "Get Help",
@ -893,7 +892,6 @@
"onboarding_welcome_user": "Welcome, {user}",
"online": "Online",
"only_favorites": "Only favorites",
"only_refreshes_modified_files": "Only refreshes modified files",
"open_in_map_view": "Open in map view",
"open_in_openstreetmap": "Open in OpenStreetMap",
"open_the_search_filters": "Open the search filters",
@ -1013,7 +1011,7 @@
"refresh_metadata": "Refresh metadata",
"refresh_thumbnails": "Refresh thumbnails",
"refreshed": "Refreshed",
"refreshes_every_file": "Refreshes every file",
"refreshes_every_file": "Re-reads all existing and new files",
"refreshing_encoded_video": "Refreshing encoded video",
"refreshing_metadata": "Refreshing metadata",
"regenerating_thumbnails": "Regenerating thumbnails",
@ -1022,10 +1020,10 @@
"remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
"remove_assets_title": "Remove assets?",
"remove_custom_date_range": "Remove custom date range",
"remove_deleted_assets": "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 Offline Files",
"remove_user": "Remove user",
"removed_api_key": "Removed API Key: {name}",
"removed_from_archive": "Removed from archive",
@ -1061,8 +1059,7 @@
"saved_settings": "Saved settings",
"say_something": "Say something",
"scan_all_libraries": "Scan All Libraries",
"scan_all_library_files": "Re-scan All Library Files",
"scan_new_library_files": "Scan New Library Files",
"scan_library": "Scan",
"scan_settings": "Scan Settings",
"scanning_for_album": "Scanning for album...",
"search": "Search",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Actualizar todas las bibliotecas",
"registration": "Registrar administrador",
"registration_description": "Dado que eres el primer usuario del sistema, se te asignará como Admin y serás responsable de las tareas administrativas, y de crear a los usuarios adicionales.",
"removing_offline_files": "Eliminando archivos sin conexión",
"removing_deleted_files": "Eliminando archivos sin conexión",
"repair_all": "Reparar todo",
"repair_matched_items": "Coincidencia {count, plural, one {# elemento} other {# elementos}}",
"repaired_items": "Reparado {count, plural, one {# elemento} other {# elementos}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "No se puede eliminar la clave API",
"unable_to_remove_assets_from_shared_link": "No se pueden eliminar archivos desde el enlace compartido",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "No se pueden eliminar archivos sin conexión",
"unable_to_remove_library": "No se puede eliminar la biblioteca",
"unable_to_remove_offline_files": "No se pueden eliminar archivos sin conexión",
"unable_to_remove_partner": "No se puede eliminar el invitado",
"unable_to_remove_reaction": "No se puede eliminar la reacción",
"unable_to_remove_user": "",
@ -1088,10 +1088,10 @@
"remove_assets_shared_link_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# elemento} other {# elementos}} del enlace compartido?",
"remove_assets_title": "¿Eliminar activos?",
"remove_custom_date_range": "Eliminar intervalo de fechas personalizado",
"remove_deleted_assets": "Eliminar archivos sin conexión",
"remove_from_album": "Eliminar del álbum",
"remove_from_favorites": "Quitar de favoritos",
"remove_from_shared_link": "Eliminar desde enlace compartido",
"remove_offline_files": "Eliminar archivos sin conexión",
"remove_user": "Eliminar usuario",
"removed_api_key": "Clave API eliminada: {name}",
"removed_from_archive": "Eliminado del archivo",

View File

@ -194,7 +194,7 @@
"refreshing_all_libraries": "بروز رسانی همه کتابخانه ها",
"registration": "ثبت نام مدیر",
"registration_description": "از آنجایی که شما اولین کاربر در سیستم هستید، به عنوان مدیر تعیین شده‌اید و مسئولیت انجام وظایف مدیریتی بر عهده شما خواهد بود و کاربران اضافی توسط شما ایجاد خواهند شد.",
"removing_offline_files": "حذف فایل‌های آفلاین",
"removing_deleted_files": "حذف فایل‌های آفلاین",
"repair_all": "بازسازی همه",
"repair_matched_items": "",
"repaired_items": "",
@ -524,8 +524,8 @@
"unable_to_refresh_user": "",
"unable_to_remove_album_users": "",
"unable_to_remove_api_key": "",
"unable_to_remove_deleted_assets": "",
"unable_to_remove_library": "",
"unable_to_remove_offline_files": "",
"unable_to_remove_partner": "",
"unable_to_remove_reaction": "",
"unable_to_repair_items": "",
@ -766,10 +766,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"removed_api_key": "",
"rename": "",
"repair": "",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Virkistetään kaikki kirjastot",
"registration": "Pääkäyttäjän rekisteröinti",
"registration_description": "Pääkäyttäjänä olet vastuussa järjestelmän hallinnallisista tehtävistä ja uusien käyttäjien luomisesta.",
"removing_offline_files": "Poistetaan Offline-tiedostot",
"removing_deleted_files": "Poistetaan Offline-tiedostot",
"repair_all": "Korjaa kaikki",
"repair_matched_items": "Löytyi {count, plural, one {# osuma} other {# osumaa}}",
"repaired_items": "Korjattiin {count, plural, one {# kohta} other {# kohtaa}}",
@ -1061,10 +1061,10 @@
"remove_assets_shared_link_confirmation": "Haluatko varmasti poistaa {count, plural, one {# median} other {# mediaa}} tästä jakolinkistä?",
"remove_assets_title": "Poistetaanko?",
"remove_custom_date_range": "Poista aikaväliltä",
"remove_deleted_assets": "Poista Offline-tiedostot",
"remove_from_album": "Poista albumista",
"remove_from_favorites": "Poista suosikeista",
"remove_from_shared_link": "Poista jakolinkistä",
"remove_offline_files": "Poista Offline-tiedostot",
"remove_user": "Poista käyttäjä",
"removed_api_key": "API Key {name} poistettu",
"removed_from_archive": "Poistettu arkistosta",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Actualisation de toutes les bibliothèques",
"registration": "Enregistrement de l'administrateur",
"registration_description": "Puisque vous êtes le premier utilisateur sur le système, vous serez désigné en tant qu'administrateur et responsable des tâches administratives, et vous pourrez alors créer d'autres utilisateurs.",
"removing_offline_files": "Suppression des fichiers hors ligne",
"removing_deleted_files": "Suppression des fichiers hors ligne",
"repair_all": "Réparer tout",
"repair_matched_items": "{count, plural, one {# Élément correspondant} other {# Éléments correspondants}}",
"repaired_items": "{count, plural, one {# Élément corrigé} other {# Éléments corrigés}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "Impossible de supprimer la clé API",
"unable_to_remove_assets_from_shared_link": "Impossible de supprimer des médias du lien partagé",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Impossible de supprimer les fichiers hors ligne",
"unable_to_remove_library": "Impossible de supprimer la bibliothèque",
"unable_to_remove_offline_files": "Impossible de supprimer les fichiers hors ligne",
"unable_to_remove_partner": "Impossible de supprimer le partenaire",
"unable_to_remove_reaction": "Impossible de supprimer la réaction",
"unable_to_remove_user": "",
@ -1088,10 +1088,10 @@
"remove_assets_shared_link_confirmation": "Êtes-vous sûr de vouloir supprimer {count, plural, one {# média} other {# médias}} de ce lien partagé ?",
"remove_assets_title": "Supprimer les médias ?",
"remove_custom_date_range": "Supprimer la plage de date personnalisée",
"remove_deleted_assets": "Supprimer les fichiers hors ligne",
"remove_from_album": "Supprimer de l'album",
"remove_from_favorites": "Supprimer des favoris",
"remove_from_shared_link": "Supprimer des liens partagés",
"remove_offline_files": "Supprimer les fichiers hors ligne",
"remove_user": "Supprimer l'utilisateur",
"removed_api_key": "Clé API supprimée : {name}",
"removed_from_archive": "Supprimé de l'archive",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "מרענן את כל הספריות",
"registration": "רישום מנהל מערכת",
"registration_description": "מכיוון שאתה המשתמש הראשון במערכת, אתה תוקצה כמנהל ואתה אחראי על משימות ניהול, ומשתמשים נוספים ייווצרו על ידך.",
"removing_offline_files": "הסרת קבצים לא מקוונים",
"removing_deleted_files": "הסרת קבצים לא מקוונים",
"repair_all": "תקן הכל",
"repair_matched_items": "{count, plural, one {פריט # תואם} other {# פריטים תואמים}}",
"repaired_items": "{count, plural, one {פריט # תוקן} other {# פריטים תוקנו}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "לא ניתן להסיר מפתח API",
"unable_to_remove_assets_from_shared_link": "לא ניתן להסיר נכסים מקישור משותף",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "לא ניתן להסיר קבצים לא מקוונים",
"unable_to_remove_library": "לא ניתן להסיר ספרייה",
"unable_to_remove_offline_files": "לא ניתן להסיר קבצים לא מקוונים",
"unable_to_remove_partner": "לא ניתן להסיר שותף",
"unable_to_remove_reaction": "לא ניתן להסיר תגובה",
"unable_to_remove_user": "",
@ -1088,10 +1088,10 @@
"remove_assets_shared_link_confirmation": "האם את/ה בטוח/ה שברצונך להסיר {count, plural, one {נכס #} other {# נכסים}} מהקישור המשותף הזה?",
"remove_assets_title": "הסר נכסים?",
"remove_custom_date_range": "הסר טווח תאריכים מותאם",
"remove_deleted_assets": "הסר קבצים לא מקוונים",
"remove_from_album": "הסר מאלבום",
"remove_from_favorites": "הסר מהמועדפים",
"remove_from_shared_link": "הסר מקישור משותף",
"remove_offline_files": "הסר קבצים לא מקוונים",
"remove_user": "הסר משתמש",
"removed_api_key": "מפתח API הוסר: {name}",
"removed_from_archive": "הוסר מארכיון",

View File

@ -197,7 +197,7 @@
"refreshing_all_libraries": "सभी पुस्तकालयों को ताज़ा किया जा रहा है",
"registration": "व्यवस्थापक पंजीकरण",
"registration_description": "चूंकि आप सिस्टम पर पहले उपयोगकर्ता हैं, इसलिए आपको व्यवस्थापक के रूप में नियुक्त किया जाएगा और आप प्रशासनिक कार्यों के लिए जिम्मेदार होंगे, और अतिरिक्त उपयोगकर्ता आपके द्वारा बनाए जाएंगे।",
"removing_offline_files": "ऑफ़लाइन फ़ाइलें हटाना",
"removing_deleted_files": "ऑफ़लाइन फ़ाइलें हटाना",
"repair_all": "सभी की मरम्मत",
"require_password_change_on_login": "उपयोगकर्ता को पहले लॉगिन पर पासवर्ड बदलने की आवश्यकता है",
"reset_settings_to_default": "सेटिंग्स को डिफ़ॉल्ट पर रीसेट करें",
@ -605,8 +605,8 @@
"unable_to_remove_api_key": "API कुंजी निकालने में असमर्थ",
"unable_to_remove_assets_from_shared_link": "साझा लिंक से संपत्तियों को निकालने में असमर्थ",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "ऑफ़लाइन फ़ाइलें निकालने में असमर्थ",
"unable_to_remove_library": "लाइब्रेरी हटाने में असमर्थ",
"unable_to_remove_offline_files": "ऑफ़लाइन फ़ाइलें निकालने में असमर्थ",
"unable_to_remove_partner": "पार्टनर को हटाने में असमर्थ",
"unable_to_remove_reaction": "प्रतिक्रिया निकालने में असमर्थ",
"unable_to_remove_user": "",
@ -933,10 +933,10 @@
"remove": "निकालना",
"remove_assets_title": "संपत्तियाँ हटाएँ?",
"remove_custom_date_range": "कस्टम दिनांक सीमा हटाएँ",
"remove_deleted_assets": "ऑफ़लाइन फ़ाइलें हटाएँ",
"remove_from_album": "एल्बम से हटाएँ",
"remove_from_favorites": "पसंदीदा से निकालें",
"remove_from_shared_link": "साझा लिंक से हटाएँ",
"remove_offline_files": "ऑफ़लाइन फ़ाइलें हटाएँ",
"remove_user": "उपयोगकर्ता को हटाएँ",
"removed_from_archive": "संग्रह से हटा दिया गया",
"removed_from_favorites": "पसंदीदा से हटाया गया",

View File

@ -204,7 +204,7 @@
"refreshing_all_libraries": "Osvježavanje svih biblioteka",
"registration": "Registracija administratora",
"registration_description": "Budući da ste prvi korisnik na sustavu, bit ćete dodijeljeni administratorsku ulogu i odgovorni ste za administrativne poslove, a dodatne korisnike kreirat ćete sami.",
"removing_offline_files": "Uklanjanje izvanmrežnih datoteka",
"removing_deleted_files": "Uklanjanje izvanmrežnih datoteka",
"repair_all": "Popravi sve",
"repair_matched_items": "Podudaranje {count, plural, one {# item} other {# items}}",
"repaired_items": "Popravljeno {count, plural, one {# item} other {# items}}",
@ -1038,10 +1038,10 @@
"remove_assets_shared_link_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz ove dijeljene veze?",
"remove_assets_title": "Ukloniti datoteke?",
"remove_custom_date_range": "Ukloni prilagođeni datumski raspon",
"remove_deleted_assets": "",
"remove_from_album": "Ukloni iz albuma",
"remove_from_favorites": "Ukloni iz favorita",
"remove_from_shared_link": "Ukloni iz dijeljene poveznice",
"remove_offline_files": "Ukloni izvanmrežne datoteke",
"remove_user": "Ukloni korisnika",
"removed_api_key": "Uklonjen API ključ: {name}",
"removed_from_archive": "Uklonjeno iz arhive",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Összes képtár újratöltése",
"registration": "Admin Regisztráció",
"registration_description": "Mivel ez az első felhasználó a rendszerben, ez a felhasználó lesz az Admin és lesz felelős adminisztratív teendőkért, illetve további felhasználókat ő tud létrehozni.",
"removing_offline_files": "Offline Fájlok eltávolítása",
"removing_deleted_files": "Offline Fájlok eltávolítása",
"repair_all": "Összes Javítása",
"repair_matched_items": "{count, plural, one {# egyezés} other {# egyezés}}",
"repaired_items": "Javítva {count, plural, one {# fájl} other {# fájl}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "API kulcs eltávolítása sikertelen",
"unable_to_remove_assets_from_shared_link": "Elemek eltávolítása megosztott linkből sikertelen",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Offline fájlok törlése sikertelen",
"unable_to_remove_library": "Könyvtár törlése sikertelen",
"unable_to_remove_offline_files": "Offline fájlok törlése sikertelen",
"unable_to_remove_partner": "Partner eltávolítása sikertelen",
"unable_to_remove_reaction": "Reakció eltávolítása sikertelen",
"unable_to_remove_user": "",
@ -1059,10 +1059,10 @@
"remove_assets_shared_link_confirmation": "Biztosan szeretne eltávolítani {count, plural, one {# elemet} other {# elemet}} ebből a megosztott linkből?",
"remove_assets_title": "Elemek eltávolítása?",
"remove_custom_date_range": "Szabadon megadott időintervallum eltávolítása",
"remove_deleted_assets": "Offline Fájlok Eltávolítása",
"remove_from_album": "Eltávolítás az albumból",
"remove_from_favorites": "Eltávolítás a kedvencekből",
"remove_from_shared_link": "Eltávolítás a megosztott linkből",
"remove_offline_files": "Offline Fájlok Eltávolítása",
"remove_user": "Felhasználó eltávolítása",
"removed_api_key": "API Kulcs eltávolítva: {name}",
"removed_from_archive": "Archívumból eltávolítva",

View File

@ -172,7 +172,7 @@
"paths_validated_successfully": "",
"quota_size_gib": "",
"refreshing_all_libraries": "",
"removing_offline_files": "",
"removing_deleted_files": "",
"repair_all": "",
"repair_matched_items": "",
"repaired_items": "",
@ -486,8 +486,8 @@
"unable_to_refresh_user": "",
"unable_to_remove_album_users": "",
"unable_to_remove_api_key": "",
"unable_to_remove_deleted_assets": "",
"unable_to_remove_library": "",
"unable_to_remove_offline_files": "",
"unable_to_remove_partner": "",
"unable_to_remove_reaction": "",
"unable_to_repair_items": "",
@ -720,10 +720,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"removed_api_key": "",
"rename": "",
"repair": "",

View File

@ -203,7 +203,7 @@
"refreshing_all_libraries": "Menyegarkan semua pustaka",
"registration": "Pendaftaran Admin",
"registration_description": "Karena Anda merupakan pengguna pertama dalam sistem, Anda akan ditetapkan sebagai Admin dan bertanggung jawab atas tugas administratif dan pengguna tambahan akan dibuat oleh Anda.",
"removing_offline_files": "Menghapus Berkas Luring",
"removing_deleted_files": "Menghapus Berkas Luring",
"repair_all": "Perbaiki Semua",
"repair_matched_items": "{count, plural, one {# item} other {# item}} dicocokkan",
"repaired_items": "{count, plural, one {# item} other {# item}} diperbaiki",
@ -669,8 +669,8 @@
"unable_to_remove_album_users": "Tidak dapat mengeluarkan pengguna dari album",
"unable_to_remove_api_key": "Tidak dapat menghapus Kunci API",
"unable_to_remove_assets_from_shared_link": "Tidak dapat menghapus aset dari tautan terbagi",
"unable_to_remove_deleted_assets": "Tidak dapat menghapus berkas luring",
"unable_to_remove_library": "Tidak dapat menghapus pustaka",
"unable_to_remove_offline_files": "Tidak dapat menghapus berkas luring",
"unable_to_remove_partner": "Tidak dapat menghapus partner",
"unable_to_remove_reaction": "Tidak dapat menghapus reaksi",
"unable_to_repair_items": "Tidak dapat memperbaiki item",
@ -1059,10 +1059,10 @@
"remove_assets_shared_link_confirmation": "Apakah Anda yakin ingin menghapus {count, plural, one {# aset} other {# aset}} dari tautan terbagi ini?",
"remove_assets_title": "Hapus aset?",
"remove_custom_date_range": "Hapus jangka tanggal khusus",
"remove_deleted_assets": "Hapus Berkas Luring",
"remove_from_album": "Hapus dari album",
"remove_from_favorites": "Hapus dari favorit",
"remove_from_shared_link": "Hapus dari tautan terbagi",
"remove_offline_files": "Hapus Berkas Luring",
"remove_user": "Keluarkan pengguna",
"removed_api_key": "Kunci API Dihapus: {name}",
"removed_from_archive": "Dihapus dari arsip",

View File

@ -202,7 +202,7 @@
"refreshing_all_libraries": "Aggiorna tutte le librerie",
"registration": "Registrazione amministratore",
"registration_description": "Poiché sei il primo utente del sistema, sarai assegnato come Amministratore e sarai responsabile dei task amministrativi, e utenti aggiuntivi saranno creati da te.",
"removing_offline_files": "Cancella File Offline",
"removing_deleted_files": "Cancella File Offline",
"repair_all": "Ripara Tutto",
"repair_matched_items": "{count, plural, one {Rilevato # elemento} other {Rilevati # elementi}}",
"repaired_items": "{count, plural, one {Riparato # elemento} other {Riparati # elementi}}",
@ -678,8 +678,8 @@
"unable_to_remove_api_key": "Impossibile rimuovere la chiave API",
"unable_to_remove_assets_from_shared_link": "Errore durante la rimozione degli assets da un link condiviso",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Impossibile rimuovere i file offline",
"unable_to_remove_library": "Impossibile rimuovere libreria",
"unable_to_remove_offline_files": "Impossibile rimuovere i file offline",
"unable_to_remove_partner": "Impossibile rimuovere compagno",
"unable_to_remove_reaction": "Impossibile rimuovere reazione",
"unable_to_remove_user": "",
@ -1081,10 +1081,10 @@
"remove_assets_shared_link_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# asset}} da questo link condiviso?",
"remove_assets_title": "Rimuovere asset?",
"remove_custom_date_range": "Rimuovi intervallo data personalizzato",
"remove_deleted_assets": "Rimuovi file offline",
"remove_from_album": "Rimuovere dall'album",
"remove_from_favorites": "Rimuovi dai preferiti",
"remove_from_shared_link": "Rimuovi dal link condiviso",
"remove_offline_files": "Rimuovi file offline",
"remove_user": "Rimuovi utente",
"removed_api_key": "Rimossa chiave API: {name}",
"removed_from_archive": "Rimosso dall'archivio",

View File

@ -198,7 +198,7 @@
"refreshing_all_libraries": "すべてのライブラリを更新",
"registration": "管理者登録",
"registration_description": "あなたはシステムの最初のユーザーであるため、管理者として割り当てられ、管理タスクを担当し、追加のユーザーはあなたによって作成されます。",
"removing_offline_files": "オフライン ファイルを削除します",
"removing_deleted_files": "オフライン ファイルを削除します",
"repair_all": "すべてを修復",
"repair_matched_items": "一致: {count, plural, one {#件} other {#件}}",
"repaired_items": "修復済み: {count, plural, one {#件} other {#件}}",
@ -671,8 +671,8 @@
"unable_to_remove_api_key": "API キーを削除できません",
"unable_to_remove_assets_from_shared_link": "共有リンクからアセットを削除できません",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "オフラインのファイルを削除できません",
"unable_to_remove_library": "ライブラリを削除できません",
"unable_to_remove_offline_files": "オフラインのファイルを削除できません",
"unable_to_remove_partner": "パートナーを削除できません",
"unable_to_remove_reaction": "リアクションを削除できません",
"unable_to_remove_user": "",
@ -1046,10 +1046,10 @@
"remove_assets_shared_link_confirmation": "本当にこの共有リンクから{count, plural, one {#個} other {#個}}のアセットを削除しますか?",
"remove_assets_title": "アセットを削除しますか?",
"remove_custom_date_range": "カスタム日付範囲を削除",
"remove_deleted_assets": "オフラインのファイルを削除",
"remove_from_album": "アルバムから削除",
"remove_from_favorites": "お気に入りから削除",
"remove_from_shared_link": "共有リンクから削除",
"remove_offline_files": "オフラインのファイルを削除",
"remove_user": "ユーザーを削除",
"removed_api_key": "削除されたAPI キー: {name}",
"removed_from_archive": "アーカイブから削除されました",

View File

@ -177,7 +177,7 @@
"paths_validated_successfully": "",
"quota_size_gib": "",
"refreshing_all_libraries": "",
"removing_offline_files": "",
"removing_deleted_files": "",
"repair_all": "",
"repair_matched_items": "",
"repaired_items": "",
@ -493,8 +493,8 @@
"unable_to_refresh_user": "",
"unable_to_remove_album_users": "",
"unable_to_remove_api_key": "",
"unable_to_remove_deleted_assets": "",
"unable_to_remove_library": "",
"unable_to_remove_offline_files": "",
"unable_to_remove_partner": "",
"unable_to_remove_reaction": "",
"unable_to_repair_items": "",
@ -727,10 +727,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"removed_api_key": "",
"rename": "",
"repair": "",

View File

@ -202,7 +202,7 @@
"refreshing_all_libraries": "모든 라이브러리 다시 스캔 중...",
"registration": "관리자 가입",
"registration_description": "첫 번째 사용자이기 때문에 관리자로 지정되었습니다. 관리 작업 및 사용자 생성이 가능합니다.",
"removing_offline_files": "누락된 파일을 제거하는 중...",
"removing_deleted_files": "누락된 파일을 제거하는 중...",
"repair_all": "모두 수리",
"repair_matched_items": "동일한 항목 {count, plural, one {#개} other {#개}}를 확인했습니다.",
"repaired_items": "항목 {count, plural, one {#개} other {#개}}를 수리했습니다.",
@ -676,8 +676,8 @@
"unable_to_remove_api_key": "API 키를 삭제할 수 없습니다.",
"unable_to_remove_assets_from_shared_link": "공유 링크에서 항목을 제거할 수 없습니다.",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "누락된 파일을 제거할 수 없습니다.",
"unable_to_remove_library": "라이브러리를 제거할 수 없습니다.",
"unable_to_remove_offline_files": "누락된 파일을 제거할 수 없습니다.",
"unable_to_remove_partner": "파트너를 제거할 수 없습니다.",
"unable_to_remove_reaction": "반응을 제거할 수 없습니다.",
"unable_to_remove_user": "",
@ -1062,10 +1062,10 @@
"remove_assets_shared_link_confirmation": "공유 링크에서 항목 {count, plural, one {#개} other {#개}}를 제거하시겠습니까?",
"remove_assets_title": "항목을 제거하시겠습니까?",
"remove_custom_date_range": "맞춤 기간 제거",
"remove_deleted_assets": "누락된 파일 제거",
"remove_from_album": "앨범에서 제거",
"remove_from_favorites": "즐겨찾기에서 제거",
"remove_from_shared_link": "공유 링크에서 제거",
"remove_offline_files": "누락된 파일 제거",
"remove_user": "사용자 삭제",
"removed_api_key": "API 키 삭제: {name}",
"removed_from_archive": "보관함에서 제거되었습니다.",

View File

@ -835,10 +835,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "Pašalinti",
"remove_deleted_assets": "",
"remove_from_album": "Pašalinti iš albumo",
"remove_from_favorites": "Pašalinti iš mėgstamiausių",
"remove_from_shared_link": "",
"remove_offline_files": "",
"remove_user": "Pašalinti vartotoją",
"removed_api_key": "Pašalintas API Raktas: {name}",
"rename": "Pervadinti",

View File

@ -694,10 +694,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "Noņemt no albuma",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"repair": "",
"repair_no_results_message": "",
"replace_with_upload": "",

View File

@ -660,10 +660,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "Дуртай зурагнуудаас хасах",
"remove_from_shared_link": "",
"remove_offline_files": "",
"removed_from_favorites": "Дуртай зурагнуудаас хасагдсан",
"removed_from_favorites_count": "Дуртай зурагнуудаас {count, plural, other {Removed #}} хасагдлаа",
"repair": "",

View File

@ -198,7 +198,7 @@
"refreshing_all_libraries": "Oppdaterer alle biblioteker",
"registration": "Administrator registrering",
"registration_description": "Siden du er den første brukeren på systemet, vil du bli utnevnt til administrator og ha ansvar for administrative oppgaver. Du vil også opprette eventuelle nye brukere.",
"removing_offline_files": "Fjerner frakoblede filer",
"removing_deleted_files": "Fjerner frakoblede filer",
"repair_all": "Reparer alle",
"repair_matched_items": "Samsvarte med {count, plural, one {# element} other {# elementer}}",
"repaired_items": "Reparerte {count, plural, one {# item} other {# items}}",
@ -592,8 +592,8 @@
"unable_to_remove_album_users": "Kan ikke fjerne brukere fra album",
"unable_to_remove_api_key": "Kan ikke fjerne API-nøkkel",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Kan ikke fjerne offlinefiler",
"unable_to_remove_library": "Kan ikke fjerne bibliotek",
"unable_to_remove_offline_files": "Kan ikke fjerne offlinefiler",
"unable_to_remove_partner": "Kan ikke fjerne partner",
"unable_to_remove_reaction": "Kan ikke fjerne reaksjon",
"unable_to_remove_user": "",
@ -844,10 +844,10 @@
"refreshed": "Oppdatert",
"refreshes_every_file": "Oppdaterer alle filer",
"remove": "Fjern",
"remove_deleted_assets": "Fjern fra frakoblede filer",
"remove_from_album": "Fjern fra album",
"remove_from_favorites": "Fjern fra favoritter",
"remove_from_shared_link": "Fjern fra delt lenke",
"remove_offline_files": "Fjern fra frakoblede filer",
"removed_api_key": "Fjernet API-nøkkel: {name}",
"rename": "Gi nytt navn",
"repair": "Reparer",

View File

@ -204,7 +204,7 @@
"refreshing_all_libraries": "Alle bibliotheken vernieuwen",
"registration": "Beheerder registratie",
"registration_description": "Omdat je de eerste gebruiker in het systeem bent, word je toegewezen als beheerder en ben je verantwoordelijk voor administratieve taken. Extra gebruikers kunnen door jou worden aangemaakt.",
"removing_offline_files": "Offline bestanden verwijderen",
"removing_deleted_files": "Offline bestanden verwijderen",
"repair_all": "Repareer alle",
"repair_matched_items": "Overeenkomend {count, plural, one {# item} other {# items}}",
"repaired_items": "Gerepareerd {count, plural, one {# item} other {# items}}",
@ -681,8 +681,8 @@
"unable_to_remove_api_key": "Kan API sleutel niet verwijderen",
"unable_to_remove_assets_from_shared_link": "Kan assets niet verwijderen uit gedeelde link",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Kan offline bestanden niet verwijderen",
"unable_to_remove_library": "Kan bibliotheek niet verwijderen",
"unable_to_remove_offline_files": "Kan offline bestanden niet verwijderen",
"unable_to_remove_partner": "Kan partner niet verwijderen",
"unable_to_remove_reaction": "Kan reactie niet verwijderen",
"unable_to_remove_user": "",
@ -1086,10 +1086,10 @@
"remove_assets_shared_link_confirmation": "Weet je zeker dat je {count, plural, one {# asset} other {# assets}} uit deze gedeelde link wilt verwijderen?",
"remove_assets_title": "Assets verwijderen?",
"remove_custom_date_range": "Aangepast datumbereik verwijderen",
"remove_deleted_assets": "Verwijder offline bestanden",
"remove_from_album": "Verwijder uit album",
"remove_from_favorites": "Verwijderen uit favorieten",
"remove_from_shared_link": "Verwijderen uit gedeelde link",
"remove_offline_files": "Verwijder offline bestanden",
"remove_user": "Gebruiker verwijderen",
"removed_api_key": "API sleutel verwijderd: {name}",
"removed_from_archive": "Verwijderd uit archief",

View File

@ -202,7 +202,7 @@
"refreshing_all_libraries": "Wszystkie biblioteki zostaną odświeżone",
"registration": "Rejestracja Administratora",
"registration_description": "Jesteś pierwszym użytkownikiem aplikacji, więc twoje konto jest administratorem. Możesz zarządzać platformą, w tym dodawać nowych użytkowników.",
"removing_offline_files": "Niedostępne pliki zostaną usunięte",
"removing_deleted_files": "Niedostępne pliki zostaną usunięte",
"repair_all": "Napraw Wszystko",
"repair_matched_items": "Powiązano {count, plural, one {# element} few {# elementy} other {# elementów}}",
"repaired_items": "Naprawiono {count, plural, one {# element} few {# elementy} other {# elementów}}",
@ -676,8 +676,8 @@
"unable_to_remove_api_key": "Usunięcie Klucza API nie powiodło się",
"unable_to_remove_assets_from_shared_link": "Nie można usunąć zasobów z udostępnionego linku",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Usunięcie niedostępnych plików nie powiodło się",
"unable_to_remove_library": "Usunięcie biblioteki nie powiodło się",
"unable_to_remove_offline_files": "Usunięcie niedostępnych plików nie powiodło się",
"unable_to_remove_partner": "Nie można usunąć partnerów",
"unable_to_remove_reaction": "Usunięcie reakcji nie powiodło się",
"unable_to_remove_user": "",
@ -1052,10 +1052,10 @@
"remove_assets_shared_link_confirmation": "Czy na pewno chcesz usunąć {count, plural, one {# zasób} other {# zasoby}} z tego udostępnionego linku?",
"remove_assets_title": "Usunąć zasoby?",
"remove_custom_date_range": "Usuń niestandardowy zakres dat",
"remove_deleted_assets": "Usuń Niedostępne Pliki",
"remove_from_album": "Usuń z albumu",
"remove_from_favorites": "Usuń z ulubionych",
"remove_from_shared_link": "Usuń z udostępnionego linku",
"remove_offline_files": "Usuń Niedostępne Pliki",
"remove_user": "Usuń użytkownika",
"removed_api_key": "Usunięto Klucz API: {name}",
"removed_from_archive": "Usunięto z archiwum",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "A atualizar todas as bibliotecas",
"registration": "Registo de Administrador",
"registration_description": "Como é o primeiro utilizador no sistema, será marcado como administrador, e será responsável pelas tarefas administrativas, sendo que utilizadores adicionais serão criados por si.",
"removing_offline_files": "A remover ficheiros offline",
"removing_deleted_files": "Removendo arquivos offline",
"repair_all": "Reparar tudo",
"repair_matched_items": "Encontrado(s) {count, plural, one {# item} other {# itens}}",
"repaired_items": "Reparado(s) {count, plural, one {# item} other {# itens}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "Não foi possível remover a Chave de API",
"unable_to_remove_assets_from_shared_link": "Não foi possível remover os ficheiros do link partilhado",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Não foi possível remover arquivos offline",
"unable_to_remove_library": "Não foi possível remover a biblioteca",
"unable_to_remove_offline_files": "Não foi possível remover ficheiros indisponíveis",
"unable_to_remove_partner": "Não foi possível remover parceiro",
"unable_to_remove_reaction": "Não foi possível remover a reação",
"unable_to_remove_user": "",
@ -1060,10 +1060,10 @@
"remove_assets_shared_link_confirmation": "Tem certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} deste link partilhado?",
"remove_assets_title": "Remover ficheiros?",
"remove_custom_date_range": "Remover intervalo de datas personalizado",
"remove_deleted_assets": "Remover arquivos offline",
"remove_from_album": "Remover do álbum",
"remove_from_favorites": "Remover dos favoritos",
"remove_from_shared_link": "Remover do link partilhado",
"remove_offline_files": "Remover ficheiros offline",
"remove_user": "Remover utilizador",
"removed_api_key": "Foi removida a Chave de API: {name}",
"removed_from_archive": "Removido do arquivo",

View File

@ -198,7 +198,7 @@
"refreshing_all_libraries": "Atualizando todas as bibliotecas",
"registration": "Registro de Administrador",
"registration_description": "Como você é o primeiro usuário no sistema, será designado como o Administrador e será responsável pelas tarefas administrativas. Você também poderá criar usuários adicionais.",
"removing_offline_files": "Removendo arquivos offline",
"removing_deleted_files": "Removendo arquivos offline",
"repair_all": "Reparar tudo",
"repair_matched_items": "{count, plural, one {# item encontrado} other {# itens encontrados}}",
"repaired_items": "{count, plural, one {# item reparado} other {# itens reparados}}",
@ -670,8 +670,8 @@
"unable_to_remove_api_key": "Não foi possível a Chave de API",
"unable_to_remove_assets_from_shared_link": "Não foi possível remover arquivos do link compartilhado",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Não foi possível remover arquivos offline",
"unable_to_remove_library": "Não foi possível remover a biblioteca",
"unable_to_remove_offline_files": "Não foi possível remover arquivos offline",
"unable_to_remove_partner": "Não foi possível remover parceiro",
"unable_to_remove_reaction": "Não foi possível remover a reação",
"unable_to_remove_user": "",
@ -1068,10 +1068,10 @@
"remove_assets_shared_link_confirmation": "Tem certeza de que deseja remover {count, plural, one {# arquivo} other {# arquivos}} desse link compartilhado?",
"remove_assets_title": "Remover arquivos?",
"remove_custom_date_range": "Remover intervalo de datas personalizado",
"remove_deleted_assets": "Remover arquivos offline",
"remove_from_album": "Remover do álbum",
"remove_from_favorites": "Remover dos favoritos",
"remove_from_shared_link": "Remover do link compartilhado",
"remove_offline_files": "Remover arquivos offline",
"remove_user": "Remover usuário",
"removed_api_key": "Removido a Chave de API: {name}",
"removed_from_archive": "Removido do arquivo",

View File

@ -204,7 +204,7 @@
"refreshing_all_libraries": "Bibliotecile sunt în curs de reîmprospǎtare",
"registration": "Înregistrare administratori",
"registration_description": "Deoarece sunteți primul utilizator de pe sistem, veți fi desemnat ca administrator și sunteți responsabil pentru sarcinile administrative, iar utilizatorii suplimentari vor fi creați de dumneavoastra.",
"removing_offline_files": "Eliminarea fișierelor offline",
"removing_deleted_files": "Eliminarea fișierelor offline",
"repair_all": "Reparǎ toate",
"repair_matched_items": "{count, plural, one {Potrivit # obiect} other {Potrivite # obiecte}}",
"repaired_items": "{count, plural, one {Reparat # obiect} other {Reparate # obiecte}}",
@ -895,10 +895,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "Șterge din album",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"repair": "",
"repair_no_results_message": "",
"replace_with_upload": "",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Обновление всех библиотек",
"registration": "Регистрация Администратора",
"registration_description": "Поскольку вы являетесь первым пользователем в системе, вам будет присвоена роль администратора, и вы будете отвечать за административные задачи. Дополнительных пользователей будете создавать вы.",
"removing_offline_files": "Удаление недоступных файлов",
"removing_deleted_files": "Удаление недоступных файлов",
"repair_all": "Починить всё",
"repair_matched_items": "Соответствует {count, plural, one {# элементу} few {# элементам} many {# элементам} other {# элементам}}",
"repaired_items": "Восстановлено {count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "Не удается удалить ключ API",
"unable_to_remove_assets_from_shared_link": "Невозможно удалить объекты из общей ссылки",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Не удается удалить автономные файлы",
"unable_to_remove_library": "Не удается удалить библиотеку",
"unable_to_remove_offline_files": "Не удается удалить автономные файлы",
"unable_to_remove_partner": "Не удается удалить партнера",
"unable_to_remove_reaction": "Не удается удалить реакцию",
"unable_to_remove_user": "",
@ -1088,10 +1088,10 @@
"remove_assets_shared_link_confirmation": "Вы уверены, что хотите удалить {count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурса}} из этой общей ссылки?",
"remove_assets_title": "Удалить объекты?",
"remove_custom_date_range": "Удалить пользовательский диапазон дат",
"remove_deleted_assets": "Удаление автономных файлов",
"remove_from_album": "Удалить из альбома",
"remove_from_favorites": "Удалить из избранного",
"remove_from_shared_link": "Удалить из общей ссылки",
"remove_offline_files": "Удаление автономных файлов",
"remove_user": "Удалить пользователя",
"removed_api_key": "Удален ключ API: {name}",
"removed_from_archive": "Удален из архива",

View File

@ -656,10 +656,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "Odstrániť z albumu",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"repair": "",
"repair_no_results_message": "",
"replace_with_upload": "",

View File

@ -671,10 +671,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "Odstrani iz albuma",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"repair": "",
"repair_no_results_message": "",
"replace_with_upload": "",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Освежавање свих библиотека",
"registration": "Регистрација администратора",
"registration_description": "Пошто сте први корисник на систему, бићете додељени као Админ и одговорни сте за административне задатке, а додатне кориснике ћете креирати ви.",
"removing_offline_files": "Уклањање ванмрежних датотека",
"removing_deleted_files": "Уклањање ванмрежних датотека",
"repair_all": "Поправи све",
"repair_matched_items": "Поклапа се са {count, plural, one {1 ставком} few {# ставке} other {# ставки}}",
"repaired_items": "{count, plural, one {Поправљена 1 ставка} few {Поправљене # ставке} other {Поправљене # ставки}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "Није могуће уклонити АПИ кључ (key)",
"unable_to_remove_assets_from_shared_link": "Није могуће уклонити елементе са дељеног linkа",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Није могуће уклонити ванмрежне датотеке",
"unable_to_remove_library": "Није могуће уклонити библиотеку",
"unable_to_remove_offline_files": "Није могуће уклонити ванмрежне датотеке",
"unable_to_remove_partner": "Није могуће уклонити партнера",
"unable_to_remove_reaction": "Није могуће уклонити реакцију",
"unable_to_remove_user": "",
@ -1088,10 +1088,10 @@
"remove_assets_shared_link_confirmation": "Да ли сте сигурни да желите да уклоните {count, plural, one {# датотеку} other {# датотеке}} са ове дељене везе?",
"remove_assets_title": "Уклонити датотеке?",
"remove_custom_date_range": "Уклоните прилагођени период",
"remove_deleted_assets": "Уклоните ванмрежне (offline) датотеке",
"remove_from_album": "Обриши из албума",
"remove_from_favorites": "Уклони из фаворита",
"remove_from_shared_link": "Уклоните са дељене везе",
"remove_offline_files": "Уклоните ванмрежне (offline) датотеке",
"remove_user": "Уклони корисника",
"removed_api_key": "Уклоњен АПИ кључ (key): {name}",
"removed_from_archive": "Уклоњено из архиве",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Osvežavanje svih biblioteka",
"registration": "Registracija administratora",
"registration_description": "Pošto ste prvi korisnik na sistemu, bićete dodeljeni kao Admin i odgovorni ste za administrativne zadatke, a dodatne korisnike ćete kreirati vi.",
"removing_offline_files": "Uklanjanje vanmrežnih datoteka",
"removing_deleted_files": "Uklanjanje vanmrežnih datoteka",
"repair_all": "Popravi sve",
"repair_matched_items": "Poklapa se sa {count, plural, one {1 stavkom} few {# stavke} other {# stavki}}",
"repaired_items": "{count, plural, one {Popravljena 1 stavka} few {Popravljene # stavke} other {Popravljene # stavki}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "Nije moguće ukloniti API ključ (key)",
"unable_to_remove_assets_from_shared_link": "Nije moguće ukloniti elemente sa deljenog linka",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Nije moguće ukloniti vanmrežne datoteke",
"unable_to_remove_library": "Nije moguće ukloniti biblioteku",
"unable_to_remove_offline_files": "Nije moguće ukloniti vanmrežne datoteke",
"unable_to_remove_partner": "Nije moguće ukloniti partnera",
"unable_to_remove_reaction": "Nije moguće ukloniti reakciju",
"unable_to_remove_user": "",
@ -1088,10 +1088,10 @@
"remove_assets_shared_link_confirmation": "Da li ste sigurni da želite da uklonite {count, plural, one {# datoteku} other {# datoteke}} sa ove deljene veze?",
"remove_assets_title": "Ukloniti datoteke?",
"remove_custom_date_range": "Uklonite prilagođeni period",
"remove_deleted_assets": "Uklonite vanmrežne (offline) datoteke",
"remove_from_album": "Obriši iz albuma",
"remove_from_favorites": "Ukloni iz favorita",
"remove_from_shared_link": "Uklonite sa deljene veze",
"remove_offline_files": "Uklonite vanmrežne (offline) datoteke",
"remove_user": "Ukloni korisnika",
"removed_api_key": "Uklonjen API ključ (key): {name}",
"removed_from_archive": "Uklonjeno iz arhive",

View File

@ -202,7 +202,7 @@
"refreshing_all_libraries": "Samtliga bibliotek uppdateras",
"registration": "Administratörsregistrering",
"registration_description": "Du utses till administratör eftersom du är systemets första användare. Du ansvarar för administration och kan skapa ytterligare användare.",
"removing_offline_files": "Tar bort offline-filer",
"removing_deleted_files": "Tar bort offline-filer",
"repair_all": "Reparera alla",
"repair_matched_items": "Matchade {count, plural, one {# föremål} other {# föremål}}",
"repaired_items": "Reparerade {count, plural, one {# item} other {# items}}",
@ -734,10 +734,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "Ta bort från album",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"repair": "",
"repair_no_results_message": "",
"replace_with_upload": "",

View File

@ -191,7 +191,7 @@
"refreshing_all_libraries": "அனைத்து நூலகங்களையும் புதுப்பிக்கிறது",
"registration": "நிர்வாக பதிவு",
"registration_description": "நீங்கள் கணினியில் முதல் பயனராக இருப்பதால், நீங்கள் நிர்வாகியாக நியமிக்கப்படுவீர்கள் மற்றும் நிர்வாகப் பணிகளுக்குப் பொறுப்பாவீர்கள், மேலும் உங்களால் கூடுதல் பயனர்கள் உருவாக்கப்படுவார்கள்.",
"removing_offline_files": "ஆஃப்லைன் கோப்புகளை நீக்குகிறது",
"removing_deleted_files": "ஆஃப்லைன் கோப்புகளை நீக்குகிறது",
"repair_all": "அனைத்தையும் பழுதுபார்க்கவும்",
"repair_matched_items": "பொருந்தியது {count, plural, one {# உருப்படி} other {# உருப்படிகள்}}",
"repaired_items": "பழுதுபார்க்கப்பட்டது {count, plural, one {# உருப்படி} other {# உருப்படிகள்}}",
@ -516,8 +516,8 @@
"unable_to_refresh_user": "",
"unable_to_remove_album_users": "",
"unable_to_remove_api_key": "",
"unable_to_remove_deleted_assets": "",
"unable_to_remove_library": "",
"unable_to_remove_offline_files": "",
"unable_to_remove_partner": "",
"unable_to_remove_reaction": "",
"unable_to_repair_items": "",
@ -758,10 +758,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"removed_api_key": "",
"rename": "",
"repair": "",

View File

@ -202,7 +202,7 @@
"refreshing_all_libraries": "รีเฟรชคลังภาพทั้งหมด",
"registration": "ลงทะเบียนผู้จัดการ",
"registration_description": "เนื่องจากคุณเป็นผู้ใช้งานแรกของระบบ คุณจะถูกแต่งตั้งเป็นผู้จัดการและรับผิดชอบงานบริหาร ผู้ใช้งานเพิ่มเติมจะถูกสร้างโดยคุณ",
"removing_offline_files": "กำลังลบไฟล์ออฟไลน์",
"removing_deleted_files": "กำลังลบไฟล์ออฟไลน์",
"repair_all": "ซ่อมแซมทั้งหมด",
"repair_matched_items": "จับคู่ {count, plural, one {# รายการ} other {# รายการ}}",
"repaired_items": "ซ่อมแซม {count, plural, one {# รายการ} other {# รายการ}}",
@ -729,10 +729,10 @@
"refreshed": "ถูกรีเฟรช",
"refreshes_every_file": "",
"remove": "เอาออก",
"remove_deleted_assets": "",
"remove_from_album": "ลบออกจากอัลบั้ม",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"repair": "ซ่อม",
"repair_no_results_message": "",
"replace_with_upload": "",

View File

@ -201,7 +201,7 @@
"refreshing_all_libraries": "Tüm kütüphaneler yenileniyor",
"registration": "Yönetici kaydı",
"registration_description": "Sistemdeki ilk kullanıcı olduğunuz için hesabınız Yönetici olarak ayarlandı. Yeni oluşturulan üyeliklerin, ve yönetici görevlerinin sorumlusu olarak atandınız.",
"removing_offline_files": "Çevrimdışı dosyalar kaldırılıyor",
"removing_deleted_files": "Çevrimdışı dosyalar kaldırılıyor",
"repair_all": "Tümünü onar",
"repair_matched_items": "Eşleşen {sayı, çoğul, bir {# öğe} diğer {# öğeler}}",
"repaired_items": "{count, plural, one {# item} other {# items}} tamir edildi",
@ -601,8 +601,8 @@
"unable_to_refresh_user": "",
"unable_to_remove_album_users": "",
"unable_to_remove_api_key": "",
"unable_to_remove_deleted_assets": "",
"unable_to_remove_library": "Kütüphane kaldırılamadı",
"unable_to_remove_offline_files": "",
"unable_to_remove_partner": "",
"unable_to_remove_reaction": "",
"unable_to_repair_items": "Ögeler onarılamadı",
@ -860,10 +860,10 @@
"refreshed": "",
"refreshes_every_file": "",
"remove": "Kaldır",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"remove_offline_files": "",
"removed_api_key": "",
"removed_from_favorites": "Favorilerden kaldırıldı",
"rename": "Yeniden adlandır",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Оновлення всіх бібліотек",
"registration": "Реєстрація адміністратора",
"registration_description": "Оскільки ви перший користувач в системі, ви будете призначені Адміністратором і відповідатимете за адміністративні завдання, а додаткові користувачі будуть створені вами.",
"removing_offline_files": "Видалення недоступних файлів",
"removing_deleted_files": "Видалення недоступних файлів",
"repair_all": "Відремонтуйте все",
"repair_matched_items": "Відповідає {count, plural, one {# елемент} few {# елементи} many {# елементів} other {# елементів}}",
"repaired_items": "Відновлено {count, plural, one {# елемент} few {# елементи} many {# елементів} other {# елементів}}",
@ -683,8 +683,8 @@
"unable_to_remove_api_key": "Не вдається видалити ключ API",
"unable_to_remove_assets_from_shared_link": "Не вдається видалити ресурси зі спільного посилання",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Неможливо видалити автономні файли",
"unable_to_remove_library": "Не вдається видалити бібліотеку",
"unable_to_remove_offline_files": "Неможливо видалити автономні файли",
"unable_to_remove_partner": "Не вдається видалити партнера",
"unable_to_remove_reaction": "Не вдалося видалити реакцію",
"unable_to_remove_user": "",
@ -1086,10 +1086,10 @@
"remove_assets_shared_link_confirmation": "Ви впевнені, що хочете видалити {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}} з цього спільного посилання?",
"remove_assets_title": "Видалити об'єкти?",
"remove_custom_date_range": "Видалити користувацький діапазон дат",
"remove_deleted_assets": "Видалення автономних файлів",
"remove_from_album": "Видалити з альбому",
"remove_from_favorites": "Видалити з обраного",
"remove_from_shared_link": "Видалити зі спільного посилання",
"remove_offline_files": "Видалення автономних файлів",
"remove_user": "Видалити користувача",
"removed_api_key": "Видалено ключ API: {name}",
"removed_from_archive": "Видалено з архіву",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "Làm mới tất cả các thư viện",
"registration": "Đăng ký Quản trị viên",
"registration_description": "Vì bạn là người dùng đầu tiên, bạn sẽ trở thành Quản trị viên và chịu trách nhiệm cho việc quản lý hệ thống. Ngoài ra, bạn có thể thêm các người dùng khác.",
"removing_offline_files": "Đang xoá các tập tin ngoại tuyến",
"removing_deleted_files": "Đang xoá các tập tin ngoại tuyến",
"repair_all": "Sửa chữa tất cả",
"repair_matched_items": "Đã tìm thấy {count, plural, one {# mục} other {# mục}} trùng khớp",
"repaired_items": "Đã sửa chữa {count, plural, one{# mục} other {# mục}}",
@ -683,8 +683,8 @@
"unable_to_remove_api_key": "Không thể xóa khóa API",
"unable_to_remove_assets_from_shared_link": "Không thể xóa các mục đã chọn khỏi liên kết chia sẻ",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "Không thể xóa tập tin ngoại tuyến",
"unable_to_remove_library": "Không thể xóa thư viện",
"unable_to_remove_offline_files": "Không thể xóa tập tin ngoại tuyến",
"unable_to_remove_partner": "Không thể xóa người thân",
"unable_to_remove_reaction": "Không thể xóa phản ứng",
"unable_to_remove_user": "",
@ -1058,10 +1058,10 @@
"remove_assets_shared_link_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi liên kết chia sẻ này?",
"remove_assets_title": "Xóa mục?",
"remove_custom_date_range": "Bỏ chọn khoảng ngày tùy chỉnh",
"remove_deleted_assets": "Loại bỏ tập tin ngoại tuyến",
"remove_from_album": "Xóa khỏi album",
"remove_from_favorites": "Xóa khỏi Mục yêu thích",
"remove_from_shared_link": "Xóa khỏi liên kết chia sẻ",
"remove_offline_files": "Loại bỏ tập tin ngoại tuyến",
"remove_user": "Xóa người dùng",
"removed_api_key": "Khóa API đã xóa: {name}",
"removed_from_archive": "Đã xoá khỏi Kho lưu trữ",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "正在重新整理所有圖庫",
"registration": "管理者註冊",
"registration_description": "由於您是本系統的首位使用者,因此將您指派爲負責管理本系統的管理者,其他使用者須由您協助建立帳號。",
"removing_offline_files": "移除離線檔案中",
"removing_deleted_files": "移除離線檔案中",
"repair_all": "全部糾正",
"repair_matched_items": "有 {count, plural, other {# 個項目相符}}",
"repaired_items": "已糾正 {count, plural, other {# 個項目}}",
@ -683,8 +683,8 @@
"unable_to_remove_api_key": "無法移除 API 金鑰",
"unable_to_remove_assets_from_shared_link": "無法從分享鏈結中刪除檔案",
"unable_to_remove_comment": "",
"unable_to_remove_deleted_assets": "無法移除離線檔案",
"unable_to_remove_library": "無法移除資料庫",
"unable_to_remove_offline_files": "無法移除離線檔案",
"unable_to_remove_partner": "無法移除夥伴",
"unable_to_remove_reaction": "無法移除反應",
"unable_to_remove_user": "",
@ -1056,10 +1056,10 @@
"remove_assets_shared_link_confirmation": "確定要從此分享鏈結中移除{count, plural, other {# 個檔案}}嗎?",
"remove_assets_title": "移除檔案?",
"remove_custom_date_range": "移除自訂日期範圍",
"remove_deleted_assets": "移除離線檔案",
"remove_from_album": "從相簿中移除",
"remove_from_favorites": "從收藏中移除",
"remove_from_shared_link": "從分享鏈結中移除",
"remove_offline_files": "移除離線檔案",
"remove_user": "移除用戶",
"removed_api_key": "已移除 API 金鑰:{name}",
"removed_from_archive": "從封存中移除",

View File

@ -205,7 +205,7 @@
"refreshing_all_libraries": "刷新所有图库",
"registration": "注册管理员",
"registration_description": "由于您是系统上的第一个用户,您将被指定为管理员并负责管理任务,由您来创建新的用户。",
"removing_offline_files": "移除离线文件",
"removing_deleted_files": "移除离线文件",
"repair_all": "修复所有",
"repair_matched_items": "匹配到 {count, plural, one {#个项目} other {#个项目}}",
"repaired_items": "已修复{count, plural, one {#个项目} other {#个项目}}",
@ -684,8 +684,8 @@
"unable_to_remove_api_key": "无法移除API Key",
"unable_to_remove_assets_from_shared_link": "无法从共享链接中移除项目",
"unable_to_remove_comment": "无法移除评论",
"unable_to_remove_deleted_assets": "无法移除离线文件",
"unable_to_remove_library": "无法移除图库",
"unable_to_remove_offline_files": "无法移除离线文件",
"unable_to_remove_partner": "无法移除同伴",
"unable_to_remove_reaction": "无法移除回应",
"unable_to_remove_user": "无法移除用户",
@ -1088,10 +1088,10 @@
"remove_assets_shared_link_confirmation": "确定要从共享链接中移除{count, plural, one {#个项目} other {#个项目}}?",
"remove_assets_title": "移除项目?",
"remove_custom_date_range": "取消自定义日期范围",
"remove_deleted_assets": "删除离线文件",
"remove_from_album": "从相册中移除",
"remove_from_favorites": "移出收藏",
"remove_from_shared_link": "从共享链接中移除",
"remove_offline_files": "删除离线文件",
"remove_user": "移除用户",
"removed_api_key": "移除的API Key:{name}",
"removed_from_archive": "从归档中移除",

View File

@ -211,13 +211,6 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
export const downloadFile = async (asset: AssetResponseDto) => {
const $t = get(t);
if (asset.isOffline) {
notificationController.show({
type: NotificationType.Info,
message: $t('asset_filename_is_offline', { values: { filename: asset.originalFileName } }),
});
return;
}
const assets = [
{
filename: asset.originalFileName,

View File

@ -20,7 +20,6 @@
getAllLibraries,
getLibraryStatistics,
getUserAdmin,
removeOfflineFiles,
scanLibrary,
updateLibrary,
type LibraryResponseDto,
@ -122,7 +121,7 @@
const handleScanAll = async () => {
try {
for (const library of libraries) {
await scanLibrary({ id: library.id, scanLibraryDto: {} });
await scanLibrary({ id: library.id });
}
notificationController.show({
message: $t('admin.refreshing_all_libraries'),
@ -135,9 +134,9 @@
const handleScan = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: {} });
await scanLibrary({ id: libraryId });
notificationController.show({
message: $t('admin.scanning_library_for_new_files'),
message: $t('admin.scanning_library'),
type: NotificationType.Info,
});
} catch (error) {
@ -145,42 +144,6 @@
}
};
const handleScanChanges = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: { refreshModifiedFiles: true } });
notificationController.show({
message: $t('admin.scanning_library_for_changed_files'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_scan_library'));
}
};
const handleForceScan = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: { refreshAllFiles: true } });
notificationController.show({
message: $t('admin.forcing_refresh_library_files'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_scan_library'));
}
};
const handleRemoveOffline = async (libraryId: string) => {
try {
await removeOfflineFiles({ id: libraryId });
notificationController.show({
message: $t('admin.removing_offline_files'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_remove_offline_files'));
}
};
const onRenameClicked = (index: number) => {
closeAll();
renameLibrary = index;
@ -193,7 +156,7 @@
updateLibraryIndex = index;
};
const onScanNewLibraryClicked = async (library: LibraryResponseDto) => {
const onScanClicked = async (library: LibraryResponseDto) => {
closeAll();
if (library) {
@ -207,27 +170,6 @@
updateLibraryIndex = index;
};
const onScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
closeAll();
if (library) {
await handleScanChanges(library.id);
}
};
const onForceScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
closeAll();
if (library) {
await handleForceScan(library.id);
}
};
const onRemoveOfflineFilesClicked = async (library: LibraryResponseDto) => {
closeAll();
if (library) {
await handleRemoveOffline(library.id);
}
};
const handleDelete = async (library: LibraryResponseDto, index: number) => {
closeAll();
@ -351,31 +293,17 @@
icon={mdiDotsVertical}
title={$t('library_options')}
>
<MenuOption onClick={() => onScanClicked(library)} text={$t('scan_library')} />
<hr />
<MenuOption onClick={() => onRenameClicked(index)} text={$t('rename')} />
<MenuOption onClick={() => onEditImportPathClicked(index)} text={$t('edit_import_paths')} />
<MenuOption onClick={() => onScanSettingClicked(index)} text={$t('scan_settings')} />
<hr />
<MenuOption onClick={() => onScanNewLibraryClicked(library)} text={$t('scan_new_library_files')} />
<MenuOption
onClick={() => onScanAllLibraryFilesClicked(library)}
text={$t('scan_all_library_files')}
subtitle={$t('only_refreshes_modified_files')}
/>
<MenuOption
onClick={() => onForceScanAllLibraryFilesClicked(library)}
text={$t('force_re-scan_library_files')}
subtitle={$t('refreshes_every_file')}
/>
<hr />
<MenuOption
onClick={() => onRemoveOfflineFilesClicked(library)}
text={$t('remove_offline_files')}
/>
<MenuOption
text={$t('delete_library')}
onClick={() => handleDelete(library, index)}
activeColor="bg-red-200"
textColor="text-red-600"
onClick={() => handleDelete(library, index)}
text={$t('delete_library')}
/>
</ButtonContextMenu>
</td>