2023-10-06 07:01:14 +00:00
|
|
|
import { AssetEntity, LibraryType } from '@app/infra/entities';
|
2023-07-14 21:31:42 -04:00
|
|
|
import { BadRequestException, Inject, Logger } from '@nestjs/common';
|
2023-10-04 18:11:11 -04:00
|
|
|
import _ from 'lodash';
|
2023-10-06 07:01:14 +00:00
|
|
|
import { DateTime, Duration } from 'luxon';
|
2023-06-30 12:24:28 -04:00
|
|
|
import { extname } from 'path';
|
2023-07-14 21:31:42 -04:00
|
|
|
import sanitize from 'sanitize-filename';
|
2023-10-09 10:25:03 -04:00
|
|
|
import { AccessCore, Permission } from '../access';
|
2023-05-21 08:26:06 +02:00
|
|
|
import { AuthUserDto } from '../auth';
|
2023-07-10 13:56:45 -04:00
|
|
|
import { mimeTypes } from '../domain.constant';
|
2023-06-30 12:24:28 -04:00
|
|
|
import { HumanReadableSize, usePagination } from '../domain.util';
|
2023-11-30 04:52:28 +01:00
|
|
|
import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
2023-10-09 10:25:03 -04:00
|
|
|
import {
|
|
|
|
|
CommunicationEvent,
|
|
|
|
|
IAccessRepository,
|
|
|
|
|
IAssetRepository,
|
|
|
|
|
ICommunicationRepository,
|
|
|
|
|
ICryptoRepository,
|
|
|
|
|
IJobRepository,
|
2023-11-11 15:06:19 -06:00
|
|
|
IPartnerRepository,
|
2023-10-09 10:25:03 -04:00
|
|
|
IStorageRepository,
|
|
|
|
|
ISystemConfigRepository,
|
|
|
|
|
ImmichReadStream,
|
2023-11-11 15:06:19 -06:00
|
|
|
TimeBucketOptions,
|
2023-10-09 10:25:03 -04:00
|
|
|
} from '../repositories';
|
|
|
|
|
import { StorageCore, StorageFolder } from '../storage';
|
|
|
|
|
import { SystemConfigCore } from '../system-config';
|
2023-08-04 17:07:15 -04:00
|
|
|
import {
|
2023-10-06 07:01:14 +00:00
|
|
|
AssetBulkDeleteDto,
|
2023-08-16 16:04:55 -04:00
|
|
|
AssetBulkUpdateDto,
|
2023-08-04 17:07:15 -04:00
|
|
|
AssetIdsDto,
|
2023-08-18 10:31:48 -04:00
|
|
|
AssetJobName,
|
|
|
|
|
AssetJobsDto,
|
2023-11-14 17:47:15 -05:00
|
|
|
AssetOrder,
|
|
|
|
|
AssetSearchDto,
|
2023-08-24 21:28:50 +02:00
|
|
|
AssetStatsDto,
|
2023-08-04 17:07:15 -04:00
|
|
|
DownloadArchiveInfo,
|
2023-08-15 11:49:32 -04:00
|
|
|
DownloadInfoDto,
|
2023-08-04 17:07:15 -04:00
|
|
|
DownloadResponseDto,
|
2023-08-24 21:28:50 +02:00
|
|
|
MapMarkerDto,
|
2023-08-04 17:07:15 -04:00
|
|
|
MemoryLaneDto,
|
|
|
|
|
TimeBucketAssetDto,
|
|
|
|
|
TimeBucketDto,
|
2023-10-06 07:01:14 +00:00
|
|
|
TrashAction,
|
2023-09-04 22:25:31 -04:00
|
|
|
UpdateAssetDto,
|
2023-10-22 02:38:07 +00:00
|
|
|
UpdateStackParentDto,
|
2023-09-04 15:45:59 -04:00
|
|
|
mapStats,
|
2023-08-04 17:07:15 -04:00
|
|
|
} from './dto';
|
2023-08-24 21:28:50 +02:00
|
|
|
import {
|
|
|
|
|
AssetResponseDto,
|
2023-10-06 07:01:14 +00:00
|
|
|
BulkIdsDto,
|
2023-08-24 21:28:50 +02:00
|
|
|
MapMarkerResponseDto,
|
|
|
|
|
MemoryLaneResponseDto,
|
2023-10-14 03:46:30 +02:00
|
|
|
SanitizedAssetResponseDto,
|
2023-08-24 21:28:50 +02:00
|
|
|
TimeBucketResponseDto,
|
2023-09-04 15:45:59 -04:00
|
|
|
mapAsset,
|
2023-08-24 21:28:50 +02:00
|
|
|
} from './response-dto';
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2023-07-09 00:37:40 -04:00
|
|
|
export enum UploadFieldName {
|
|
|
|
|
ASSET_DATA = 'assetData',
|
|
|
|
|
LIVE_PHOTO_DATA = 'livePhotoData',
|
|
|
|
|
SIDECAR_DATA = 'sidecarData',
|
|
|
|
|
PROFILE_DATA = 'file',
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-14 21:31:42 -04:00
|
|
|
export interface UploadRequest {
|
|
|
|
|
authUser: AuthUserDto | null;
|
|
|
|
|
fieldName: UploadFieldName;
|
|
|
|
|
file: UploadFile;
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-09 00:37:40 -04:00
|
|
|
export interface UploadFile {
|
|
|
|
|
checksum: Buffer;
|
|
|
|
|
originalPath: string;
|
|
|
|
|
originalName: string;
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-25 09:12:03 -05:00
|
|
|
export class AssetService {
|
2023-07-14 21:31:42 -04:00
|
|
|
private logger = new Logger(AssetService.name);
|
2023-06-30 12:24:28 -04:00
|
|
|
private access: AccessCore;
|
2023-10-06 07:01:14 +00:00
|
|
|
private configCore: SystemConfigCore;
|
2023-06-30 12:24:28 -04:00
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
|
|
|
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
2023-07-14 21:31:42 -04:00
|
|
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
2023-08-18 10:31:48 -04:00
|
|
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
2023-10-06 07:01:14 +00:00
|
|
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
2023-06-30 12:24:28 -04:00
|
|
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
2023-10-06 15:48:11 -05:00
|
|
|
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
2023-11-11 15:06:19 -06:00
|
|
|
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
2023-06-30 12:24:28 -04:00
|
|
|
) {
|
2023-10-23 14:37:51 +02:00
|
|
|
this.access = AccessCore.create(accessRepository);
|
2023-10-09 02:51:03 +02:00
|
|
|
this.configCore = SystemConfigCore.create(configRepository);
|
2023-06-30 12:24:28 -04:00
|
|
|
}
|
2023-05-21 08:26:06 +02:00
|
|
|
|
2023-11-14 17:47:15 -05:00
|
|
|
search(authUser: AuthUserDto, dto: AssetSearchDto) {
|
|
|
|
|
let checksum: Buffer | undefined = undefined;
|
|
|
|
|
|
|
|
|
|
if (dto.checksum) {
|
|
|
|
|
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
|
|
|
|
|
checksum = Buffer.from(dto.checksum, encoding);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const;
|
|
|
|
|
const order = dto.order ? enumToOrder[dto.order] : undefined;
|
|
|
|
|
|
|
|
|
|
return this.assetRepository
|
|
|
|
|
.search({
|
|
|
|
|
...dto,
|
|
|
|
|
order,
|
|
|
|
|
checksum,
|
|
|
|
|
ownerId: authUser.id,
|
|
|
|
|
})
|
|
|
|
|
.then((assets) =>
|
|
|
|
|
assets.map((asset) =>
|
|
|
|
|
mapAsset(asset, {
|
|
|
|
|
stripMetadata: false,
|
|
|
|
|
withStack: true,
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-14 21:31:42 -04:00
|
|
|
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
|
|
|
|
|
this.access.requireUploadAccess(authUser);
|
|
|
|
|
|
|
|
|
|
const filename = file.originalName;
|
|
|
|
|
|
|
|
|
|
switch (fieldName) {
|
|
|
|
|
case UploadFieldName.ASSET_DATA:
|
|
|
|
|
if (mimeTypes.isAsset(filename)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case UploadFieldName.LIVE_PHOTO_DATA:
|
|
|
|
|
if (mimeTypes.isVideo(filename)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case UploadFieldName.SIDECAR_DATA:
|
|
|
|
|
if (mimeTypes.isSidecar(filename)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case UploadFieldName.PROFILE_DATA:
|
|
|
|
|
if (mimeTypes.isProfile(filename)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.error(`Unsupported file type ${filename}`);
|
|
|
|
|
throw new BadRequestException(`Unsupported file type ${filename}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getUploadFilename({ authUser, fieldName, file }: UploadRequest): string {
|
|
|
|
|
this.access.requireUploadAccess(authUser);
|
|
|
|
|
|
|
|
|
|
const originalExt = extname(file.originalName);
|
|
|
|
|
|
|
|
|
|
const lookup = {
|
|
|
|
|
[UploadFieldName.ASSET_DATA]: originalExt,
|
|
|
|
|
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
|
|
|
|
|
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
|
|
|
|
[UploadFieldName.PROFILE_DATA]: originalExt,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getUploadFolder({ authUser, fieldName }: UploadRequest): string {
|
|
|
|
|
authUser = this.access.requireUploadAccess(authUser);
|
|
|
|
|
|
2023-10-23 17:52:21 +02:00
|
|
|
let folder = StorageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id);
|
2023-07-14 21:31:42 -04:00
|
|
|
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
2023-10-23 17:52:21 +02:00
|
|
|
folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id);
|
2023-07-14 21:31:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.storageRepository.mkdirSync(folder);
|
|
|
|
|
|
|
|
|
|
return folder;
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-21 08:26:06 +02:00
|
|
|
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
|
|
|
|
|
return this.assetRepository.getMapMarkers(authUser.id, options);
|
|
|
|
|
}
|
2023-06-14 20:47:18 -05:00
|
|
|
|
2023-06-15 14:05:30 -04:00
|
|
|
async getMemoryLane(authUser: AuthUserDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
2023-10-04 18:11:11 -04:00
|
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
|
const assets = await this.assetRepository.getByDayOfYear(authUser.id, dto);
|
|
|
|
|
|
|
|
|
|
return _.chain(assets)
|
|
|
|
|
.filter((asset) => asset.localDateTime.getFullYear() < currentYear)
|
|
|
|
|
.map((asset) => {
|
|
|
|
|
const years = currentYear - asset.localDateTime.getFullYear();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
title: `${years} year${years > 1 ? 's' : ''} since...`,
|
|
|
|
|
asset: mapAsset(asset),
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.groupBy((asset) => asset.title)
|
|
|
|
|
.map((items, title) => ({ title, assets: items.map(({ asset }) => asset) }))
|
|
|
|
|
.value();
|
2023-06-14 20:47:18 -05:00
|
|
|
}
|
2023-06-30 12:24:28 -04:00
|
|
|
|
2023-08-11 12:00:51 -04:00
|
|
|
private async timeBucketChecks(authUser: AuthUserDto, dto: TimeBucketDto) {
|
|
|
|
|
if (dto.albumId) {
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ALBUM_READ, [dto.albumId]);
|
2023-10-09 11:57:36 -04:00
|
|
|
} else {
|
|
|
|
|
dto.userId = dto.userId || authUser.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dto.userId) {
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.TIMELINE_READ, [dto.userId]);
|
2023-08-15 19:02:38 +03:00
|
|
|
if (dto.isArchived !== false) {
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ARCHIVE_READ, [dto.userId]);
|
|
|
|
|
}
|
2023-08-11 12:00:51 -04:00
|
|
|
}
|
2023-11-11 15:06:19 -06:00
|
|
|
|
|
|
|
|
if (dto.withPartners) {
|
|
|
|
|
const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
|
|
|
|
|
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
|
|
|
|
|
const requestedTrash = dto.isTrashed === true;
|
|
|
|
|
|
|
|
|
|
if (requestedArchived || requestedFavorite || requestedTrash) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-08-11 12:00:51 -04:00
|
|
|
}
|
|
|
|
|
|
2023-08-04 17:07:15 -04:00
|
|
|
async getTimeBuckets(authUser: AuthUserDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
|
2023-08-11 12:00:51 -04:00
|
|
|
await this.timeBucketChecks(authUser, dto);
|
2023-11-11 15:06:19 -06:00
|
|
|
const timeBucketOptions = await this.buildTimeBucketOptions(authUser, dto);
|
|
|
|
|
|
|
|
|
|
return this.assetRepository.getTimeBuckets(timeBucketOptions);
|
2023-08-04 17:07:15 -04:00
|
|
|
}
|
|
|
|
|
|
2023-11-03 21:33:15 -04:00
|
|
|
async getTimeBucket(
|
2023-10-14 03:46:30 +02:00
|
|
|
authUser: AuthUserDto,
|
|
|
|
|
dto: TimeBucketAssetDto,
|
|
|
|
|
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
|
2023-08-11 12:00:51 -04:00
|
|
|
await this.timeBucketChecks(authUser, dto);
|
2023-11-11 15:06:19 -06:00
|
|
|
const timeBucketOptions = await this.buildTimeBucketOptions(authUser, dto);
|
|
|
|
|
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
|
2023-10-14 03:46:30 +02:00
|
|
|
if (authUser.isShowMetadata) {
|
2023-10-27 15:34:01 -05:00
|
|
|
return assets.map((asset) => mapAsset(asset, { withStack: true }));
|
2023-10-14 03:46:30 +02:00
|
|
|
} else {
|
2023-10-22 02:38:07 +00:00
|
|
|
return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
|
2023-10-14 03:46:30 +02:00
|
|
|
}
|
2023-08-04 17:07:15 -04:00
|
|
|
}
|
|
|
|
|
|
2023-11-11 15:06:19 -06:00
|
|
|
async buildTimeBucketOptions(authUser: AuthUserDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
|
|
|
|
|
const { userId, ...options } = dto;
|
|
|
|
|
let userIds: string[] | undefined = undefined;
|
|
|
|
|
|
|
|
|
|
if (userId) {
|
|
|
|
|
userIds = [userId];
|
|
|
|
|
|
|
|
|
|
if (dto.withPartners) {
|
|
|
|
|
const partners = await this.partnerRepository.getAll(authUser.id);
|
|
|
|
|
const partnersIds = partners
|
|
|
|
|
.filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline)
|
|
|
|
|
.map((partner) => partner.sharedById);
|
|
|
|
|
|
|
|
|
|
userIds.push(...partnersIds);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ...options, userIds };
|
|
|
|
|
}
|
2023-06-30 12:24:28 -04:00
|
|
|
async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, id);
|
|
|
|
|
|
|
|
|
|
const [asset] = await this.assetRepository.getByIds([id]);
|
|
|
|
|
if (!asset) {
|
|
|
|
|
throw new BadRequestException('Asset not found');
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-20 13:16:33 +02:00
|
|
|
if (asset.isOffline) {
|
|
|
|
|
throw new BadRequestException('Asset is offline');
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-10 13:56:45 -04:00
|
|
|
return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath));
|
2023-06-30 12:24:28 -04:00
|
|
|
}
|
|
|
|
|
|
2023-08-15 11:49:32 -04:00
|
|
|
async getDownloadInfo(authUser: AuthUserDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
|
2023-06-30 12:24:28 -04:00
|
|
|
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
|
|
|
|
|
const archives: DownloadArchiveInfo[] = [];
|
|
|
|
|
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };
|
|
|
|
|
|
|
|
|
|
const assetPagination = await this.getDownloadAssets(authUser, dto);
|
|
|
|
|
for await (const assets of assetPagination) {
|
|
|
|
|
// motion part of live photos
|
|
|
|
|
const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter<string>((id): id is string => !!id);
|
|
|
|
|
if (motionIds.length > 0) {
|
|
|
|
|
assets.push(...(await this.assetRepository.getByIds(motionIds)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const asset of assets) {
|
|
|
|
|
archive.size += Number(asset.exifInfo?.fileSizeInByte || 0);
|
|
|
|
|
archive.assetIds.push(asset.id);
|
|
|
|
|
|
|
|
|
|
if (archive.size > targetSize) {
|
|
|
|
|
archives.push(archive);
|
|
|
|
|
archive = { size: 0, assetIds: [] };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (archive.assetIds.length > 0) {
|
|
|
|
|
archives.push(archive);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalSize: archives.reduce((total, item) => (total += item.size), 0),
|
|
|
|
|
archives,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async downloadArchive(authUser: AuthUserDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds);
|
|
|
|
|
|
|
|
|
|
const zip = this.storageRepository.createZipStream();
|
|
|
|
|
const assets = await this.assetRepository.getByIds(dto.assetIds);
|
2023-07-01 00:44:55 -04:00
|
|
|
const paths: Record<string, number> = {};
|
2023-06-30 12:24:28 -04:00
|
|
|
|
|
|
|
|
for (const { originalPath, originalFileName } of assets) {
|
|
|
|
|
const ext = extname(originalPath);
|
|
|
|
|
let filename = `${originalFileName}${ext}`;
|
2023-07-01 00:44:55 -04:00
|
|
|
const count = paths[filename] || 0;
|
|
|
|
|
paths[filename] = count + 1;
|
|
|
|
|
if (count !== 0) {
|
|
|
|
|
filename = `${originalFileName}+${count}${ext}`;
|
2023-06-30 12:24:28 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
zip.addFile(originalPath, filename);
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-13 07:22:40 +02:00
|
|
|
void zip.finalize();
|
2023-06-30 12:24:28 -04:00
|
|
|
|
|
|
|
|
return { stream: zip.stream };
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-15 11:49:32 -04:00
|
|
|
private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadInfoDto): Promise<AsyncGenerator<AssetEntity[]>> {
|
2023-06-30 12:24:28 -04:00
|
|
|
const PAGINATION_SIZE = 2500;
|
|
|
|
|
|
|
|
|
|
if (dto.assetIds) {
|
|
|
|
|
const assetIds = dto.assetIds;
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetIds);
|
|
|
|
|
const assets = await this.assetRepository.getByIds(assetIds);
|
|
|
|
|
return (async function* () {
|
|
|
|
|
yield assets;
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dto.albumId) {
|
|
|
|
|
const albumId = dto.albumId;
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ALBUM_DOWNLOAD, albumId);
|
|
|
|
|
return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dto.userId) {
|
|
|
|
|
const userId = dto.userId;
|
2023-09-20 13:16:33 +02:00
|
|
|
await this.access.requirePermission(authUser, Permission.TIMELINE_DOWNLOAD, userId);
|
2023-10-06 07:01:14 +00:00
|
|
|
return usePagination(PAGINATION_SIZE, (pagination) =>
|
|
|
|
|
this.assetRepository.getByUserId(pagination, userId, { isVisible: true }),
|
|
|
|
|
);
|
2023-06-30 12:24:28 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new BadRequestException('assetIds, albumId, or userId is required');
|
|
|
|
|
}
|
2023-07-14 09:30:17 -04:00
|
|
|
|
|
|
|
|
async getStatistics(authUser: AuthUserDto, dto: AssetStatsDto) {
|
|
|
|
|
const stats = await this.assetRepository.getStatistics(authUser.id, dto);
|
|
|
|
|
return mapStats(stats);
|
|
|
|
|
}
|
2023-08-16 16:04:55 -04:00
|
|
|
|
2023-09-23 17:28:55 +02:00
|
|
|
async getRandom(authUser: AuthUserDto, count: number): Promise<AssetResponseDto[]> {
|
|
|
|
|
const assets = await this.assetRepository.getRandom(authUser.id, count);
|
|
|
|
|
return assets.map((a) => mapAsset(a));
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-25 15:46:20 +00:00
|
|
|
async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
|
|
|
|
|
return this.assetRepository.getAllByDeviceId(authUser.id, deviceId);
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-04 22:25:31 -04:00
|
|
|
async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);
|
|
|
|
|
|
2023-11-30 04:52:28 +01:00
|
|
|
const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto;
|
|
|
|
|
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude });
|
2023-09-04 22:25:31 -04:00
|
|
|
|
|
|
|
|
const asset = await this.assetRepository.save({ id, ...rest });
|
|
|
|
|
return mapAsset(asset);
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-06 07:01:14 +00:00
|
|
|
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
|
2023-11-30 04:52:28 +01:00
|
|
|
const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto;
|
2023-08-16 16:04:55 -04:00
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
|
2023-10-22 02:38:07 +00:00
|
|
|
|
|
|
|
|
if (removeParent) {
|
|
|
|
|
(options as Partial<AssetEntity>).stackParentId = null;
|
|
|
|
|
const assets = await this.assetRepository.getByIds(ids);
|
|
|
|
|
// This updates the updatedAt column of the parents to indicate that one of its children is removed
|
|
|
|
|
// All the unique parent's -> parent is set to null
|
|
|
|
|
ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!)));
|
|
|
|
|
} else if (options.stackParentId) {
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId);
|
|
|
|
|
// Merge stacks
|
|
|
|
|
const assets = await this.assetRepository.getByIds(ids);
|
|
|
|
|
const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0);
|
|
|
|
|
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id)));
|
|
|
|
|
|
|
|
|
|
// This updates the updatedAt column of the parent to indicate that a new child has been added
|
|
|
|
|
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-30 04:52:28 +01:00
|
|
|
for (const id of ids) {
|
|
|
|
|
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-08 11:15:46 -05:00
|
|
|
for (const id of ids) {
|
|
|
|
|
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-16 16:04:55 -04:00
|
|
|
await this.assetRepository.updateAll(ids, options);
|
2023-10-22 02:38:07 +00:00
|
|
|
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
|
2023-08-16 16:04:55 -04:00
|
|
|
}
|
2023-08-18 10:31:48 -04:00
|
|
|
|
2023-10-06 07:01:14 +00:00
|
|
|
async handleAssetDeletionCheck() {
|
|
|
|
|
const config = await this.configCore.getConfig();
|
|
|
|
|
const trashedDays = config.trash.enabled ? config.trash.days : 0;
|
|
|
|
|
const trashedBefore = DateTime.now()
|
|
|
|
|
.minus(Duration.fromObject({ days: trashedDays }))
|
|
|
|
|
.toJSDate();
|
|
|
|
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
|
|
|
|
this.assetRepository.getAll(pagination, { trashedBefore }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for await (const assets of assetPagination) {
|
|
|
|
|
for (const asset of assets) {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id } });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleAssetDeletion(job: IAssetDeletionJob) {
|
|
|
|
|
const { id, fromExternal } = job;
|
|
|
|
|
|
|
|
|
|
const asset = await this.assetRepository.getById(id);
|
|
|
|
|
if (!asset) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ignore requests that are not from external library job but is for an external asset
|
|
|
|
|
if (!fromExternal && (!asset.library || asset.library.type === LibraryType.EXTERNAL)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-22 02:38:07 +00:00
|
|
|
// Replace the parent of the stack children with a new asset
|
|
|
|
|
if (asset.stack && asset.stack.length != 0) {
|
|
|
|
|
const stackIds = asset.stack.map((a) => a.id);
|
|
|
|
|
const newParentId = stackIds[0];
|
|
|
|
|
await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId });
|
|
|
|
|
await this.assetRepository.updateAll([newParentId], { stackParentId: null });
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-06 07:01:14 +00:00
|
|
|
await this.assetRepository.remove(asset);
|
2023-10-06 15:48:11 -05:00
|
|
|
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
|
2023-10-06 07:01:14 +00:00
|
|
|
|
|
|
|
|
// TODO refactor this to use cascades
|
|
|
|
|
if (asset.livePhotoVideoId) {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const files = [asset.webpPath, asset.resizePath, asset.encodedVideoPath, asset.sidecarPath];
|
|
|
|
|
if (!fromExternal) {
|
|
|
|
|
files.push(asset.originalPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!asset.isReadOnly) {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteAll(authUser: AuthUserDto, dto: AssetBulkDeleteDto): Promise<void> {
|
|
|
|
|
const { ids, force } = dto;
|
|
|
|
|
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_DELETE, ids);
|
|
|
|
|
|
|
|
|
|
if (force) {
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id } });
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
await this.assetRepository.softDeleteAll(ids);
|
2023-10-06 15:48:11 -05:00
|
|
|
this.communicationRepository.send(CommunicationEvent.ASSET_TRASH, authUser.id, ids);
|
2023-10-06 07:01:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleTrashAction(authUser: AuthUserDto, action: TrashAction): Promise<void> {
|
|
|
|
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
|
|
|
|
this.assetRepository.getByUserId(pagination, authUser.id, { trashedBefore: DateTime.now().toJSDate() }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (action == TrashAction.RESTORE_ALL) {
|
|
|
|
|
for await (const assets of assetPagination) {
|
|
|
|
|
const ids = assets.map((a) => a.id);
|
|
|
|
|
await this.assetRepository.restoreAll(ids);
|
2023-10-16 18:01:38 +00:00
|
|
|
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
|
2023-10-06 07:01:14 +00:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action == TrashAction.EMPTY_ALL) {
|
|
|
|
|
for await (const assets of assetPagination) {
|
|
|
|
|
for (const asset of assets) {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id } });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async restoreAll(authUser: AuthUserDto, dto: BulkIdsDto): Promise<void> {
|
|
|
|
|
const { ids } = dto;
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_RESTORE, ids);
|
|
|
|
|
await this.assetRepository.restoreAll(ids);
|
2023-10-16 18:01:38 +00:00
|
|
|
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
|
2023-10-06 07:01:14 +00:00
|
|
|
}
|
|
|
|
|
|
2023-10-22 02:38:07 +00:00
|
|
|
async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise<void> {
|
|
|
|
|
const { oldParentId, newParentId } = dto;
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId);
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId);
|
|
|
|
|
|
|
|
|
|
const childIds: string[] = [];
|
|
|
|
|
const oldParent = await this.assetRepository.getById(oldParentId);
|
|
|
|
|
if (oldParent != null) {
|
|
|
|
|
childIds.push(oldParent.id);
|
|
|
|
|
// Get all children of old parent
|
|
|
|
|
childIds.push(...(oldParent.stack?.map((a) => a.id) ?? []));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]);
|
|
|
|
|
await this.assetRepository.updateAll(childIds, { stackParentId: newParentId });
|
|
|
|
|
// Remove ParentId of new parent if this was previously a child of some other asset
|
|
|
|
|
return this.assetRepository.updateAll([newParentId], { stackParentId: null });
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-18 10:31:48 -04:00
|
|
|
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
|
|
|
|
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
|
|
|
|
|
|
|
|
|
|
for (const id of dto.assetIds) {
|
|
|
|
|
switch (dto.name) {
|
|
|
|
|
case AssetJobName.REFRESH_METADATA:
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id } });
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case AssetJobName.REGENERATE_THUMBNAIL:
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } });
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case AssetJobName.TRANSCODE_VIDEO:
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id } });
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-11-30 04:52:28 +01:00
|
|
|
|
|
|
|
|
private async updateMetadata(dto: ISidecarWriteJob) {
|
|
|
|
|
const { id, description, dateTimeOriginal, latitude, longitude } = dto;
|
|
|
|
|
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.isUndefined);
|
|
|
|
|
if (Object.keys(writes).length > 0) {
|
|
|
|
|
await this.assetRepository.upsertExif({ assetId: id, ...writes });
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } });
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|