diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index e065e60c993d8..e0281085cf1f0 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -577,6 +577,16 @@ describe('/asset', () => { expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id }); }); + it('should unlink a motion photo', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: null }); + + expect(status).toEqual(200); + expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: null }); + }); + it('should update date time original when sidecar file contains DateTimeOriginal', async () => { const sidecarData = ` diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 6e5be5683f484..9aa413d24221e 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -63,12 +63,6 @@ class UpdateAssetDto { /// num? latitude; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? livePhotoVideoId; /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b80bb52a11383..77c2ab127f87a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12243,6 +12243,7 @@ }, "livePhotoVideoId": { "format": "uuid", + "nullable": true, "type": "string" }, "longitude": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7cf4d48eda66b..021551d947a52 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -427,7 +427,7 @@ export type UpdateAssetDto = { isArchived?: boolean; isFavorite?: boolean; latitude?: number; - livePhotoVideoId?: string; + livePhotoVideoId?: string | null; longitude?: number; rating?: number; }; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 02ea2c69a990e..703b1ccfe3225 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -69,8 +69,8 @@ export class UpdateAssetDto extends UpdateAssetBase { @IsString() description?: string; - @ValidateUUID({ optional: true }) - livePhotoVideoId?: string; + @ValidateUUID({ optional: true, nullable: true }) + livePhotoVideoId?: string | null; } export class RandomAssetsDto { diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 61233a8001eb3..0cd0207155853 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -21,6 +21,7 @@ type EmitEventMap = { 'asset.tag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }]; 'asset.hide': [{ assetId: string; userId: string }]; + 'asset.show': [{ assetId: string; userId: string }]; // session events 'session.delete': [{ sessionId: string }]; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index bc780398eaf05..a0533fa63f9c0 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -120,6 +120,7 @@ export interface IBaseJob { export interface IEntityJob extends IBaseJob { id: string; source?: 'upload' | 'sidecar-write' | 'copy'; + notify?: boolean; } export interface IAssetDeleteJob extends IEntityJob { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index ecc9a135759da..1d8d7d05d328f 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, onBeforeLink } from 'src/utils/asset.util'; +import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; export class AssetService { @@ -159,17 +159,26 @@ export class AssetService { await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + const repos = { asset: this.assetRepository, event: this.eventRepository }; + let previousMotion: AssetEntity | null = null; if (rest.livePhotoVideoId) { - await onBeforeLink( - { asset: this.assetRepository, event: this.eventRepository }, - { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }, - ); + await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); + } else if (rest.livePhotoVideoId === null) { + const asset = await this.findOrFail(id); + if (asset.livePhotoVideoId) { + previousMotion = await onBeforeUnlink(repos, { livePhotoVideoId: asset.livePhotoVideoId }); + } } await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.assetRepository.update({ id, ...rest }); + + if (previousMotion) { + await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id }); + } + const asset = await this.assetRepository.getById(id, { exifInfo: true, owner: true, @@ -180,9 +189,11 @@ export class AssetService { }, files: true, }); + if (!asset) { throw new BadRequestException('Asset not found'); } + return mapAsset(asset, { auth }); } @@ -326,6 +337,14 @@ export class AssetService { await this.jobRepository.queueAll(jobs); } + private async findOrFail(id: string) { + const asset = await this.assetRepository.getById(id); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + return asset; + } + private async updateMetadata(dto: ISidecarWriteJob) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa84ef4f40957..58eba6245b7f9 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -289,7 +289,7 @@ export class JobService { } case JobName.GENERATE_THUMBNAIL: { - if (item.data.source !== 'upload') { + if (!(item.data.notify || item.data.source === 'upload')) { break; } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index b1c862dc1225c..01da235bf0086 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -64,6 +64,11 @@ export class NotificationService { this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); } + @OnEmit({ event: 'asset.show' }) + async onAssetShow({ assetId }: ArgOf<'asset.show'>) { + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } }); + } + @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 f2a03a9dcb159..44c291e139766 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; +import { StorageCore } from 'src/cores/storage.core'; 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'; @@ -134,8 +135,10 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P return [...partnerIds]; }; +export type AssetHookRepositories = { asset: IAssetRepository; event: IEventRepository }; + export const onBeforeLink = async ( - { asset: assetRepository, event: eventRepository }: { asset: IAssetRepository; event: IEventRepository }, + { asset: assetRepository, event: eventRepository }: AssetHookRepositories, { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, ) => { const motionAsset = await assetRepository.getById(livePhotoVideoId); @@ -154,3 +157,27 @@ export const onBeforeLink = async ( await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId }); } }; + +export const onBeforeUnlink = async ( + { asset: assetRepository }: AssetHookRepositories, + { livePhotoVideoId }: { livePhotoVideoId: string }, +) => { + const motion = await assetRepository.getById(livePhotoVideoId); + if (!motion) { + return null; + } + + if (StorageCore.isAndroidMotionPath(motion.originalPath)) { + throw new BadRequestException('Cannot unlink Android motion photos'); + } + + return motion; +}; + +export const onAfterUnlink = async ( + { asset: assetRepository, event: eventRepository }: AssetHookRepositories, + { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, +) => { + await assetRepository.update({ id: livePhotoVideoId, isVisible: true }); + await eventRepository.emit('asset.show', { assetId: livePhotoVideoId, 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 index fa33b7d5ccd11..24107b9f88c2e 100644 --- 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 @@ -1,44 +1,75 @@ {#if menuItem} - + {/if} {#if !menuItem} {#if loading} {:else} - + {/if} {/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index dbd6f32fde7c5..e27cc54d52156 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -641,6 +641,7 @@ "unable_to_get_comments_number": "Unable to get number of comments", "unable_to_get_shared_link": "Failed to get shared link", "unable_to_hide_person": "Unable to hide person", + "unable_to_link_motion_video": "Unable to link motion video", "unable_to_link_oauth_account": "Unable to link OAuth account", "unable_to_load_album": "Unable to load album", "unable_to_load_asset_activity": "Unable to load asset activity", @@ -679,6 +680,7 @@ "unable_to_submit_job": "Unable to submit job", "unable_to_trash_asset": "Unable to trash asset", "unable_to_unlink_account": "Unable to unlink account", + "unable_to_unlink_motion_video": "Unable to unlink motion video", "unable_to_update_album_cover": "Unable to update album cover", "unable_to_update_album_info": "Unable to update album info", "unable_to_update_library": "Unable to update library", @@ -1219,6 +1221,7 @@ "unknown": "Unknown", "unknown_year": "Unknown Year", "unlimited": "Unlimited", + "unlink_motion_video": "Unlink motion video", "unlink_oauth": "Unlink OAuth", "unlinked_oauth_account": "Unlinked OAuth account", "unnamed_album": "Unnamed Album", diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index f1772c200e15a..291d3926ee151 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -6,7 +6,8 @@ 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 OnLink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void; +export type OnUnlink = (assets: { still: AssetResponseDto; motion: 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 a1131ecfbb16c..4649da8205120 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -23,8 +23,9 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { preferences, user } from '$lib/stores/user.store'; + import type { OnLink, OnUnlink } from '$lib/utils/actions'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; - import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; + import { AssetTypeEnum } from '@immich/sdk'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; @@ -35,12 +36,21 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; let isAllFavorite: boolean; + let isAllOwned: boolean; let isAssetStackSelected: boolean; + let isLinkActionAvailable: boolean; $: { const selection = [...$selectedAssets]; + isAllOwned = selection.every((asset) => asset.ownerId === $user.id); isAllFavorite = selection.every((asset) => asset.isFavorite); isAssetStackSelected = selection.length === 1 && !!selection[0].stack; + const isLivePhoto = selection.length === 1 && !!selection[0].livePhotoVideoId; + const isLivePhotoCandidate = + selection.length === 2 && + selection.some((asset) => asset.type === AssetTypeEnum.Image) && + selection.some((asset) => asset.type === AssetTypeEnum.Image); + isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate); } const handleEscape = () => { @@ -53,11 +63,14 @@ } }; - const handleLink = (asset: AssetResponseDto) => { - if (asset.livePhotoVideoId) { - assetStore.removeAssets([asset.livePhotoVideoId]); - } - assetStore.updateAssets([asset]); + const handleLink: OnLink = ({ still, motion }) => { + assetStore.removeAssets([motion.id]); + assetStore.updateAssets([still]); + }; + + const handleUnlink: OnUnlink = ({ still, motion }) => { + assetStore.addAssets([motion]); + assetStore.updateAssets([still]); }; onDestroy(() => { @@ -87,8 +100,13 @@ onUnstack={(assets) => assetStore.addAssets(assets)} /> {/if} - {#if $selectedAssets.size === 2 && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Image && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Video))} - + {#if isLinkActionAvailable} + {/if}