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)} />