1
0
mirror of https://github.com/immich-app/immich.git synced 2025-04-19 12:42:38 +02:00
immich/web/src/lib/utils/asset-utils.ts
Jonathan Jogenfors b2f2be3485
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>
2024-09-25 18:26:19 +01:00

561 lines
16 KiB
TypeScript

import { goto } from '$app/navigation';
import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte';
import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
import { downloadManager } from '$lib/stores/download';
import { preferences } from '$lib/stores/user.store';
import { downloadRequest, getKey, withError } from '$lib/utils';
import { createAlbum } from '$lib/utils/album-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import {
addAssetsToAlbum as addAssets,
createStack,
deleteStacks,
getAssetInfo,
getBaseUrl,
getDownloadInfo,
getStack,
tagAssets as tagAllAssets,
untagAssets,
updateAsset,
updateAssets,
type AlbumResponseDto,
type AssetResponseDto,
type AssetTypeEnum,
type DownloadInfoDto,
type UserPreferencesResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { handleError } from './handle-error';
export const addAssetsToAlbum = async (albumId: string, assetIds: string[], showNotification = true) => {
const result = await addAssets({
id: albumId,
bulkIdsDto: {
ids: assetIds,
},
key: getKey(),
});
const count = result.filter(({ success }) => success).length;
const $t = get(t);
if (showNotification) {
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message:
count > 0
? $t('assets_added_to_album_count', { values: { count } })
: $t('assets_were_part_of_album_count', { values: { count: assetIds.length } }),
button: {
text: $t('view_album'),
onClick() {
return goto(`${AppRoute.ALBUMS}/${albumId}`);
},
},
});
}
};
export const tagAssets = async ({
assetIds,
tagIds,
showNotification = true,
}: {
assetIds: string[];
tagIds: string[];
showNotification?: boolean;
}) => {
for (const tagId of tagIds) {
await tagAllAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
}
if (showNotification) {
const $t = await getFormatter();
notificationController.show({
message: $t('tagged_assets', { values: { count: assetIds.length } }),
type: NotificationType.Info,
});
}
return assetIds;
};
export const removeTag = async ({
assetIds,
tagIds,
showNotification = true,
}: {
assetIds: string[];
tagIds: string[];
showNotification?: boolean;
}) => {
for (const tagId of tagIds) {
await untagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
}
if (showNotification) {
const $t = await getFormatter();
notificationController.show({
message: $t('removed_tagged_assets', { values: { count: assetIds.length } }),
type: NotificationType.Info,
});
}
return assetIds;
};
export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => {
const album = await createAlbum(albumName, assetIds);
if (!album) {
return;
}
const $t = get(t);
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
component: {
type: FormatBoldMessage,
props: {
key: 'assets_added_to_name_count',
values: { count: assetIds.length, name: albumName, hasName: !!albumName },
},
},
button: {
text: $t('view_album'),
onClick() {
return goto(`${AppRoute.ALBUMS}/${album.id}`);
},
},
});
return album;
};
export const downloadAlbum = async (album: AlbumResponseDto) => {
await downloadArchive(`${album.albumName}.zip`, {
albumId: album.id,
});
};
export const downloadBlob = (data: Blob, filename: string) => {
const url = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
const $preferences = get<UserPreferencesResponseDto | undefined>(preferences);
const dto = { ...options, archiveSize: $preferences?.download.archiveSize };
const [error, downloadInfo] = await withError(() => getDownloadInfo({ downloadInfoDto: dto, key: getKey() }));
if (error) {
const $t = get(t);
handleError(error, $t('errors.unable_to_download_files'));
return;
}
if (!downloadInfo) {
return;
}
for (let index = 0; index < downloadInfo.archives.length; index++) {
const archive = downloadInfo.archives[index];
const suffix = downloadInfo.archives.length > 1 ? `+${index + 1}` : '';
const archiveName = fileName.replace('.zip', `${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}.zip`);
const key = getKey();
let downloadKey = `${archiveName} `;
if (downloadInfo.archives.length > 1) {
downloadKey = `${archiveName} (${index + 1}/${downloadInfo.archives.length})`;
}
const abort = new AbortController();
downloadManager.add(downloadKey, archive.size, abort);
try {
// TODO use sdk once it supports progress events
const { data } = await downloadRequest({
method: 'POST',
url: getBaseUrl() + '/download/archive' + (key ? `?key=${key}` : ''),
data: { assetIds: archive.assetIds },
signal: abort.signal,
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded),
});
downloadBlob(data, archiveName);
} catch (error) {
const $t = get(t);
handleError(error, $t('errors.unable_to_download_files'));
downloadManager.clear(downloadKey);
return;
} finally {
setTimeout(() => downloadManager.clear(downloadKey), 5000);
}
}
};
export const downloadFile = async (asset: AssetResponseDto) => {
const $t = get(t);
const assets = [
{
filename: asset.originalFileName,
id: asset.id,
size: asset.exifInfo?.fileSizeInByte || 0,
},
];
const isAndroidMotionVideo = (asset: AssetResponseDto) => {
return asset.originalPath.includes('encoded-video');
};
if (asset.livePhotoVideoId) {
const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() });
if (!isAndroidMotionVideo(motionAsset) || get(preferences).download.includeEmbeddedVideos) {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
size: motionAsset.exifInfo?.fileSizeInByte || 0,
});
}
}
for (const { filename, id, size } of assets) {
const downloadKey = filename;
try {
const abort = new AbortController();
downloadManager.add(downloadKey, size, abort);
const key = getKey();
notificationController.show({
type: NotificationType.Info,
message: $t('downloading_asset_filename', { values: { filename: asset.originalFileName } }),
});
// TODO use sdk once it supports progress events
const { data } = await downloadRequest({
method: 'GET',
url: getBaseUrl() + `/assets/${id}/original` + (key ? `?key=${key}` : ''),
signal: abort.signal,
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded, event.total),
});
downloadBlob(data, filename);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
downloadManager.clear(downloadKey);
} finally {
setTimeout(() => downloadManager.clear(downloadKey), 5000);
}
}
};
/**
* Returns the lowercase filename extension without a dot (.) and
* an empty string when not found.
*/
export function getFilenameExtension(filename: string): string {
const lastIndex = Math.max(0, filename.lastIndexOf('.'));
const startIndex = (lastIndex || Number.POSITIVE_INFINITY) + 1;
return filename.slice(startIndex).toLowerCase();
}
/**
* Returns the filename of an asset including file extension
*/
export function getAssetFilename(asset: AssetResponseDto): string {
const fileExtension = getFilenameExtension(asset.originalPath);
return `${asset.originalFileName}.${fileExtension}`;
}
function isRotated90CW(orientation: number) {
return orientation === 5 || orientation === 6 || orientation === 90;
}
function isRotated270CW(orientation: number) {
return orientation === 7 || orientation === 8 || orientation === -90;
}
export function isFlipped(orientation?: string | null) {
const value = Number(orientation);
return value && (isRotated270CW(value) || isRotated90CW(value));
}
export function getFileSize(asset: AssetResponseDto): string {
const size = asset.exifInfo?.fileSizeInByte || 0;
return size > 0 ? getByteUnitString(size, undefined, 4) : 'Invalid Data';
}
export function getAssetResolution(asset: AssetResponseDto): string {
const { width, height } = getAssetRatio(asset);
if (width === 235 && height === 235) {
return 'Invalid Data';
}
return `${width} x ${height}`;
}
/**
* Returns aspect ratio for the asset
*/
export function getAssetRatio(asset: AssetResponseDto) {
let height = asset.exifInfo?.exifImageHeight || 235;
let width = asset.exifInfo?.exifImageWidth || 235;
if (isFlipped(asset.exifInfo?.orientation)) {
[width, height] = [height, width];
}
return { width, height };
}
// list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg
const supportedImageMimeTypes = new Set([
'image/apng',
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
if (isSafari) {
supportedImageMimeTypes.add('image/heic').add('image/heif');
}
/**
* Returns true if the asset is an image supported by web browsers, false otherwise
*/
export function isWebCompatibleImage(asset: AssetResponseDto): boolean {
if (!asset.originalMimeType) {
return false;
}
return supportedImageMimeTypes.has(asset.originalMimeType);
}
export const getAssetType = (type: AssetTypeEnum) => {
switch (type) {
case 'IMAGE': {
return 'Photo';
}
case 'VIDEO': {
return 'Video';
}
default: {
return 'Asset';
}
}
};
export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserResponseDto | null): string[] => {
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
if (numberOfIssues > 0) {
const $t = get(t);
notificationController.show({
message: $t('errors.cant_change_metadata_assets_count', { values: { count: numberOfIssues } }),
type: NotificationType.Warning,
});
}
return ids;
};
export const stackAssets = async (assets: AssetResponseDto[], showNotification = true) => {
if (assets.length < 2) {
return false;
}
const $t = get(t);
try {
const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } });
if (showNotification) {
notificationController.show({
message: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
type: NotificationType.Info,
button: {
text: $t('view_stack'),
onClick: () => assetViewingStore.setAssetId(stack.primaryAssetId),
},
});
}
for (const [index, asset] of assets.entries()) {
asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null;
}
return assets.slice(1).map((asset) => asset.id);
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
return false;
}
};
export const deleteStack = async (stackIds: string[]) => {
const ids = [...new Set(stackIds)];
if (ids.length === 0) {
return;
}
const $t = get(t);
try {
const stacks = await Promise.all(ids.map((id) => getStack({ id })));
const count = stacks.reduce((sum, stack) => sum + stack.assets.length, 0);
await deleteStacks({ bulkIdsDto: { ids: [...ids] } });
notificationController.show({
type: NotificationType.Info,
message: $t('unstacked_assets_count', { values: { count } }),
});
const assets = stacks.flatMap((stack) => stack.assets);
for (const asset of assets) {
asset.stack = null;
}
return assets;
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => {
if (get(isSelectingAllAssets)) {
// Selection is already ongoing
return;
}
isSelectingAllAssets.set(true);
try {
for (const bucket of assetStore.buckets) {
await assetStore.loadBucket(bucket.bucketDate);
if (!get(isSelectingAllAssets)) {
break; // Cancelled
}
assetInteractionStore.selectAssets(bucket.assets);
// We use setTimeout to allow the UI to update. Otherwise, this may
// cause a long delay between the start of 'select all' and the
// effective update of the UI, depending on the number of assets
// to select
await delay(0);
}
} catch (error) {
const $t = get(t);
handleError(error, $t('errors.error_selecting_all_assets'));
isSelectingAllAssets.set(false);
}
};
export const toggleArchive = async (asset: AssetResponseDto) => {
const $t = get(t);
try {
const data = await updateAsset({
id: asset.id,
updateAssetDto: {
isArchived: !asset.isArchived,
},
});
asset.isArchived = data.isArchived;
notificationController.show({
type: NotificationType.Info,
message: asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`),
});
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_archive', { values: { archived: asset.isArchived } }));
}
return asset;
};
export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean) => {
const isArchived = archive;
const ids = assets.map(({ id }) => id);
const $t = get(t);
try {
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
}
for (const asset of assets) {
asset.isArchived = isArchived;
}
notificationController.show({
message: isArchived
? $t('archived_count', { values: { count: ids.length } })
: $t('unarchived_count', { values: { count: ids.length } }),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: isArchived } }));
}
return ids;
};
export const delay = async (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const canCopyImageToClipboard = (): boolean => {
return !!(navigator.clipboard && window.ClipboardItem);
};
const imgToBlob = async (imageElement: HTMLImageElement) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = imageElement.naturalWidth;
canvas.height = imageElement.naturalHeight;
if (context) {
context.drawImage(imageElement, 0, 0);
return await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
throw new Error('Canvas conversion to Blob failed');
}
});
});
}
throw new Error('Canvas context is null');
};
const urlToBlob = async (imageSource: string) => {
const response = await fetch(imageSource);
return await response.blob();
};
export const copyImageToClipboard = async (source: HTMLImageElement | string) => {
const blob = source instanceof HTMLImageElement ? await imgToBlob(source) : await urlToBlob(source);
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
};