refactor(server): stacks (#11453)

* refactor: stacks

* mobile: get it built

* chore: feedback

* fix: sync and duplicates

* mobile: remove old stack reference

* chore: add primary asset id

* revert change to asset entity

* mobile: refactor mobile api

* mobile: sync stack info after creating stack

* mobile: update timeline after deleting stack

* server: update asset updatedAt when stack is deleted

* mobile: simplify action

* mobile: rename to match dto property

* fix: web test

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2024-08-19 13:37:15 -04:00 committed by GitHub
parent ca52cbace1
commit 8338657eaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 2321 additions and 1152 deletions

View file

@ -1,17 +1,17 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import { unstackAssets } from '$lib/utils/asset-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { deleteStack } from '$lib/utils/asset-utils';
import type { StackResponseDto } from '@immich/sdk';
import { mdiImageMinusOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
export let stackedAssets: AssetResponseDto[];
export let stack: StackResponseDto;
export let onAction: OnAction;
const handleUnstack = async () => {
const unstackedAssets = await unstackAssets(stackedAssets);
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets });
}

View file

@ -19,7 +19,13 @@
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
AssetJobName,
AssetTypeEnum,
type AlbumResponseDto,
type AssetResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import {
mdiAlertOutline,
mdiCogRefreshOutline,
@ -37,10 +43,9 @@
export let asset: AssetResponseDto;
export let album: AlbumResponseDto | null = null;
export let stackedAssets: AssetResponseDto[];
export let stack: StackResponseDto | null = null;
export let showDetailButton: boolean;
export let showSlideshow = false;
export let hasStackChildren = false;
export let onZoomImage: () => void;
export let onCopyImage: () => void;
export let onAction: OnAction;
@ -136,8 +141,8 @@
{/if}
{#if isOwner}
{#if hasStackChildren}
<UnstackAction {stackedAssets} {onAction} />
{#if stack}
<UnstackAction {stack} {onAction} />
{/if}
{#if album}
<SetAlbumCoverAction {asset} {album} />

View file

@ -30,6 +30,8 @@
type ActivityResponseDto,
type AlbumResponseDto,
type AssetResponseDto,
getStack,
type StackResponseDto,
} from '@immich/sdk';
import { mdiImageBrokenVariant } from '@mdi/js';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
@ -74,7 +76,6 @@
}>();
let appearsInAlbums: AlbumResponseDto[] = [];
let stackedAssets: AssetResponseDto[] = [];
let shouldPlayMotionPhoto = false;
let sharedLink = getSharedLink();
let enableDetailPanel = asset.hasMetadata;
@ -92,22 +93,28 @@
$: isFullScreen = fullscreenElement !== null;
$: {
if (asset.stackCount && asset.stack) {
stackedAssets = asset.stack;
stackedAssets = [...stackedAssets, asset].sort(
(a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(),
);
let stack: StackResponseDto | null = null;
// if its a stack, add the next stack image in addition to the next asset
if (asset.stackCount > 1) {
preloadAssets.push(stackedAssets[1]);
}
const refreshStack = async () => {
if (isSharedLink()) {
return;
}
if (!stackedAssets.map((a) => a.id).includes(asset.id)) {
stackedAssets = [];
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
if (stack && stack?.assets.length > 1) {
preloadAssets.push(stack.assets[1]);
}
};
$: if (asset) {
handlePromiseError(refreshStack());
}
$: {
@ -215,15 +222,6 @@
if (!sharedLink) {
await handleGetAllAlbums();
}
if (asset.stackCount && asset.stack) {
stackedAssets = asset.stack;
stackedAssets = [...stackedAssets, asset].sort(
(a, b) => new Date(a.fileCreatedAt).getTime() - new Date(b.fileCreatedAt).getTime(),
);
} else {
stackedAssets = [];
}
});
onDestroy(() => {
@ -392,8 +390,10 @@
await handleGetAllAlbums();
break;
}
case AssetAction.UNSTACK: {
await closeViewer();
break;
}
}
@ -420,10 +420,9 @@
<AssetViewerNavBar
{asset}
{album}
{stackedAssets}
{stack}
showDetailButton={enableDetailPanel}
showSlideshow={!!assetStore}
hasStackChildren={stackedAssets.length > 0}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
onAction={handleAction}
@ -568,7 +567,8 @@
</div>
{/if}
{#if stackedAssets.length > 0 && withStacked}
{#if stack && withStacked}
{@const stackedAssets = stack.assets}
<div
id="stack-slideshow"
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"

View file

@ -170,14 +170,14 @@
<!-- Stacked asset -->
{#if asset.stackCount && showStackedIcon}
{#if asset.stack && showStackedIcon}
<div
class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == undefined
? 'top-0 right-0'
: 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white"
>
<span class="pr-2 pt-2 flex place-items-center gap-1">
<p>{asset.stackCount.toLocaleString($locale)}</p>
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon path={mdiCameraBurst} size="24" />
</span>
</div>

View file

@ -2,7 +2,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
import { stackAssets, unstackAssets } from '$lib/utils/asset-utils';
import { stackAssets, deleteStack } from '$lib/utils/asset-utils';
import type { OnStack, OnUnstack } from '$lib/utils/actions';
import { t } from 'svelte-i18n';
@ -30,8 +30,7 @@
if (!stack) {
return;
}
const assets = [selectedAssets[0], ...stack];
const unstackedAssets = await unstackAssets(assets);
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onUnstack?.(unstackedAssets);
}

View file

@ -14,7 +14,6 @@
$: isFromExternalLibrary = !!asset.libraryId;
$: assetData = JSON.stringify(asset, null, 2);
$: stackCount = asset.stackCount;
</script>
<div
@ -55,17 +54,17 @@
{isSelected ? $t('keep') : $t('to_trash')}
</div>
<!-- EXTERNAL LIBRARY / STACK COUNT CHIP-->
<!-- EXTERNAL LIBRARY / STACK COUNT CHIP -->
<div class="absolute top-2 right-3">
{#if isFromExternalLibrary}
<div class="bg-immich-primary/90 px-2 py-1 rounded-xl text-xs text-white">
{$t('external')}
</div>
{/if}
{#if stackCount != null && stackCount != 0}
{#if asset.stack?.assetCount}
<div class="bg-immich-primary/90 px-2 py-1 my-0.5 rounded-xl text-xs text-white">
<div class="flex items-center justify-center">
<div class="mr-1">{stackCount}</div>
<div class="mr-1">{asset.stack.assetCount}</div>
<Icon path={mdiImageMultipleOutline} size="18" />
</div>
</div>

View file

@ -12,9 +12,12 @@ import { createAlbum } from '$lib/utils/album-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import {
addAssetsToAlbum as addAssets,
createStack,
deleteStacks,
getAssetInfo,
getBaseUrl,
getDownloadInfo,
getStack,
updateAsset,
updateAssets,
type AlbumResponseDto,
@ -335,79 +338,60 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification =
return false;
}
const parent = assets[0];
const children = assets.slice(1);
const ids = children.map(({ id }) => id);
const $t = get(t);
try {
await updateAssets({
assetBulkUpdateDto: {
ids,
stackParentId: parent.id,
},
});
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;
}
let grandChildren: AssetResponseDto[] = [];
for (const asset of children) {
asset.stackParentId = parent.id;
if (asset.stack) {
// Add grand-children to new parent
grandChildren = grandChildren.concat(asset.stack);
// Reset children stack info
asset.stackCount = null;
asset.stack = [];
}
}
parent.stack ??= [];
parent.stack = parent.stack.concat(children, grandChildren);
parent.stackCount = parent.stack.length + 1;
if (showNotification) {
notificationController.show({
message: $t('stacked_assets_count', { values: { count: parent.stackCount } }),
type: NotificationType.Info,
button: {
text: $t('view_stack'),
onClick() {
return assetViewingStore.setAssetId(parent.id);
},
},
});
}
return ids;
};
export const unstackAssets = async (assets: AssetResponseDto[]) => {
const ids = assets.map(({ id }) => id);
const $t = get(t);
try {
await updateAssets({
assetBulkUpdateDto: {
ids,
removeParent: true,
},
});
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
export const deleteStack = async (stackIds: string[]) => {
const ids = [...new Set(stackIds)];
if (ids.length === 0) {
return;
}
for (const asset of assets) {
asset.stackParentId = null;
asset.stackCount = null;
asset.stack = [];
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'));
}
notificationController.show({
type: NotificationType.Info,
message: $t('unstacked_assets_count', { values: { count: assets.length } }),
});
return assets;
};
export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => {

View file

@ -25,5 +25,4 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
checksum: Sync.each(() => faker.string.alphanumeric(28)),
isOffline: Sync.each(() => faker.datatype.boolean()),
hasMetadata: Sync.each(() => faker.datatype.boolean()),
stackCount: null,
});