mirror of
https://github.com/immich-app/immich.git
synced 2024-11-15 09:59:00 -07:00
feat(web): manually link live photos (#12514)
feat(web,server): manually link live photos
This commit is contained in:
parent
12bfb19852
commit
27050af57b
@ -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'>
|
||||
|
19
mobile/openapi/lib/model/update_asset_dto.dart
generated
19
mobile/openapi/lib/model/update_asset_dto.dart
generated
@ -18,6 +18,7 @@ class UpdateAssetDto {
|
||||
this.isArchived,
|
||||
this.isFavorite,
|
||||
this.latitude,
|
||||
this.livePhotoVideoId,
|
||||
this.longitude,
|
||||
this.rating,
|
||||
});
|
||||
@ -62,6 +63,14 @@ 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;
|
||||
|
||||
///
|
||||
/// 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
|
||||
@ -87,6 +96,7 @@ class UpdateAssetDto {
|
||||
other.isArchived == isArchived &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.latitude == latitude &&
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.longitude == longitude &&
|
||||
other.rating == rating;
|
||||
|
||||
@ -98,11 +108,12 @@ class UpdateAssetDto {
|
||||
(isArchived == null ? 0 : isArchived!.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(latitude == null ? 0 : latitude!.hashCode) +
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||
(longitude == null ? 0 : longitude!.hashCode) +
|
||||
(rating == null ? 0 : rating!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]';
|
||||
String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -131,6 +142,11 @@ class UpdateAssetDto {
|
||||
} else {
|
||||
// json[r'latitude'] = null;
|
||||
}
|
||||
if (this.livePhotoVideoId != null) {
|
||||
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
|
||||
} else {
|
||||
// json[r'livePhotoVideoId'] = null;
|
||||
}
|
||||
if (this.longitude != null) {
|
||||
json[r'longitude'] = this.longitude;
|
||||
} else {
|
||||
@ -157,6 +173,7 @@ class UpdateAssetDto {
|
||||
isArchived: mapValueOfType<bool>(json, r'isArchived'),
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
latitude: num.parse('${json[r'latitude']}'),
|
||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||
longitude: num.parse('${json[r'longitude']}'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
);
|
||||
|
@ -12241,6 +12241,10 @@
|
||||
"latitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"livePhotoVideoId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number"
|
||||
},
|
||||
|
@ -427,6 +427,7 @@ export type UpdateAssetDto = {
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
latitude?: number;
|
||||
livePhotoVideoId?: string;
|
||||
longitude?: number;
|
||||
rating?: number;
|
||||
};
|
||||
|
@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase {
|
||||
@Optional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
livePhotoVideoId?: string;
|
||||
}
|
||||
|
||||
export class RandomAssetsDto {
|
||||
|
@ -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 }];
|
||||
|
@ -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);
|
||||
|
@ -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 });
|
||||
|
@ -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 () => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
@ -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}
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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)} />
|
||||
|
Loading…
Reference in New Issue
Block a user