Skip to content

Commit 990a4d1

Browse files
committed
feat(web,server): manually link live photos
1 parent 0a552d2 commit 990a4d1

File tree

16 files changed

+176
-34
lines changed

16 files changed

+176
-34
lines changed

Diff for: e2e/src/api/specs/asset.e2e-spec.ts

+32
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,38 @@ describe('/asset', () => {
545545
expect(status).toEqual(200);
546546
});
547547

548+
it('should not allow linking two photos', async () => {
549+
const { status, body } = await request(app)
550+
.put(`/assets/${user1Assets[0].id}`)
551+
.set('Authorization', `Bearer ${user1.accessToken}`)
552+
.send({ livePhotoVideoId: user1Assets[1].id });
553+
554+
expect(body).toEqual(errorDto.badRequest('Live photo video must be a video'));
555+
expect(status).toEqual(400);
556+
});
557+
558+
it('should not allow linking a video owned by another user', async () => {
559+
const asset = await utils.createAsset(user2.accessToken, { assetData: { filename: 'example.mp4' } });
560+
const { status, body } = await request(app)
561+
.put(`/assets/${user1Assets[0].id}`)
562+
.set('Authorization', `Bearer ${user1.accessToken}`)
563+
.send({ livePhotoVideoId: asset.id });
564+
565+
expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user'));
566+
expect(status).toEqual(400);
567+
});
568+
569+
it('should link a motion photo', async () => {
570+
const asset = await utils.createAsset(user1.accessToken, { assetData: { filename: 'example.mp4' } });
571+
const { status, body } = await request(app)
572+
.put(`/assets/${user1Assets[0].id}`)
573+
.set('Authorization', `Bearer ${user1.accessToken}`)
574+
.send({ livePhotoVideoId: asset.id });
575+
576+
expect(status).toEqual(200);
577+
expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id });
578+
});
579+
548580
it('should update date time original when sidecar file contains DateTimeOriginal', async () => {
549581
const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
550582
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>

Diff for: mobile/openapi/lib/model/update_asset_dto.dart

+18-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: open-api/immich-openapi-specs.json

+4
Original file line numberDiff line numberDiff line change
@@ -12238,6 +12238,10 @@
1223812238
"latitude": {
1223912239
"type": "number"
1224012240
},
12241+
"livePhotoVideoId": {
12242+
"format": "uuid",
12243+
"type": "string"
12244+
},
1224112245
"longitude": {
1224212246
"type": "number"
1224312247
},

Diff for: open-api/typescript-sdk/src/fetch-client.ts

+1
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ export type UpdateAssetDto = {
426426
isArchived?: boolean;
427427
isFavorite?: boolean;
428428
latitude?: number;
429+
livePhotoVideoId?: string;
429430
longitude?: number;
430431
rating?: number;
431432
};

Diff for: server/src/dtos/asset.dto.ts

+3
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase {
6868
@Optional()
6969
@IsString()
7070
description?: string;
71+
72+
@ValidateUUID({ optional: true })
73+
livePhotoVideoId?: string;
7174
}
7275

7376
export class RandomAssetsDto {

Diff for: server/src/interfaces/event.interface.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ type EmitEventMap = {
1717
'album.update': [{ id: string; updatedBy: string }];
1818
'album.invite': [{ id: string; userId: string }];
1919

20-
// tag events
20+
// asset events
2121
'asset.tag': [{ assetId: string }];
2222
'asset.untag': [{ assetId: string }];
23+
'asset.hide': [{ assetId: string; userId: string }];
2324

2425
// session events
2526
'session.delete': [{ sessionId: string }];

Diff for: server/src/services/asset-media.service.ts

+5-15
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
3636
import { IStorageRepository } from 'src/interfaces/storage.interface';
3737
import { IUserRepository } from 'src/interfaces/user.interface';
3838
import { requireAccess, requireUploadAccess } from 'src/utils/access';
39-
import { getAssetFiles } from 'src/utils/asset.util';
39+
import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
4040
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
4141
import { mimeTypes } from 'src/utils/mime-types';
4242
import { fromChecksum } from 'src/utils/request';
@@ -158,20 +158,10 @@ export class AssetMediaService {
158158
this.requireQuota(auth, file.size);
159159

160160
if (dto.livePhotoVideoId) {
161-
const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId);
162-
if (!motionAsset) {
163-
throw new BadRequestException('Live photo video not found');
164-
}
165-
if (motionAsset.type !== AssetType.VIDEO) {
166-
throw new BadRequestException('Live photo video must be a video');
167-
}
168-
if (motionAsset.ownerId !== auth.user.id) {
169-
throw new BadRequestException('Live photo video does not belong to the user');
170-
}
171-
if (motionAsset.isVisible) {
172-
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
173-
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id);
174-
}
161+
await onBeforeLink(
162+
{ asset: this.assetRepository, event: this.eventRepository },
163+
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
164+
);
175165
}
176166

177167
const asset = await this.create(auth.user.id, dto, file, sidecarFile);

Diff for: server/src/services/asset.service.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
3939
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
4040
import { IUserRepository } from 'src/interfaces/user.interface';
4141
import { requireAccess } from 'src/utils/access';
42-
import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util';
42+
import { getAssetFiles, getMyPartnerIds, onBeforeLink } from 'src/utils/asset.util';
4343
import { usePagination } from 'src/utils/pagination';
4444

4545
export class AssetService {
@@ -159,6 +159,14 @@ export class AssetService {
159159
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
160160

161161
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
162+
163+
if (rest.livePhotoVideoId) {
164+
await onBeforeLink(
165+
{ asset: this.assetRepository, event: this.eventRepository },
166+
{ userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId },
167+
);
168+
}
169+
162170
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
163171

164172
await this.assetRepository.update({ id, ...rest });

Diff for: server/src/services/metadata.service.spec.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,10 @@ describe(MetadataService.name, () => {
220220
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
221221
JobStatus.SUCCESS,
222222
);
223-
expect(eventMock.clientSend).toHaveBeenCalledWith(
224-
ClientEvent.ASSET_HIDDEN,
225-
assetStub.livePhotoMotionAsset.ownerId,
226-
assetStub.livePhotoMotionAsset.id,
227-
);
223+
expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', {
224+
userId: assetStub.livePhotoMotionAsset.ownerId,
225+
assetId: assetStub.livePhotoMotionAsset.id,
226+
});
228227
});
229228

230229
it('should search by libraryId', async () => {

Diff for: server/src/services/metadata.service.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
1717
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
1818
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
1919
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
20-
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
20+
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
2121
import {
2222
IBaseJob,
2323
IEntityJob,
@@ -186,8 +186,7 @@ export class MetadataService {
186186
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
187187
await this.albumRepository.removeAsset(motionAsset.id);
188188

189-
// Notify clients to hide the linked live photo asset
190-
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
189+
await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
191190

192191
return JobStatus.SUCCESS;
193192
}

Diff for: server/src/services/notification.service.ts

+6
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export class NotificationService {
5858
}
5959
}
6060

61+
@OnEmit({ event: 'asset.hide' })
62+
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
63+
// Notify clients to hide the linked live photo asset
64+
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
65+
}
66+
6167
@OnEmit({ event: 'user.signup' })
6268
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
6369
if (notify) {

Diff for: server/src/utils/asset.util.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { BadRequestException } from '@nestjs/common';
12
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
23
import { AuthDto } from 'src/dtos/auth.dto';
34
import { AssetFileEntity } from 'src/entities/asset-files.entity';
4-
import { AssetFileType, Permission } from 'src/enum';
5+
import { AssetFileType, AssetType, Permission } from 'src/enum';
56
import { IAccessRepository } from 'src/interfaces/access.interface';
7+
import { IAssetRepository } from 'src/interfaces/asset.interface';
8+
import { IEventRepository } from 'src/interfaces/event.interface';
69
import { IPartnerRepository } from 'src/interfaces/partner.interface';
710
import { checkAccess } from 'src/utils/access';
811

@@ -130,3 +133,24 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
130133

131134
return [...partnerIds];
132135
};
136+
137+
export const onBeforeLink = async (
138+
{ asset: assetRepository, event: eventRepository }: { asset: IAssetRepository; event: IEventRepository },
139+
{ userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string },
140+
) => {
141+
const motionAsset = await assetRepository.getById(livePhotoVideoId);
142+
if (!motionAsset) {
143+
throw new BadRequestException('Live photo video not found');
144+
}
145+
if (motionAsset.type !== AssetType.VIDEO) {
146+
throw new BadRequestException('Live photo video must be a video');
147+
}
148+
if (motionAsset.ownerId !== userId) {
149+
throw new BadRequestException('Live photo video does not belong to the user');
150+
}
151+
152+
if (motionAsset?.isVisible) {
153+
await assetRepository.update({ id: livePhotoVideoId, isVisible: false });
154+
await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId });
155+
}
156+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script lang="ts">
2+
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
3+
import type { OnLink } from '$lib/utils/actions';
4+
import { AssetTypeEnum, updateAsset } from '@immich/sdk';
5+
import { mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
6+
import { t } from 'svelte-i18n';
7+
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
8+
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
9+
10+
export let onLink: OnLink;
11+
export let menuItem = false;
12+
13+
let loading = false;
14+
15+
const text = $t('link_motion_video');
16+
const icon = mdiMotionPlayOutline;
17+
18+
const { clearSelect, getOwnedAssets } = getAssetControlContext();
19+
20+
const handleLink = async () => {
21+
let [still, motion] = [...getOwnedAssets()];
22+
if (still.type === AssetTypeEnum.Video) {
23+
[still, motion] = [motion, still];
24+
}
25+
26+
loading = true;
27+
const response = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
28+
onLink(response);
29+
clearSelect();
30+
loading = false;
31+
};
32+
</script>
33+
34+
{#if menuItem}
35+
<MenuOption {text} {icon} onClick={handleLink} />
36+
{/if}
37+
38+
{#if !menuItem}
39+
{#if loading}
40+
<CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
41+
{:else}
42+
<CircleIconButton title={text} {icon} on:click={handleLink} />
43+
{/if}
44+
{/if}

Diff for: web/src/lib/i18n/en.json

+1
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,7 @@
784784
"library_options": "Library options",
785785
"light": "Light",
786786
"like_deleted": "Like deleted",
787+
"link_motion_video": "Link motion video",
787788
"link_options": "Link options",
788789
"link_to_oauth": "Link to OAuth",
789790
"linked_oauth_account": "Linked OAuth account",

Diff for: web/src/lib/utils/actions.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { handleError } from './handle-error';
66

77
export type OnDelete = (assetIds: string[]) => void;
88
export type OnRestore = (ids: string[]) => void;
9+
export type OnLink = (asset: AssetResponseDto) => void;
910
export type OnArchive = (ids: string[], isArchived: boolean) => void;
1011
export type OnFavorite = (ids: string[], favorite: boolean) => void;
1112
export type OnStack = (ids: string[]) => void;

0 commit comments

Comments
 (0)