Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): unlink live photos #12574

Merged
merged 1 commit into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions e2e/src/api/specs/asset.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>
Expand Down
6 changes: 0 additions & 6 deletions mobile/openapi/lib/model/update_asset_dto.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -12243,6 +12243,7 @@
},
"livePhotoVideoId": {
"format": "uuid",
"nullable": true,
"type": "string"
},
"longitude": {
Expand Down
2 changes: 1 addition & 1 deletion open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export type UpdateAssetDto = {
isArchived?: boolean;
isFavorite?: boolean;
latitude?: number;
livePhotoVideoId?: string;
livePhotoVideoId?: string | null;
longitude?: number;
rating?: number;
};
Expand Down
4 changes: 2 additions & 2 deletions server/src/dtos/asset.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions server/src/interfaces/event.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
Expand Down
1 change: 1 addition & 0 deletions server/src/interfaces/job.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 24 additions & 5 deletions server/src/services/asset.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -180,9 +189,11 @@ export class AssetService {
},
files: true,
});

if (!asset) {
throw new BadRequestException('Asset not found');
}

return mapAsset(asset, { auth });
}

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion server/src/services/job.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions server/src/services/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 28 additions & 1 deletion server/src/utils/asset.util.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
};
Original file line number Diff line number Diff line change
@@ -1,44 +1,75 @@
<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 type { OnLink, OnUnlink } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { AssetTypeEnum, getAssetInfo, updateAsset } from '@immich/sdk';
import { mdiLinkOff, 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 onUnlink: OnUnlink;
export let menuItem = false;
export let unlink = false;

let loading = false;

const text = $t('link_motion_video');
const icon = mdiMotionPlayOutline;
$: text = unlink ? $t('unlink_motion_video') : $t('link_motion_video');
$: icon = unlink ? mdiLinkOff : mdiMotionPlayOutline;

const { clearSelect, getOwnedAssets } = getAssetControlContext();

const onClick = () => (unlink ? handleUnlink() : handleLink());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const onClick = () => (unlink ? handleUnlink() : handleLink());
const onClick = unlink ? handleUnlink : handleLink;


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;
try {
loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
onLink({ still: stillResponse, motion });
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>

{#if menuItem}
<MenuOption {text} {icon} onClick={handleLink} />
<MenuOption {text} {icon} {onClick} />
{/if}

{#if !menuItem}
{#if loading}
<CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
{:else}
<CircleIconButton title={text} {icon} on:click={handleLink} />
<CircleIconButton title={text} {icon} on:click={onClick} />
{/if}
{/if}
3 changes: 3 additions & 0 deletions web/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion web/src/lib/utils/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 26 additions & 8 deletions web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = () => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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))}
<LinkLivePhotoAction menuItem onLink={handleLink} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
unlink={[...$selectedAssets].length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>
{/if}
<ChangeDate menuItem />
<ChangeLocation menuItem />
Expand Down
Loading