immich/server/src/utils/asset.util.ts

200 lines
6.7 KiB
TypeScript
Raw Normal View History

import { BadRequestException } from '@nestjs/common';
import { StorageCore } from 'src/cores/storage.core';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, AssetType, Permission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
2025-01-21 11:09:24 -05:00
import { AccessRepository } from 'src/repositories/access.repository';
2025-02-11 14:08:13 -05:00
import { AssetRepository } from 'src/repositories/asset.repository';
2025-02-11 15:12:31 -05:00
import { EventRepository } from 'src/repositories/event.repository';
2025-02-11 14:08:13 -05:00
import { PartnerRepository } from 'src/repositories/partner.repository';
2025-02-11 17:15:56 -05:00
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
2024-08-20 07:49:56 -04:00
import { checkAccess } from 'src/utils/access';
const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType) => {
return (files || []).find((file) => file.type === type);
};
export const getAssetFiles = (files?: AssetFileEntity[]) => ({
feat: original-sized previews for non-web-friendly images (#14446) * feat(server): extract full-size previews from RAW images * feat(web): load fullsize preview for RAW images when zoomed in * refactor: tweaks for code review * refactor: rename "converted" preview/assets to "fullsize" * feat(web/server): fullsize preview for non-web-friendly images * feat: tweaks for code review * feat(server): require ASSET_DOWNLOAD premission for fullsize previews * test: fix types and interfaces * chore: gen open-api * feat(server): keep only essential exif in fullsize preview * chore: regen openapi * test: revert unnecessary timeout * feat: move full-size preview config to standalone entry * feat(i18n): update en texts * fix: don't return fullsizePath when disabled * test: full-size previews * test(web): full-size previews * chore: make open-api * feat(server): redirect to preview/original URL when fullsize thumbnail not available * fix(server): delete fullsize preview image on thumbnail regen after fullsize preview turned off * refactor(server): AssetRepository.deleteFiles with Kysely * fix(server): type of MediaRepository.writeExif * minor simplification * minor styling changes and condensed wording * simplify * chore: reuild open-api * test(server): fix media.service tests * test(web): fix photo-viewer test * fix(server): use fullsize image when requested * fix file path extension * formatting * use fullsize when zooming back out or when "display original photos" is enabled * simplify condition --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-04-01 01:24:28 +08:00
fullsizeFile: getFileByType(files, AssetFileType.FULLSIZE),
previewFile: getFileByType(files, AssetFileType.PREVIEW),
thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL),
});
export const addAssets = async (
auth: AuthDto,
2025-01-21 11:09:24 -05:00
repositories: { access: AccessRepository; bulk: IBulkAsset },
dto: { parentId: string; assetIds: string[] },
) => {
2024-08-20 07:49:56 -04:00
const { access, bulk } = repositories;
const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds);
const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id));
2024-08-20 07:49:56 -04:00
const allowedAssetIds = await checkAccess(access, {
auth,
permission: Permission.ASSET_SHARE,
ids: notPresentAssetIds,
});
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = existingAssetIds.has(assetId);
if (hasAsset) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE });
continue;
}
const hasAccess = allowedAssetIds.has(assetId);
if (!hasAccess) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
existingAssetIds.add(assetId);
results.push({ id: assetId, success: true });
}
const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
if (newAssetIds.length > 0) {
2024-08-20 07:49:56 -04:00
await bulk.addAssetIds(dto.parentId, newAssetIds);
}
return results;
};
export const removeAssets = async (
auth: AuthDto,
2025-01-21 11:09:24 -05:00
repositories: { access: AccessRepository; bulk: IBulkAsset },
dto: { parentId: string; assetIds: string[]; canAlwaysRemove: Permission },
) => {
2024-08-20 07:49:56 -04:00
const { access, bulk } = repositories;
// check if the user can always remove from the parent album, memory, etc.
2024-08-20 07:49:56 -04:00
const canAlwaysRemove = await checkAccess(access, { auth, permission: dto.canAlwaysRemove, ids: [dto.parentId] });
const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds);
const allowedAssetIds = canAlwaysRemove.has(dto.parentId)
? existingAssetIds
2024-08-20 07:49:56 -04:00
: await checkAccess(access, { auth, permission: Permission.ASSET_SHARE, ids: existingAssetIds });
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = existingAssetIds.has(assetId);
if (!hasAsset) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const hasAccess = allowedAssetIds.has(assetId);
if (!hasAccess) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
existingAssetIds.delete(assetId);
results.push({ id: assetId, success: true });
}
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0) {
2024-08-20 07:49:56 -04:00
await bulk.removeAssetIds(dto.parentId, removedIds);
}
return results;
};
2024-06-14 18:29:32 -04:00
export type PartnerIdOptions = {
userId: string;
2025-02-11 14:08:13 -05:00
repository: PartnerRepository;
2024-06-14 18:29:32 -04:00
/** only include partners with `inTimeline: true` */
timelineEnabled?: boolean;
};
export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: PartnerIdOptions) => {
const partnerIds = new Set<string>();
const partners = await repository.getAll(userId);
for (const partner of partners) {
// ignore deleted users
if (!partner.sharedBy || !partner.sharedWith) {
continue;
}
// wrong direction
if (partner.sharedWithId !== userId) {
continue;
}
if (timelineEnabled && !partner.inTimeline) {
continue;
}
partnerIds.add(partner.sharedById);
}
return [...partnerIds];
};
2025-02-11 15:12:31 -05:00
export type AssetHookRepositories = { asset: AssetRepository; event: EventRepository };
export const onBeforeLink = async (
{ asset: assetRepository, event: eventRepository }: AssetHookRepositories,
{ userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string },
) => {
const motionAsset = await assetRepository.getById(livePhotoVideoId);
if (!motionAsset) {
throw new BadRequestException('Live photo video not found');
}
if (motionAsset.type !== AssetType.VIDEO) {
throw new BadRequestException('Live photo video must be a video');
}
if (motionAsset.ownerId !== userId) {
throw new BadRequestException('Live photo video does not belong to the user');
}
if (motionAsset?.isVisible) {
await assetRepository.update({ id: livePhotoVideoId, isVisible: false });
await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId });
}
};
export const onBeforeUnlink = async (
{ asset: assetRepository }: AssetHookRepositories,
{ livePhotoVideoId }: { livePhotoVideoId: string },
) => {
const motion = await assetRepository.getById(livePhotoVideoId);
if (!motion) {
return null;
}
if (StorageCore.isAndroidMotionPath(motion.originalPath)) {
throw new BadRequestException('Cannot unlink Android motion photos');
}
return motion;
};
export const onAfterUnlink = async (
{ asset: assetRepository, event: eventRepository }: AssetHookRepositories,
{ userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string },
) => {
await assetRepository.update({ id: livePhotoVideoId, isVisible: true });
await eventRepository.emit('asset.show', { assetId: livePhotoVideoId, userId });
};
export function mapToUploadFile(file: ImmichFile): UploadFile {
return {
uuid: file.uuid,
checksum: file.checksum,
originalPath: file.path,
originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
size: file.size,
};
}
export const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
return {
auth: request.user || null,
fieldName: file.fieldname as UploadFieldName,
file: mapToUploadFile(file as ImmichFile),
};
};