immich/web/src/lib/services/asset.service.ts
2026-03-17 17:54:11 -04:00

395 lines
12 KiB
TypeScript

import { ProjectionType } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import {
AssetJobName,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
getBaseUrl,
runAssetJobs,
updateAsset,
type AssetJobsDto,
type AssetResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAlertOutline,
mdiCogRefreshOutline,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDownload,
mdiDownloadBox,
mdiFaceRecognition,
mdiHeadSyncOutline,
mdiHeart,
mdiHeartOutline,
mdiImageRefreshOutline,
mdiInformationOutline,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiPlus,
mdiShareVariantOutline,
mdiTagPlusOutline,
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlContext) => {
const ownedAssets = ctx.getOwnedAssets();
const assetIds = ownedAssets.map((asset) => asset.id);
const isAllVideos = ownedAssets.every((asset) => asset.isVideo);
const onAction = async (name: AssetJobName) => {
await handleRunAssetJob({ name, assetIds });
ctx.clearSelect();
};
const AddToAlbum: ActionItem = {
title: $t('add_to_album'),
icon: mdiPlus,
shortcuts: [{ key: 'l' }],
onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds }),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
onAction: () => onAction(AssetJobName.RefreshFaces),
};
const RefreshMetadataJob: ActionItem = {
title: $t('refresh_metadata'),
icon: mdiDatabaseRefreshOutline,
onAction: () => onAction(AssetJobName.RefreshMetadata),
};
const RegenerateThumbnailJob: ActionItem = {
title: $t('refresh_thumbnails'),
icon: mdiImageRefreshOutline,
onAction: () => onAction(AssetJobName.RegenerateThumbnail),
};
const TranscodeVideoJob: ActionItem = {
title: $t('refresh_encoded_videos'),
icon: mdiCogRefreshOutline,
onAction: () => onAction(AssetJobName.TranscodeVideo),
$if: () => isAllVideos,
};
return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
};
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const sharedLink = getSharedLink();
const currentAuthUser = get(authUser);
const userPreferences = get(preferences);
const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId);
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
type: $t('assets'),
$if: () => !!(currentAuthUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
const Download: ActionItem = {
title: $t('download'),
icon: mdiDownload,
shortcuts: { key: 'd', shift: true },
type: $t('assets'),
$if: () => !!currentAuthUser,
onAction: () => handleDownloadAsset(asset, { edited: true }),
};
const DownloadOriginal: ActionItem = {
title: $t('download_original'),
icon: mdiDownloadBox,
type: $t('assets'),
$if: () => !!currentAuthUser && asset.isEdited,
onAction: () => handleDownloadAsset(asset, { edited: false }),
};
const SharedLinkDownload: ActionItem = {
...Download,
$if: () => isOwner || !!sharedLink?.allowDownload,
};
const PlayMotionPhoto: ActionItem = {
title: $t('play_motion_photo'),
icon: mdiMotionPlayOutline,
type: $t('assets'),
$if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto,
onAction: () => {
assetViewerManager.isPlayingMotionPhoto = true;
},
};
const StopMotionPhoto: ActionItem = {
title: $t('stop_motion_photo'),
icon: mdiMotionPauseOutline,
type: $t('assets'),
$if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto,
onAction: () => {
assetViewerManager.isPlayingMotionPhoto = false;
},
};
const Favorite: ActionItem = {
title: $t('to_favorite'),
icon: mdiHeartOutline,
type: $t('assets'),
$if: () => isOwner && !asset.isFavorite,
onAction: () => handleFavorite(asset),
shortcuts: [{ key: 'f' }],
};
const Unfavorite: ActionItem = {
title: $t('unfavorite'),
icon: mdiHeart,
type: $t('assets'),
$if: () => isOwner && asset.isFavorite,
onAction: () => handleUnfavorite(asset),
shortcuts: [{ key: 'f' }],
};
const AddToAlbum: ActionItem = {
title: $t('add_to_album'),
icon: mdiPlus,
shortcuts: [{ key: 'l' }],
$if: () => asset.visibility !== AssetVisibility.Locked && !asset.isTrashed,
onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds: [asset.id] }),
};
const Offline: ActionItem = {
title: $t('asset_offline'),
icon: mdiAlertOutline,
type: $t('assets'),
color: 'danger',
$if: () => !!asset.isOffline,
onAction: () => assetViewerManager.toggleDetailPanel(),
};
const ZoomIn: ActionItem = {
title: $t('zoom_image'),
icon: mdiMagnifyPlusOutline,
$if: () => assetViewerManager.canZoomIn(),
onAction: () => assetViewerManager.emit('Zoom'),
};
const ZoomOut: ActionItem = {
title: $t('zoom_image'),
icon: mdiMagnifyMinusOutline,
$if: () => assetViewerManager.canZoomOut(),
onAction: () => assetViewerManager.emit('Zoom'),
};
const Copy: ActionItem = {
title: $t('copy_image'),
icon: mdiContentCopy,
$if: () => assetViewerManager.canCopyImage(),
onAction: () => assetViewerManager.emit('Copy'),
};
const Info: ActionItem = {
title: $t('info'),
icon: mdiInformationOutline,
type: $t('assets'),
$if: () => asset.hasMetadata,
onAction: () => assetViewerManager.toggleDetailPanel(),
shortcuts: { key: 'i' },
};
const Tag: ActionItem = {
title: $t('add_tag'),
icon: mdiTagPlusOutline,
type: $t('assets'),
$if: () => userPreferences.tags.enabled,
onAction: () => modalManager.show(AssetTagModal, { assetIds: [asset.id] }),
shortcuts: { key: 't' },
};
const TagPeople: ActionItem = {
title: $t('tag_people'),
icon: mdiFaceRecognition,
type: $t('assets'),
$if: () => isOwner && asset.type === AssetTypeEnum.Image && !asset.isTrashed,
onAction: () => {
isFaceEditMode.value = !isFaceEditMode.value;
},
shortcuts: { key: 'p' },
};
const Edit: ActionItem = {
title: $t('editor'),
icon: mdiTune,
$if: () =>
!sharedLink &&
isOwner &&
asset.type === AssetTypeEnum.Image &&
!asset.livePhotoVideoId &&
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&
!asset.originalPath.toLowerCase().endsWith('.insp') &&
!asset.originalPath.toLowerCase().endsWith('.gif') &&
!asset.originalPath.toLowerCase().endsWith('.svg'),
onAction: () => assetViewerManager.openEditor(),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
onAction: () => handleRunAssetJob({ name: AssetJobName.RefreshFaces, assetIds: [asset.id] }),
};
const RefreshMetadataJob: ActionItem = {
title: $t('refresh_metadata'),
icon: mdiDatabaseRefreshOutline,
onAction: () => handleRunAssetJob({ name: AssetJobName.RefreshMetadata, assetIds: [asset.id] }),
};
const RegenerateThumbnailJob: ActionItem = {
title: $t('refresh_thumbnails'),
icon: mdiImageRefreshOutline,
onAction: () => handleRunAssetJob({ name: AssetJobName.RegenerateThumbnail, assetIds: [asset.id] }),
};
const TranscodeVideoJob: ActionItem = {
title: $t('refresh_encoded_videos'),
icon: mdiCogRefreshOutline,
onAction: () => handleRunAssetJob({ name: AssetJobName.TranscodeVideo, assetIds: [asset.id] }),
$if: () => asset.type === AssetTypeEnum.Video,
};
return {
Share,
Download,
DownloadOriginal,
SharedLinkDownload,
Offline,
Info,
Favorite,
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
AddToAlbum,
ZoomIn,
ZoomOut,
Copy,
Tag,
TagPeople,
Edit,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
TranscodeVideoJob,
};
};
export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: { edited: boolean }) => {
const $t = await getFormatter();
const assets = [
{
filename: asset.originalFileName,
id: asset.id,
},
];
const isAndroidMotionVideo = (asset: AssetResponseDto) => {
return asset.originalPath.includes('encoded-video');
};
if (asset.livePhotoVideoId) {
const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId });
if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
});
}
}
const queryParams = asQueryString(authManager.params);
for (const [i, { filename, id }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
}
try {
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
filename,
);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}
}
};
const handleFavorite = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } });
toastManager.primary($t('added_to_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
const handleUnfavorite = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } });
toastManager.primary($t('removed_from_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
[AssetJobName.RefreshMetadata]: $t('refreshing_metadata'),
[AssetJobName.RegenerateThumbnail]: $t('regenerating_thumbnails'),
[AssetJobName.TranscodeVideo]: $t('refreshing_encoded_video'),
};
return messages[job];
};
const handleRunAssetJob = async (dto: AssetJobsDto) => {
const $t = await getFormatter();
try {
await runAssetJobs({ assetJobsDto: dto });
toastManager.primary(getAssetJobMessage($t, dto.name));
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}
};