diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 436613d4a..b45ea4137 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - name: immich-e2e services: diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 15fe3de3b..1964dc679 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -236,6 +236,32 @@ describe('/users', () => { const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); }); + + it('should require a boolean for download include embedded videos', async () => { + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { includeEmbeddedVideos: 1_234_567.89 } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value'])); + }); + + it('should update download include embedded videos', async () => { + const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } }); + + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { includeEmbeddedVideos: true } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } }); + }); }); describe('GET /users/:id', () => { diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart index 8973e17eb..25c5159a8 100644 Binary files a/mobile/openapi/lib/model/download_response.dart and b/mobile/openapi/lib/model/download_response.dart differ diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart index 162970641..2c3839a68 100644 Binary files a/mobile/openapi/lib/model/download_update.dart and b/mobile/openapi/lib/model/download_update.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index aa0d9fa2b..63d22aa4f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8497,10 +8497,15 @@ "properties": { "archiveSize": { "type": "integer" + }, + "includeEmbeddedVideos": { + "default": false, + "type": "boolean" } }, "required": [ - "archiveSize" + "archiveSize", + "includeEmbeddedVideos" ], "type": "object" }, @@ -8527,6 +8532,9 @@ "archiveSize": { "minimum": 1, "type": "integer" + }, + "includeEmbeddedVideos": { + "type": "boolean" } }, "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d270f09e5..077e802b8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -86,6 +86,7 @@ export type AvatarResponse = { }; export type DownloadResponse = { archiveSize: number; + includeEmbeddedVideos: boolean; }; export type EmailNotificationsResponse = { albumInvite: boolean; @@ -115,6 +116,7 @@ export type AvatarUpdate = { }; export type DownloadUpdate = { archiveSize?: number; + includeEmbeddedVideos?: boolean; }; export type EmailNotificationsUpdate = { albumInvite?: boolean; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index c3b2c051a..7ccf6cd78 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -33,12 +33,15 @@ class EmailNotificationsUpdate { albumUpdate?: boolean; } -class DownloadUpdate { +class DownloadUpdate implements Partial { @Optional() @IsInt() @IsPositive() @ApiProperty({ type: 'integer' }) archiveSize?: number; + + @ValidateBoolean({ optional: true }) + includeEmbeddedVideos?: boolean; } class PurchaseUpdate { @@ -104,6 +107,8 @@ class EmailNotificationsResponse { class DownloadResponse { @ApiProperty({ type: 'integer' }) archiveSize!: number; + + includeEmbeddedVideos: boolean = false; } class PurchaseResponse { diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 2dcb57093..eadcdeec5 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -35,6 +35,7 @@ export interface UserPreferences { }; download: { archiveSize: number; + includeEmbeddedVideos: boolean; }; purchase: { showSupportBadge: boolean; @@ -65,6 +66,7 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences }, download: { archiveSize: HumanReadableSize.GiB * 4, + includeEmbeddedVideos: false, }, purchase: { showSupportBadge: true, diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 2d3c11a6f..14fa7bab4 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -226,5 +226,31 @@ describe(DownloadService.name, () => { ], }); }); + + it('should skip the video portion of an android live photo by default', async () => { + const assetIds = [assetStub.livePhotoStillAsset.id]; + const assets = [ + assetStub.livePhotoStillAsset, + { ...assetStub.livePhotoMotionAsset, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, + ]; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + assetMock.getByIds.mockImplementation( + (ids) => + Promise.resolve( + ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), + ) as Promise, + ); + + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ + totalSize: 25_000, + archives: [ + { + assetIds: [assetStub.livePhotoStillAsset.id], + size: 25_000, + }, + ], + }); + }); }); }); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 157142d90..1ff9e5157 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; import { AccessCore } from 'src/cores/access.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; @@ -12,6 +13,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; +import { getPreferences } from 'src/utils/preferences'; @Injectable() export class DownloadService { @@ -32,12 +34,22 @@ export class DownloadService { const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + const preferences = getPreferences(auth.user); + const assetPagination = await this.getDownloadAssets(auth, dto); for await (const assets of assetPagination) { // motion part of live photos - const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); + const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); if (motionIds.length > 0) { - assets.push(...(await this.assetRepository.getByIds(motionIds, { exifInfo: true }))); + const motionAssets = await this.assetRepository.getByIds(motionIds, { exifInfo: true }); + for (const motionAsset of motionAssets) { + if ( + !StorageCore.isAndroidMotionPath(motionAsset.originalPath) || + preferences.download.includeEmbeddedVideos + ) { + assets.push(motionAsset); + } + } } for (const asset of assets) { diff --git a/web/src/lib/components/user-settings-page/download-settings.svelte b/web/src/lib/components/user-settings-page/download-settings.svelte index f103f348f..f5b94ebee 100644 --- a/web/src/lib/components/user-settings-page/download-settings.svelte +++ b/web/src/lib/components/user-settings-page/download-settings.svelte @@ -14,13 +14,21 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; let archiveSize = convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB); + let includeEmbeddedVideos = $preferences?.download?.includeEmbeddedVideos || false; const handleSave = async () => { try { - const dto = { download: { archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)) } }; - const newPreferences = await updateMyPreferences({ userPreferencesUpdateDto: dto }); + const newPreferences = await updateMyPreferences({ + userPreferencesUpdateDto: { + download: { + archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)), + includeEmbeddedVideos, + }, + }, + }); $preferences = newPreferences; notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info }); @@ -34,14 +42,17 @@
-
- -
+ +
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 5b2d9d393..2b97cb6e2 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -368,7 +368,7 @@ "appears_in": "Appears in", "archive": "Archive", "archive_or_unarchive_photo": "Archive or unarchive photo", - "archive_size": "Archive Size", + "archive_size": "Archive size", "archive_size_description": "Configure the archive size for downloads (in GiB)", "archived_count": "{count, plural, other {Archived #}}", "are_these_the_same_person": "Are these the same person?", @@ -512,6 +512,8 @@ "do_not_show_again": "Do not show this message again", "done": "Done", "download": "Download", + "download_include_embedded_motion_videos": "Embedded videos", + "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file", "download_settings": "Download", "download_settings_description": "Manage settings related to asset download", "downloading": "Downloading", diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index a23c36900..74a695770 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -172,13 +172,19 @@ export const downloadFile = async (asset: AssetResponseDto) => { }, ]; + const isAndroidMotionVideo = (asset: AssetResponseDto) => { + return asset.originalPath.includes('encoded-video'); + }; + if (asset.livePhotoVideoId) { const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() }); - assets.push({ - filename: motionAsset.originalFileName, - id: asset.livePhotoVideoId, - size: motionAsset.exifInfo?.fileSizeInByte || 0, - }); + if (!isAndroidMotionVideo(motionAsset) || get(preferences).download.includeEmbeddedVideos) { + assets.push({ + filename: motionAsset.originalFileName, + id: asset.livePhotoVideoId, + size: motionAsset.exifInfo?.fileSizeInByte || 0, + }); + } } for (const { filename, id, size } of assets) {