From 27050af57b8bc0514fa387d5df17036c50505079 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jason@rasm.me>
Date: Tue, 10 Sep 2024 08:51:11 -0400
Subject: [PATCH] feat(web): manually link live photos (#12514)

feat(web,server): manually link live photos
---
 e2e/src/api/specs/asset.e2e-spec.ts           |  32 +++++++++++++
 .../openapi/lib/model/update_asset_dto.dart   | Bin 7498 -> 8291 bytes
 open-api/immich-openapi-specs.json            |   4 ++
 open-api/typescript-sdk/src/fetch-client.ts   |   1 +
 server/src/dtos/asset.dto.ts                  |   3 ++
 server/src/interfaces/event.interface.ts      |   3 +-
 server/src/services/asset-media.service.ts    |  20 ++------
 server/src/services/asset.service.ts          |  10 +++-
 server/src/services/metadata.service.spec.ts  |  11 ++---
 server/src/services/metadata.service.ts       |   5 +-
 server/src/services/notification.service.ts   |   6 +++
 server/src/utils/asset.util.ts                |  26 ++++++++++-
 .../actions/link-live-photo-action.svelte     |  44 ++++++++++++++++++
 web/src/lib/i18n/en.json                      |   1 +
 web/src/lib/utils/actions.ts                  |   1 +
 .../(user)/photos/[[assetId=id]]/+page.svelte |  28 +++++++----
 16 files changed, 160 insertions(+), 35 deletions(-)
 create mode 100644 web/src/lib/components/photos-page/actions/link-live-photo-action.svelte

diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts
index 7d3c3c6e59..e065e60c99 100644
--- a/e2e/src/api/specs/asset.e2e-spec.ts
+++ b/e2e/src/api/specs/asset.e2e-spec.ts
@@ -545,6 +545,38 @@ describe('/asset', () => {
       expect(status).toEqual(200);
     });
 
+    it('should not allow linking two photos', async () => {
+      const { status, body } = await request(app)
+        .put(`/assets/${user1Assets[0].id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ livePhotoVideoId: user1Assets[1].id });
+
+      expect(body).toEqual(errorDto.badRequest('Live photo video must be a video'));
+      expect(status).toEqual(400);
+    });
+
+    it('should not allow linking a video owned by another user', async () => {
+      const asset = await utils.createAsset(user2.accessToken, { assetData: { filename: 'example.mp4' } });
+      const { status, body } = await request(app)
+        .put(`/assets/${user1Assets[0].id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ livePhotoVideoId: asset.id });
+
+      expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user'));
+      expect(status).toEqual(400);
+    });
+
+    it('should link a motion photo', async () => {
+      const asset = await utils.createAsset(user1.accessToken, { assetData: { filename: 'example.mp4' } });
+      const { status, body } = await request(app)
+        .put(`/assets/${user1Assets[0].id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ livePhotoVideoId: asset.id });
+
+      expect(status).toEqual(200);
+      expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id });
+    });
+
     it('should update date time original when sidecar file contains DateTimeOriginal', async () => {
       const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
 <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>
diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart
index 391836c444bb30e6eee202f5593ba6931bceba00..6e5be5683f484d7ef974229a09e130424a5417b2 100644
GIT binary patch
delta 328
zcmX?Q_1Iy<Cr19vvebZ#{F3~z%#_r8&y>y27|(M|e#^qapMxs1xsWZHar0KLCrrwy
z5-AF{whCy9Q#N<;u3^-}qA;&CCr80v!9c+ZL(k+7d?K68_=Ol1(e&A>U>NTzV8M*;
z^vMlElA7o$)ngTG6-qKPi}lcCChH5y3!sUmOui^6x4A}m2Bz5$L=`bqZ)O*FW&;4>
CY<On?

delta 42
zcmV+_0M-BFK*~C>@&U6A0>cQiN(|%zvtSRM0kc055&^Sy5;X&}Nfws_vn?7n24-gt
AaR2}S

diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 19d6b50556..b80bb52a11 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -12241,6 +12241,10 @@
           "latitude": {
             "type": "number"
           },
+          "livePhotoVideoId": {
+            "format": "uuid",
+            "type": "string"
+          },
           "longitude": {
             "type": "number"
           },
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 2afdf08343..7cf4d48eda 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -427,6 +427,7 @@ export type UpdateAssetDto = {
     isArchived?: boolean;
     isFavorite?: boolean;
     latitude?: number;
+    livePhotoVideoId?: string;
     longitude?: number;
     rating?: number;
 };
diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts
index 5a2fdb5120..02ea2c69a9 100644
--- a/server/src/dtos/asset.dto.ts
+++ b/server/src/dtos/asset.dto.ts
@@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase {
   @Optional()
   @IsString()
   description?: string;
+
+  @ValidateUUID({ optional: true })
+  livePhotoVideoId?: string;
 }
 
 export class RandomAssetsDto {
diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts
index ec6e776f59..61233a8001 100644
--- a/server/src/interfaces/event.interface.ts
+++ b/server/src/interfaces/event.interface.ts
@@ -17,9 +17,10 @@ type EmitEventMap = {
   'album.update': [{ id: string; updatedBy: string }];
   'album.invite': [{ id: string; userId: string }];
 
-  // tag events
+  // asset events
   'asset.tag': [{ assetId: string }];
   'asset.untag': [{ assetId: string }];
+  'asset.hide': [{ assetId: string; userId: string }];
 
   // session events
   'session.delete': [{ sessionId: string }];
diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts
index 30fb878cd0..111d222c16 100644
--- a/server/src/services/asset-media.service.ts
+++ b/server/src/services/asset-media.service.ts
@@ -36,7 +36,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
 import { IStorageRepository } from 'src/interfaces/storage.interface';
 import { IUserRepository } from 'src/interfaces/user.interface';
 import { requireAccess, requireUploadAccess } from 'src/utils/access';
-import { getAssetFiles } from 'src/utils/asset.util';
+import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
 import { CacheControl, ImmichFileResponse } from 'src/utils/file';
 import { mimeTypes } from 'src/utils/mime-types';
 import { fromChecksum } from 'src/utils/request';
@@ -158,20 +158,10 @@ export class AssetMediaService {
       this.requireQuota(auth, file.size);
 
       if (dto.livePhotoVideoId) {
-        const motionAsset = await this.assetRepository.getById(dto.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 !== auth.user.id) {
-          throw new BadRequestException('Live photo video does not belong to the user');
-        }
-        if (motionAsset.isVisible) {
-          await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
-          this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id);
-        }
+        await onBeforeLink(
+          { asset: this.assetRepository, event: this.eventRepository },
+          { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
+        );
       }
 
       const asset = await this.create(auth.user.id, dto, file, sidecarFile);
diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts
index bfd3a0c4d2..ecc9a13575 100644
--- a/server/src/services/asset.service.ts
+++ b/server/src/services/asset.service.ts
@@ -39,7 +39,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
 import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
 import { IUserRepository } from 'src/interfaces/user.interface';
 import { requireAccess } from 'src/utils/access';
-import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util';
+import { getAssetFiles, getMyPartnerIds, onBeforeLink } from 'src/utils/asset.util';
 import { usePagination } from 'src/utils/pagination';
 
 export class AssetService {
@@ -159,6 +159,14 @@ export class AssetService {
     await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
 
     const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
+
+    if (rest.livePhotoVideoId) {
+      await onBeforeLink(
+        { asset: this.assetRepository, event: this.eventRepository },
+        { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId },
+      );
+    }
+
     await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
 
     await this.assetRepository.update({ id, ...rest });
diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts
index ad07c2595f..114c3db8ab 100644
--- a/server/src/services/metadata.service.spec.ts
+++ b/server/src/services/metadata.service.spec.ts
@@ -8,7 +8,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
 import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
 import { ICryptoRepository } from 'src/interfaces/crypto.interface';
 import { IDatabaseRepository } from 'src/interfaces/database.interface';
-import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
 import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
 import { ILoggerRepository } from 'src/interfaces/logger.interface';
 import { IMapRepository } from 'src/interfaces/map.interface';
@@ -220,11 +220,10 @@ describe(MetadataService.name, () => {
       await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
         JobStatus.SUCCESS,
       );
-      expect(eventMock.clientSend).toHaveBeenCalledWith(
-        ClientEvent.ASSET_HIDDEN,
-        assetStub.livePhotoMotionAsset.ownerId,
-        assetStub.livePhotoMotionAsset.id,
-      );
+      expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', {
+        userId: assetStub.livePhotoMotionAsset.ownerId,
+        assetId: assetStub.livePhotoMotionAsset.id,
+      });
     });
 
     it('should search by libraryId', async () => {
diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts
index 9a4362daca..0522c883dd 100644
--- a/server/src/services/metadata.service.ts
+++ b/server/src/services/metadata.service.ts
@@ -17,7 +17,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
 import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
 import { ICryptoRepository } from 'src/interfaces/crypto.interface';
 import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
-import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
+import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
 import {
   IBaseJob,
   IEntityJob,
@@ -186,8 +186,7 @@ export class MetadataService {
     await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
     await this.albumRepository.removeAsset(motionAsset.id);
 
-    // Notify clients to hide the linked live photo asset
-    this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
+    await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
 
     return JobStatus.SUCCESS;
   }
diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts
index d450f8dc75..b1c862dc12 100644
--- a/server/src/services/notification.service.ts
+++ b/server/src/services/notification.service.ts
@@ -58,6 +58,12 @@ export class NotificationService {
     }
   }
 
+  @OnEmit({ event: 'asset.hide' })
+  onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
+    // Notify clients to hide the linked live photo asset
+    this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
+  }
+
   @OnEmit({ event: 'user.signup' })
   async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
     if (notify) {
diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts
index 26d5f9292e..f2a03a9dcb 100644
--- a/server/src/utils/asset.util.ts
+++ b/server/src/utils/asset.util.ts
@@ -1,8 +1,11 @@
+import { BadRequestException } from '@nestjs/common';
 import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
 import { AssetFileEntity } from 'src/entities/asset-files.entity';
-import { AssetFileType, Permission } from 'src/enum';
+import { AssetFileType, AssetType, Permission } from 'src/enum';
 import { IAccessRepository } from 'src/interfaces/access.interface';
+import { IAssetRepository } from 'src/interfaces/asset.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
 import { IPartnerRepository } from 'src/interfaces/partner.interface';
 import { checkAccess } from 'src/utils/access';
 
@@ -130,3 +133,24 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
 
   return [...partnerIds];
 };
+
+export const onBeforeLink = async (
+  { asset: assetRepository, event: eventRepository }: { asset: IAssetRepository; event: IEventRepository },
+  { 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 });
+  }
+};
diff --git a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte
new file mode 100644
index 0000000000..fa33b7d5cc
--- /dev/null
+++ b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import type { OnLink } from '$lib/utils/actions';
+  import { AssetTypeEnum, updateAsset } from '@immich/sdk';
+  import { mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+  import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
+  import { getAssetControlContext } from '../asset-select-control-bar.svelte';
+
+  export let onLink: OnLink;
+  export let menuItem = false;
+
+  let loading = false;
+
+  const text = $t('link_motion_video');
+  const icon = mdiMotionPlayOutline;
+
+  const { clearSelect, getOwnedAssets } = getAssetControlContext();
+
+  const handleLink = async () => {
+    let [still, motion] = [...getOwnedAssets()];
+    if (still.type === AssetTypeEnum.Video) {
+      [still, motion] = [motion, still];
+    }
+
+    loading = true;
+    const response = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
+    onLink(response);
+    clearSelect();
+    loading = false;
+  };
+</script>
+
+{#if menuItem}
+  <MenuOption {text} {icon} onClick={handleLink} />
+{/if}
+
+{#if !menuItem}
+  {#if loading}
+    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
+  {:else}
+    <CircleIconButton title={text} {icon} on:click={handleLink} />
+  {/if}
+{/if}
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index ee6aaba358..dbd6f32fde 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -785,6 +785,7 @@
   "library_options": "Library options",
   "light": "Light",
   "like_deleted": "Like deleted",
+  "link_motion_video": "Link motion video",
   "link_options": "Link options",
   "link_to_oauth": "Link to OAuth",
   "linked_oauth_account": "Linked OAuth account",
diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts
index 75232e793d..f1772c200e 100644
--- a/web/src/lib/utils/actions.ts
+++ b/web/src/lib/utils/actions.ts
@@ -6,6 +6,7 @@ import { handleError } from './handle-error';
 
 export type OnDelete = (assetIds: string[]) => void;
 export type OnRestore = (ids: string[]) => void;
+export type OnLink = (asset: AssetResponseDto) => void;
 export type OnArchive = (ids: string[], isArchived: boolean) => void;
 export type OnFavorite = (ids: string[], favorite: boolean) => void;
 export type OnStack = (ids: string[]) => void;
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 70e74f84f1..a1131ecfbb 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -2,30 +2,32 @@
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
   import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
   import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
+  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
   import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
   import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
-  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
   import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
-  import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
+  import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte';
   import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
+  import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
+  import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
   import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
-  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
   import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
+  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
   import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
   import { AssetAction } from '$lib/constants';
   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
-  import { AssetStore } from '$lib/stores/assets.store';
-  import { openFileUploadDialog } from '$lib/utils/file-uploader';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-  import { mdiDotsVertical, mdiPlus } from '@mdi/js';
+  import { AssetStore } from '$lib/stores/assets.store';
   import { preferences, user } from '$lib/stores/user.store';
-  import { t } from 'svelte-i18n';
+  import { openFileUploadDialog } from '$lib/utils/file-uploader';
+  import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
+  import { mdiDotsVertical, mdiPlus } from '@mdi/js';
   import { onDestroy } from 'svelte';
-  import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
+  import { t } from 'svelte-i18n';
 
   let { isViewing: showAssetViewer } = assetViewingStore;
   const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@@ -51,6 +53,13 @@
     }
   };
 
+  const handleLink = (asset: AssetResponseDto) => {
+    if (asset.livePhotoVideoId) {
+      assetStore.removeAssets([asset.livePhotoVideoId]);
+    }
+    assetStore.updateAssets([asset]);
+  };
+
   onDestroy(() => {
     assetStore.destroy();
   });
@@ -78,6 +87,9 @@
           onUnstack={(assets) => assetStore.addAssets(assets)}
         />
       {/if}
+      {#if $selectedAssets.size === 2 && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Image && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Video))}
+        <LinkLivePhotoAction menuItem onLink={handleLink} />
+      {/if}
       <ChangeDate menuItem />
       <ChangeLocation menuItem />
       <ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />