(cherry picked from commit b45ec0f55abb9dc39fbbb39dcf5296d45a726ba0)
557 lines
18 KiB
TypeScript
557 lines
18 KiB
TypeScript
import {
|
|
AssetJobName,
|
|
AssetMediaSize,
|
|
AssetTypeEnum,
|
|
AssetVisibility,
|
|
bulkTagAssets,
|
|
getAssetInfo,
|
|
removeAssetFromAlbum,
|
|
runAssetJobs,
|
|
updateAsset,
|
|
type AlbumResponseDto,
|
|
type AssetJobsDto,
|
|
type AssetResponseDto,
|
|
} from '@immich/sdk';
|
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
|
import {
|
|
mdiAccountCircleOutline,
|
|
mdiAlertOutline,
|
|
mdiCogRefreshOutline,
|
|
mdiCompare,
|
|
mdiContentCopy,
|
|
mdiDatabaseRefreshOutline,
|
|
mdiDownload,
|
|
mdiDownloadBox,
|
|
mdiFaceRecognition,
|
|
mdiHeadSyncOutline,
|
|
mdiHeart,
|
|
mdiHeartOutline,
|
|
mdiImageRefreshOutline,
|
|
mdiImageRemoveOutline,
|
|
mdiImageSearch,
|
|
mdiInformationOutline,
|
|
mdiMagnifyMinusOutline,
|
|
mdiMagnifyPlusOutline,
|
|
mdiMotionPauseOutline,
|
|
mdiMotionPlayOutline,
|
|
mdiPlus,
|
|
mdiPresentationPlay,
|
|
mdiShareVariantOutline,
|
|
mdiTagMultipleOutline,
|
|
mdiTagPlusOutline,
|
|
mdiTune,
|
|
} from '@mdi/js';
|
|
import type { MessageFormatter } from 'svelte-i18n';
|
|
import { goto } from '$app/navigation';
|
|
import { ProjectionType } from '$lib/constants';
|
|
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
|
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 ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
|
|
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
|
import { readPreloadedQuery } from '$lib/queries/client';
|
|
import { getUserConfigQuery } from '$lib/queries/config-user.query';
|
|
import { Route } from '$lib/route';
|
|
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
|
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
|
import { downloadUrl } from '$lib/utils';
|
|
import { handleError } from '$lib/utils/handle-error';
|
|
import { getFormatter } from '$lib/utils/i18n';
|
|
|
|
export const getAssetBulkActions = ($t: MessageFormatter, album?: AlbumResponseDto) => {
|
|
const assetIds = assetMultiSelectManager.assets.map((asset) => asset.id);
|
|
const ownedAssets = assetMultiSelectManager.ownedAssets;
|
|
const isAlbumOwner = album?.albumUsers[0].user.id === authManager.user.id;
|
|
|
|
const onAction = async (name: AssetJobName) => {
|
|
await handleRunAssetJob({ name, assetIds: ownedAssets.map(({ id }) => id) });
|
|
assetMultiSelectManager.clear();
|
|
};
|
|
|
|
const AddToAlbum: ActionItem = {
|
|
title: $t('add_to_album'),
|
|
icon: mdiPlus,
|
|
shortcuts: [{ key: 'l' }],
|
|
onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds }),
|
|
};
|
|
|
|
const CreateSharedLink: ActionItem = {
|
|
title: $t('share'),
|
|
icon: mdiShareVariantOutline,
|
|
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds }),
|
|
};
|
|
|
|
const RemoveFromAlbum: ActionItem = {
|
|
title: $t('remove_from_album'),
|
|
icon: mdiImageRemoveOutline,
|
|
shortcuts: [{ key: 'l', shift: true }],
|
|
$if: () => !!album && (isAlbumOwner || assetMultiSelectManager.isAllUserOwned),
|
|
onAction: () => handleBulkRemoveAssetsFromAlbum(assetIds, album!),
|
|
};
|
|
|
|
const Tag: ActionItem = {
|
|
title: $t('tag'),
|
|
icon: mdiTagMultipleOutline,
|
|
$if: () => authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned,
|
|
onAction: async () => {
|
|
if (await modalManager.show(AssetTagModal, { assetIds })) {
|
|
assetMultiSelectManager.clear();
|
|
}
|
|
},
|
|
shortcuts: { key: 't' },
|
|
};
|
|
|
|
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: () => ownedAssets.every((asset) => asset.isVideo),
|
|
};
|
|
|
|
return {
|
|
AddToAlbum,
|
|
CreateSharedLink,
|
|
RemoveFromAlbum,
|
|
Tag,
|
|
RefreshFacesJob,
|
|
RefreshMetadataJob,
|
|
RegenerateThumbnailJob,
|
|
TranscodeVideoJob,
|
|
};
|
|
};
|
|
|
|
export const getAssetActions = (
|
|
$t: MessageFormatter,
|
|
asset: AssetResponseDto & { stackPrimaryAssetId?: string },
|
|
album?: AlbumResponseDto,
|
|
) => {
|
|
const sharedLink = getSharedLink();
|
|
const authUser = authManager.authenticated ? authManager.user : undefined;
|
|
const isOwner = !!(authUser && authUser.id === asset.ownerId);
|
|
const isAlbumOwner = !!(authUser && authUser.id === album?.albumUsers[0].user.id);
|
|
const smartSearchEnabled = !!authUser && readPreloadedQuery(getUserConfigQuery()).machineLearning.clip.enabled;
|
|
|
|
const Share: ActionItem = {
|
|
title: $t('share'),
|
|
icon: mdiShareVariantOutline,
|
|
$if: () => !!(authUser && !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 },
|
|
$if: () => !!authUser,
|
|
onAction: () => handleDownloadAsset(asset, { edited: true }),
|
|
};
|
|
|
|
const DownloadOriginal: ActionItem = {
|
|
title: $t('download_original'),
|
|
icon: mdiDownloadBox,
|
|
$if: () => !!authUser && 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,
|
|
$if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto,
|
|
onAction: () => {
|
|
assetViewerManager.isPlayingMotionPhoto = true;
|
|
},
|
|
};
|
|
|
|
const StopMotionPhoto: ActionItem = {
|
|
title: $t('stop_motion_photo'),
|
|
icon: mdiMotionPauseOutline,
|
|
$if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto,
|
|
onAction: () => {
|
|
assetViewerManager.isPlayingMotionPhoto = false;
|
|
},
|
|
};
|
|
|
|
const PlaySlideshow: ActionItem = {
|
|
title: $t('slideshow'),
|
|
icon: mdiPresentationPlay,
|
|
$if: () => asset.visibility !== AssetVisibility.Locked,
|
|
onAction: () => slideshowStore.slideshowState.set(SlideshowState.PlaySlideshow),
|
|
};
|
|
|
|
const Favorite: ActionItem = {
|
|
title: $t('to_favorite'),
|
|
icon: mdiHeartOutline,
|
|
$if: () => isOwner && !asset.isFavorite,
|
|
onAction: () => handleFavorite(asset),
|
|
shortcuts: [{ key: 'f' }],
|
|
};
|
|
|
|
const Unfavorite: ActionItem = {
|
|
title: $t('unfavorite'),
|
|
icon: mdiHeart,
|
|
$if: () => isOwner && asset.isFavorite,
|
|
onAction: () => handleUnfavorite(asset),
|
|
shortcuts: [{ key: 'f' }],
|
|
};
|
|
|
|
const Rate: ActionItem = {
|
|
title: $t('rate_asset'),
|
|
description: $t('rate_asset_description'),
|
|
$if: () => isOwner && authManager.preferences.ratings.enabled,
|
|
onAction: ({ event }) => handleRate(asset, event instanceof KeyboardEvent ? Number(event.key) : NaN),
|
|
shortcuts: [0, 1, 2, 3, 4, 5].map((key) => ({ key: String(key) })),
|
|
};
|
|
|
|
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 RemoveFromAlbum: ActionItem = {
|
|
title: $t('remove_from_album'),
|
|
icon: mdiImageRemoveOutline,
|
|
shortcuts: [{ key: 'l', shift: true }],
|
|
$if: () => !!album && (isOwner || isAlbumOwner),
|
|
onAction: () => handleRemoveAssetsFromAlbum([asset.id], album!),
|
|
};
|
|
|
|
const Offline: ActionItem = {
|
|
title: $t('asset_offline'),
|
|
icon: mdiAlertOutline,
|
|
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,
|
|
$if: () => asset.hasMetadata,
|
|
onAction: () => assetViewerManager.toggleDetailPanel(),
|
|
shortcuts: { key: 'i' },
|
|
};
|
|
|
|
const Tag: ActionItem = {
|
|
title: $t('add_tag'),
|
|
icon: mdiTagPlusOutline,
|
|
$if: () => authManager.authenticated && authManager.preferences.tags.enabled,
|
|
onAction: () => modalManager.show(AssetTagModal, { assetIds: [asset.id] }),
|
|
shortcuts: { key: 't' },
|
|
};
|
|
|
|
const TagPeople: ActionItem = {
|
|
title: $t('tag_people'),
|
|
icon: mdiFaceRecognition,
|
|
$if: () => isOwner && asset.type === AssetTypeEnum.Image && !asset.isTrashed,
|
|
onAction: () => assetViewerManager.toggleFaceEditMode(),
|
|
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(),
|
|
shortcuts: [{ key: 'e' }],
|
|
};
|
|
|
|
const SetProfilePicture: ActionItem = {
|
|
title: $t('set_as_profile_picture'),
|
|
icon: mdiAccountCircleOutline,
|
|
$if: () => asset.type === AssetTypeEnum.Image && asset.visibility !== AssetVisibility.Locked,
|
|
onAction: () => modalManager.show(ProfileImageCropperModal, { asset }),
|
|
};
|
|
|
|
const ViewInTimeline: ActionItem = {
|
|
title: $t('view_in_timeline'),
|
|
icon: mdiImageSearch,
|
|
$if: () => isOwner && asset.visibility !== AssetVisibility.Locked && !asset.isArchived && !asset.isTrashed,
|
|
onAction: () => goto(Route.photos({ at: asset.stackPrimaryAssetId ?? asset.id })),
|
|
};
|
|
|
|
const ViewSimilar: ActionItem = {
|
|
title: $t('view_similar_photos'),
|
|
icon: mdiCompare,
|
|
$if: () =>
|
|
asset.visibility !== AssetVisibility.Locked && !asset.isArchived && !asset.isTrashed && smartSearchEnabled,
|
|
onAction: () => goto(Route.search({ queryAssetId: asset.stackPrimaryAssetId ?? asset.id })),
|
|
};
|
|
|
|
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,
|
|
Rate,
|
|
PlayMotionPhoto,
|
|
StopMotionPhoto,
|
|
PlaySlideshow,
|
|
AddToAlbum,
|
|
RemoveFromAlbum,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
Copy,
|
|
Tag,
|
|
TagPeople,
|
|
Edit,
|
|
SetProfilePicture,
|
|
ViewInTimeline,
|
|
ViewSimilar,
|
|
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,
|
|
cacheKey: asset.thumbhash,
|
|
},
|
|
];
|
|
|
|
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) ||
|
|
(authManager.authenticated && authManager.preferences.download.includeEmbeddedVideos)
|
|
) {
|
|
const motionFilename = motionAsset.originalFileName;
|
|
const lastDotIndex = motionFilename.lastIndexOf('.');
|
|
const motionDownloadFilename =
|
|
lastDotIndex > 0
|
|
? `${motionFilename.slice(0, lastDotIndex)}-motion${motionFilename.slice(lastDotIndex)}`
|
|
: `${motionFilename}-motion`;
|
|
assets.push({
|
|
filename: motionDownloadFilename,
|
|
id: asset.livePhotoVideoId,
|
|
cacheKey: motionAsset.thumbhash,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
|
|
if (i !== 0) {
|
|
// play nice with Safari
|
|
await sleep(500);
|
|
}
|
|
|
|
try {
|
|
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
|
|
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), 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 handleRate = async (asset: AssetResponseDto, rating: number) => {
|
|
const $t = await getFormatter();
|
|
|
|
if (Number.isNaN(rating)) {
|
|
toastManager.info($t('rate_asset_description'));
|
|
return;
|
|
}
|
|
|
|
const newRating = rating === 0 ? null : rating;
|
|
if (asset.exifInfo && asset.exifInfo.rating === newRating) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await updateAsset({ id: asset.id, updateAssetDto: { rating: newRating } });
|
|
eventManager.emit('AssetUpdate', response);
|
|
} catch (error) {
|
|
handleError(error, $t('errors.unable_to_set_rating'));
|
|
}
|
|
};
|
|
|
|
export const handleTagAssets = async (assetIds: string[], tagIds: string[]) => {
|
|
const $t = await getFormatter();
|
|
|
|
try {
|
|
const response = await bulkTagAssets({ tagBulkAssetsDto: { assetIds, tagIds } });
|
|
toastManager.primary($t('tagged_assets', { values: { count: response.count } }));
|
|
eventManager.emit('AssetsTag', assetIds);
|
|
return true;
|
|
} catch (error) {
|
|
handleError(error, $t('errors.failed_to_tag_assets'));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleBulkRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => {
|
|
const $t = await getFormatter();
|
|
|
|
const isConfirmed = await modalManager.showDialog({
|
|
prompt: $t('remove_assets_album_confirmation', { values: { count: assetIds.length } }),
|
|
});
|
|
|
|
if (!isConfirmed) {
|
|
return;
|
|
}
|
|
|
|
await handleRemoveAssetsFromAlbum(assetIds, album);
|
|
assetMultiSelectManager.clear();
|
|
};
|
|
|
|
const handleRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => {
|
|
const $t = await getFormatter();
|
|
|
|
try {
|
|
const results = await removeAssetFromAlbum({
|
|
id: album.id,
|
|
bulkIdsDto: { ids: assetIds },
|
|
});
|
|
|
|
const count = results.filter(({ success }) => success).length;
|
|
|
|
toastManager.primary($t('assets_removed_count', { values: { count } }));
|
|
eventManager.emit('AlbumRemoveAssets', { assetIds, albumIds: [album.id] });
|
|
} catch (error) {
|
|
handleError(error, $t('errors.error_removing_assets_from_album'));
|
|
}
|
|
};
|
|
|
|
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'));
|
|
}
|
|
};
|