mirror of
https://github.com/immich-app/immich.git
synced 2024-11-15 18:08:48 -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);
|
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 () => {
|
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'>
|
||||||
|
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.isArchived,
|
||||||
this.isFavorite,
|
this.isFavorite,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
|
this.livePhotoVideoId,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
this.rating,
|
this.rating,
|
||||||
});
|
});
|
||||||
@ -62,6 +63,14 @@ 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;
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
/// 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
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
@ -87,6 +96,7 @@ class UpdateAssetDto {
|
|||||||
other.isArchived == isArchived &&
|
other.isArchived == isArchived &&
|
||||||
other.isFavorite == isFavorite &&
|
other.isFavorite == isFavorite &&
|
||||||
other.latitude == latitude &&
|
other.latitude == latitude &&
|
||||||
|
other.livePhotoVideoId == livePhotoVideoId &&
|
||||||
other.longitude == longitude &&
|
other.longitude == longitude &&
|
||||||
other.rating == rating;
|
other.rating == rating;
|
||||||
|
|
||||||
@ -98,11 +108,12 @@ class UpdateAssetDto {
|
|||||||
(isArchived == null ? 0 : isArchived!.hashCode) +
|
(isArchived == null ? 0 : isArchived!.hashCode) +
|
||||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||||
(latitude == null ? 0 : latitude!.hashCode) +
|
(latitude == null ? 0 : latitude!.hashCode) +
|
||||||
|
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||||
(longitude == null ? 0 : longitude!.hashCode) +
|
(longitude == null ? 0 : longitude!.hashCode) +
|
||||||
(rating == null ? 0 : rating!.hashCode);
|
(rating == null ? 0 : rating!.hashCode);
|
||||||
|
|
||||||
@override
|
@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() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -131,6 +142,11 @@ class UpdateAssetDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'latitude'] = null;
|
// json[r'latitude'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.livePhotoVideoId != null) {
|
||||||
|
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
|
||||||
|
} else {
|
||||||
|
// json[r'livePhotoVideoId'] = null;
|
||||||
|
}
|
||||||
if (this.longitude != null) {
|
if (this.longitude != null) {
|
||||||
json[r'longitude'] = this.longitude;
|
json[r'longitude'] = this.longitude;
|
||||||
} else {
|
} else {
|
||||||
@ -157,6 +173,7 @@ class UpdateAssetDto {
|
|||||||
isArchived: mapValueOfType<bool>(json, r'isArchived'),
|
isArchived: mapValueOfType<bool>(json, r'isArchived'),
|
||||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||||
latitude: num.parse('${json[r'latitude']}'),
|
latitude: num.parse('${json[r'latitude']}'),
|
||||||
|
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||||
longitude: num.parse('${json[r'longitude']}'),
|
longitude: num.parse('${json[r'longitude']}'),
|
||||||
rating: num.parse('${json[r'rating']}'),
|
rating: num.parse('${json[r'rating']}'),
|
||||||
);
|
);
|
||||||
|
@ -12241,6 +12241,10 @@
|
|||||||
"latitude": {
|
"latitude": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"livePhotoVideoId": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"longitude": {
|
"longitude": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
@ -427,6 +427,7 @@ export type UpdateAssetDto = {
|
|||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
|
livePhotoVideoId?: string;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
};
|
};
|
||||||
|
@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@IsString()
|
@IsString()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@ValidateUUID({ optional: true })
|
||||||
|
livePhotoVideoId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RandomAssetsDto {
|
export class RandomAssetsDto {
|
||||||
|
@ -17,9 +17,10 @@ type EmitEventMap = {
|
|||||||
'album.update': [{ id: string; updatedBy: string }];
|
'album.update': [{ id: string; updatedBy: string }];
|
||||||
'album.invite': [{ id: string; userId: string }];
|
'album.invite': [{ id: string; userId: string }];
|
||||||
|
|
||||||
// tag events
|
// asset events
|
||||||
'asset.tag': [{ assetId: string }];
|
'asset.tag': [{ assetId: string }];
|
||||||
'asset.untag': [{ assetId: string }];
|
'asset.untag': [{ assetId: string }];
|
||||||
|
'asset.hide': [{ assetId: string; userId: string }];
|
||||||
|
|
||||||
// session events
|
// session events
|
||||||
'session.delete': [{ sessionId: string }];
|
'session.delete': [{ sessionId: string }];
|
||||||
|
@ -36,7 +36,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
|||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||||
import { requireAccess, requireUploadAccess } from 'src/utils/access';
|
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 { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { fromChecksum } from 'src/utils/request';
|
import { fromChecksum } from 'src/utils/request';
|
||||||
@ -158,20 +158,10 @@ export class AssetMediaService {
|
|||||||
this.requireQuota(auth, file.size);
|
this.requireQuota(auth, file.size);
|
||||||
|
|
||||||
if (dto.livePhotoVideoId) {
|
if (dto.livePhotoVideoId) {
|
||||||
const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId);
|
await onBeforeLink(
|
||||||
if (!motionAsset) {
|
{ asset: this.assetRepository, event: this.eventRepository },
|
||||||
throw new BadRequestException('Live photo video not found');
|
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
||||||
}
|
);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
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 { 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 } from 'src/utils/asset.util';
|
import { getAssetFiles, getMyPartnerIds, onBeforeLink } from 'src/utils/asset.util';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
import { usePagination } from 'src/utils/pagination';
|
||||||
|
|
||||||
export class AssetService {
|
export class AssetService {
|
||||||
@ -159,6 +159,14 @@ 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;
|
||||||
|
|
||||||
|
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.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
|
||||||
|
|
||||||
await this.assetRepository.update({ id, ...rest });
|
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 { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
import { IDatabaseRepository } from 'src/interfaces/database.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 { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IMapRepository } from 'src/interfaces/map.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(
|
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
||||||
JobStatus.SUCCESS,
|
JobStatus.SUCCESS,
|
||||||
);
|
);
|
||||||
expect(eventMock.clientSend).toHaveBeenCalledWith(
|
expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', {
|
||||||
ClientEvent.ASSET_HIDDEN,
|
userId: assetStub.livePhotoMotionAsset.ownerId,
|
||||||
assetStub.livePhotoMotionAsset.ownerId,
|
assetId: assetStub.livePhotoMotionAsset.id,
|
||||||
assetStub.livePhotoMotionAsset.id,
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should search by libraryId', async () => {
|
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 { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.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 {
|
import {
|
||||||
IBaseJob,
|
IBaseJob,
|
||||||
IEntityJob,
|
IEntityJob,
|
||||||
@ -186,8 +186,7 @@ export class MetadataService {
|
|||||||
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
||||||
await this.albumRepository.removeAsset(motionAsset.id);
|
await this.albumRepository.removeAsset(motionAsset.id);
|
||||||
|
|
||||||
// Notify clients to hide the linked live photo asset
|
await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
|
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
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' })
|
@OnEmit({ event: 'user.signup' })
|
||||||
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
|
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
|
||||||
if (notify) {
|
if (notify) {
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
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';
|
||||||
import { AssetFileType, Permission } from 'src/enum';
|
import { AssetFileType, AssetType, Permission } from 'src/enum';
|
||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
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 { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||||
import { checkAccess } from 'src/utils/access';
|
import { checkAccess } from 'src/utils/access';
|
||||||
|
|
||||||
@ -130,3 +133,24 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
|
|||||||
|
|
||||||
return [...partnerIds];
|
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",
|
"library_options": "Library options",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"like_deleted": "Like deleted",
|
"like_deleted": "Like deleted",
|
||||||
|
"link_motion_video": "Link motion video",
|
||||||
"link_options": "Link options",
|
"link_options": "Link options",
|
||||||
"link_to_oauth": "Link to OAuth",
|
"link_to_oauth": "Link to OAuth",
|
||||||
"linked_oauth_account": "Linked OAuth account",
|
"linked_oauth_account": "Linked OAuth account",
|
||||||
|
@ -6,6 +6,7 @@ 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 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;
|
||||||
|
@ -2,30 +2,32 @@
|
|||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.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 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 ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
|
||||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-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 CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
||||||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||||
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
|
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
|
||||||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-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 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 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 AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
import MemoryLane from '$lib/components/photos-page/memory-lane.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 EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
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 { 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 { 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 { onDestroy } from 'svelte';
|
||||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
|
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(() => {
|
onDestroy(() => {
|
||||||
assetStore.destroy();
|
assetStore.destroy();
|
||||||
});
|
});
|
||||||
@ -78,6 +87,9 @@
|
|||||||
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))}
|
||||||
|
<LinkLivePhotoAction menuItem onLink={handleLink} />
|
||||||
|
{/if}
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||||
|
Loading…
Reference in New Issue
Block a user