feat(web): unlink live photos (#12574)

feat(web): unlink live photo
This commit is contained in:
Jason Rasmussen 2024-09-11 16:26:29 -04:00 committed by GitHub
parent 233372303b
commit 01c7adc24d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 148 additions and 37 deletions

View File

@ -577,6 +577,16 @@ describe('/asset', () => {
expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id }); 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 () => { it('should update date time original when sidecar file contains DateTimeOriginal', async () => {
const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?> const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'> <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>

View File

@ -63,12 +63,6 @@ class UpdateAssetDto {
/// ///
num? latitude; 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; String? livePhotoVideoId;
/// ///

View File

@ -12243,6 +12243,7 @@
}, },
"livePhotoVideoId": { "livePhotoVideoId": {
"format": "uuid", "format": "uuid",
"nullable": true,
"type": "string" "type": "string"
}, },
"longitude": { "longitude": {

View File

@ -427,7 +427,7 @@ export type UpdateAssetDto = {
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
latitude?: number; latitude?: number;
livePhotoVideoId?: string; livePhotoVideoId?: string | null;
longitude?: number; longitude?: number;
rating?: number; rating?: number;
}; };

View File

@ -69,8 +69,8 @@ export class UpdateAssetDto extends UpdateAssetBase {
@IsString() @IsString()
description?: string; description?: string;
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true, nullable: true })
livePhotoVideoId?: string; livePhotoVideoId?: string | null;
} }
export class RandomAssetsDto { export class RandomAssetsDto {

View File

@ -21,6 +21,7 @@ type EmitEventMap = {
'asset.tag': [{ assetId: string }]; 'asset.tag': [{ assetId: string }];
'asset.untag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }];
'asset.hide': [{ assetId: string; userId: string }]; 'asset.hide': [{ assetId: string; userId: string }];
'asset.show': [{ assetId: string; userId: string }];
// session events // session events
'session.delete': [{ sessionId: string }]; 'session.delete': [{ sessionId: string }];

View File

@ -120,6 +120,7 @@ export interface IBaseJob {
export interface IEntityJob extends IBaseJob { export interface IEntityJob extends IBaseJob {
id: string; id: string;
source?: 'upload' | 'sidecar-write' | 'copy'; source?: 'upload' | 'sidecar-write' | 'copy';
notify?: boolean;
} }
export interface IAssetDeleteJob extends IEntityJob { export interface IAssetDeleteJob extends IEntityJob {

View File

@ -39,7 +39,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess } from 'src/utils/access'; 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'; import { usePagination } from 'src/utils/pagination';
export class AssetService { export class AssetService {
@ -159,17 +159,26 @@ export class AssetService {
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository };
let previousMotion: AssetEntity | null = null;
if (rest.livePhotoVideoId) { if (rest.livePhotoVideoId) {
await onBeforeLink( await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
{ asset: this.assetRepository, event: this.eventRepository }, } else if (rest.livePhotoVideoId === null) {
{ userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }, 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.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
await this.assetRepository.update({ id, ...rest }); 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, { const asset = await this.assetRepository.getById(id, {
exifInfo: true, exifInfo: true,
owner: true, owner: true,
@ -180,9 +189,11 @@ export class AssetService {
}, },
files: true, files: true,
}); });
if (!asset) { if (!asset) {
throw new BadRequestException('Asset not found'); throw new BadRequestException('Asset not found');
} }
return mapAsset(asset, { auth }); return mapAsset(asset, { auth });
} }
@ -326,6 +337,14 @@ export class AssetService {
await this.jobRepository.queueAll(jobs); 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) { private async updateMetadata(dto: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);

View File

@ -289,7 +289,7 @@ export class JobService {
} }
case JobName.GENERATE_THUMBNAIL: { case JobName.GENERATE_THUMBNAIL: {
if (item.data.source !== 'upload') { if (!(item.data.notify || item.data.source === 'upload')) {
break; break;
} }

View File

@ -64,6 +64,11 @@ export class NotificationService {
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); 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' }) @OnEmit({ event: 'user.signup' })
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
if (notify) { if (notify) {

View File

@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { StorageCore } from 'src/cores/storage.core';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity';
@ -134,8 +135,10 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
return [...partnerIds]; return [...partnerIds];
}; };
export type AssetHookRepositories = { asset: IAssetRepository; event: IEventRepository };
export const onBeforeLink = async ( export const onBeforeLink = async (
{ asset: assetRepository, event: eventRepository }: { asset: IAssetRepository; event: IEventRepository }, { asset: assetRepository, event: eventRepository }: AssetHookRepositories,
{ userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string },
) => { ) => {
const motionAsset = await assetRepository.getById(livePhotoVideoId); const motionAsset = await assetRepository.getById(livePhotoVideoId);
@ -154,3 +157,27 @@ export const onBeforeLink = async (
await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId }); 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 });
};

View File

@ -1,44 +1,75 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import type { OnLink } from '$lib/utils/actions'; import type { OnLink, OnUnlink } from '$lib/utils/actions';
import { AssetTypeEnum, updateAsset } from '@immich/sdk'; import { handleError } from '$lib/utils/handle-error';
import { mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js'; import { AssetTypeEnum, getAssetInfo, updateAsset } from '@immich/sdk';
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onLink: OnLink; export let onLink: OnLink;
export let onUnlink: OnUnlink;
export let menuItem = false; export let menuItem = false;
export let unlink = false;
let loading = false; let loading = false;
const text = $t('link_motion_video'); $: text = unlink ? $t('unlink_motion_video') : $t('link_motion_video');
const icon = mdiMotionPlayOutline; $: icon = unlink ? mdiLinkOff : mdiMotionPlayOutline;
const { clearSelect, getOwnedAssets } = getAssetControlContext(); const { clearSelect, getOwnedAssets } = getAssetControlContext();
const onClick = () => (unlink ? handleUnlink() : handleLink());
const handleLink = async () => { const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()]; let [still, motion] = [...getOwnedAssets()];
if (still.type === AssetTypeEnum.Video) { if (still.type === AssetTypeEnum.Video) {
[still, motion] = [motion, still]; [still, motion] = [motion, still];
} }
loading = true; try {
const response = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } }); loading = true;
onLink(response); const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
clearSelect(); onLink({ still: stillResponse, motion });
loading = false; clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_link_motion_video'));
} finally {
loading = false;
}
};
const handleUnlink = async () => {
const [still] = [...getOwnedAssets()];
const motionId = still?.livePhotoVideoId;
if (!motionId) {
return;
}
try {
loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
const motionResponse = await getAssetInfo({ id: motionId });
onUnlink({ still: stillResponse, motion: motionResponse });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_unlink_motion_video'));
} finally {
loading = false;
}
}; };
</script> </script>
{#if menuItem} {#if menuItem}
<MenuOption {text} {icon} onClick={handleLink} /> <MenuOption {text} {icon} {onClick} />
{/if} {/if}
{#if !menuItem} {#if !menuItem}
{#if loading} {#if loading}
<CircleIconButton title={$t('loading')} icon={mdiTimerSand} /> <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
{:else} {:else}
<CircleIconButton title={text} {icon} on:click={handleLink} /> <CircleIconButton title={text} {icon} on:click={onClick} />
{/if} {/if}
{/if} {/if}

View File

@ -641,6 +641,7 @@
"unable_to_get_comments_number": "Unable to get number of comments", "unable_to_get_comments_number": "Unable to get number of comments",
"unable_to_get_shared_link": "Failed to get shared link", "unable_to_get_shared_link": "Failed to get shared link",
"unable_to_hide_person": "Unable to hide person", "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_link_oauth_account": "Unable to link OAuth account",
"unable_to_load_album": "Unable to load album", "unable_to_load_album": "Unable to load album",
"unable_to_load_asset_activity": "Unable to load asset activity", "unable_to_load_asset_activity": "Unable to load asset activity",
@ -679,6 +680,7 @@
"unable_to_submit_job": "Unable to submit job", "unable_to_submit_job": "Unable to submit job",
"unable_to_trash_asset": "Unable to trash asset", "unable_to_trash_asset": "Unable to trash asset",
"unable_to_unlink_account": "Unable to unlink account", "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_cover": "Unable to update album cover",
"unable_to_update_album_info": "Unable to update album info", "unable_to_update_album_info": "Unable to update album info",
"unable_to_update_library": "Unable to update library", "unable_to_update_library": "Unable to update library",
@ -1219,6 +1221,7 @@
"unknown": "Unknown", "unknown": "Unknown",
"unknown_year": "Unknown Year", "unknown_year": "Unknown Year",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"unlink_motion_video": "Unlink motion video",
"unlink_oauth": "Unlink OAuth", "unlink_oauth": "Unlink OAuth",
"unlinked_oauth_account": "Unlinked OAuth account", "unlinked_oauth_account": "Unlinked OAuth account",
"unnamed_album": "Unnamed Album", "unnamed_album": "Unnamed Album",

View File

@ -6,7 +6,8 @@ import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void; export type OnDelete = (assetIds: string[]) => void;
export type OnRestore = (ids: 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 OnArchive = (ids: string[], isArchived: boolean) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (ids: string[]) => void; export type OnStack = (ids: string[]) => void;

View File

@ -23,8 +23,9 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets.store';
import { preferences, user } from '$lib/stores/user.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 { 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 { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -35,12 +36,21 @@
const { isMultiSelectState, selectedAssets } = assetInteractionStore; const { isMultiSelectState, selectedAssets } = assetInteractionStore;
let isAllFavorite: boolean; let isAllFavorite: boolean;
let isAllOwned: boolean;
let isAssetStackSelected: boolean; let isAssetStackSelected: boolean;
let isLinkActionAvailable: boolean;
$: { $: {
const selection = [...$selectedAssets]; const selection = [...$selectedAssets];
isAllOwned = selection.every((asset) => asset.ownerId === $user.id);
isAllFavorite = selection.every((asset) => asset.isFavorite); isAllFavorite = selection.every((asset) => asset.isFavorite);
isAssetStackSelected = selection.length === 1 && !!selection[0].stack; 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 = () => { const handleEscape = () => {
@ -53,11 +63,14 @@
} }
}; };
const handleLink = (asset: AssetResponseDto) => { const handleLink: OnLink = ({ still, motion }) => {
if (asset.livePhotoVideoId) { assetStore.removeAssets([motion.id]);
assetStore.removeAssets([asset.livePhotoVideoId]); assetStore.updateAssets([still]);
} };
assetStore.updateAssets([asset]);
const handleUnlink: OnUnlink = ({ still, motion }) => {
assetStore.addAssets([motion]);
assetStore.updateAssets([still]);
}; };
onDestroy(() => { onDestroy(() => {
@ -87,8 +100,13 @@
onUnstack={(assets) => assetStore.addAssets(assets)} onUnstack={(assets) => assetStore.addAssets(assets)}
/> />
{/if} {/if}
{#if $selectedAssets.size === 2 && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Image && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Video))} {#if isLinkActionAvailable}
<LinkLivePhotoAction menuItem onLink={handleLink} /> <LinkLivePhotoAction
menuItem
unlink={[...$selectedAssets].length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>
{/if} {/if}
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />