mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 01:11:16 +03: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:
committed by
GitHub
parent
1ef2834603
commit
b2f2be3485
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,6 @@ export class AssetMediaCreateDto extends AssetMediaBase {
|
||||
@ValidateBoolean({ optional: true })
|
||||
isVisible?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isOffline?: boolean;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
livePhotoVideoId?: string;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +427,6 @@ export class AssetMediaService {
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: file.originalName,
|
||||
sidecarPath: sidecarFile?.originalPath,
|
||||
isOffline: dto.isOffline ?? false,
|
||||
});
|
||||
|
||||
if (sidecarFile) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user