From dca48d7722b690928cf4a3349a015969d73c05a6 Mon Sep 17 00:00:00 2001 From: Sergey Kondrikov Date: Thu, 29 Jun 2023 16:11:00 +0300 Subject: [PATCH 01/53] fix(web): aspect ratio for videos (#3023) --- web/src/lib/utils/asset-utils.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 0f6c84872..c993b97d7 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -181,6 +181,14 @@ export function getFileMimeType(file: File): string { return file.type || (mimeTypes[getFilenameExtension(file.name)] ?? ''); } +function isRotated90CW(orientation: number) { + return orientation == 6 || orientation == 90; +} + +function isRotated270CW(orientation: number) { + return orientation == 8 || orientation == -90; +} + /** * Returns aspect ratio for the asset */ @@ -189,8 +197,7 @@ export function getAssetRatio(asset: AssetResponseDto) { let width = asset.exifInfo?.exifImageWidth || 235; const orientation = Number(asset.exifInfo?.orientation); if (orientation) { - // 6 - Rotate 90 CW, 8 - Rotate 270 CW - if (orientation == 6 || orientation == 8) { + if (isRotated90CW(orientation) || isRotated270CW(orientation)) { [width, height] = [height, width]; } } From 39482470558b392425ebe34d4a7386ee89859b92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jun 2023 08:11:17 -0500 Subject: [PATCH 02/53] chore(deps): bump docker/setup-buildx-action from 2.7.0 to 2.8.0 (#3028) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.7.0 to 2.8.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v2.7.0...v2.8.0) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f22be00e2..8a448f2fc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -45,7 +45,7 @@ jobs: uses: docker/setup-qemu-action@v2.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.7.0 + uses: docker/setup-buildx-action@v2.8.0 # Workaround to fix error: # failed to push: failed to copy: io: read/write on closed pipe # See https://github.com/docker/build-push-action/issues/761 From c065705608f7f3ee54d648ca84cbb79f2cf2c726 Mon Sep 17 00:00:00 2001 From: faupau Date: Thu, 29 Jun 2023 17:11:37 +0200 Subject: [PATCH 03/53] fix(web): Share link multi-select download icon showing when not available #3006 (#3027) * only show download button if allowDownload add SelectAll to individual share * fix allow download if not share --- web/src/lib/components/album-page/album-viewer.svelte | 4 +++- .../share-page/individual-shared-viewer.svelte | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 2223fcbd2..4d327d322 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -351,7 +351,9 @@ clearSelect={() => (multiSelectAsset = new Set())} > - + {#if sharedLink?.allowDownload || !isPublicShared} + + {/if} {#if isOwned} {/if} diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 865747584..a6a50f091 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -12,6 +12,7 @@ import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte'; + import SelectAll from 'svelte-material-icons/SelectAll.svelte'; import ImmichLogo from '../shared-components/immich-logo.svelte'; import { notificationController, @@ -53,12 +54,19 @@ handleError(e, 'Unable to add assets to shared link'); } }; + + const handleSelectAll = () => { + selectedAssets = new Set(assets); + };
{#if isMultiSelectionMode} (selectedAssets = new Set())}> - + + {#if sharedLink?.allowDownload} + + {/if} {#if isOwned} {/if} From e3557fd80e43b7a11d206e8afd2654663dffc3a1 Mon Sep 17 00:00:00 2001 From: faupau Date: Thu, 29 Jun 2023 17:26:25 +0200 Subject: [PATCH 04/53] Fix(web): drag n drop shared link (#3030) * add event to trigger uploadhandler * add dragndrop store to handle upload in album-viewer and individuel-shared-viewer (only on shares) * fix handleUploadAssets no parameter * fix format --- .../components/album-page/album-viewer.svelte | 10 +++++++- .../individual-shared-viewer.svelte | 23 +++++++++++++++---- .../lib/stores/drag-and-drop-files.store.ts | 7 ++++++ .../routes/(user)/share/[key]/+page.svelte | 1 - web/src/routes/+layout.svelte | 15 ++++++++---- 5 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 web/src/lib/stores/drag-and-drop-files.store.ts diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 4d327d322..1183af14d 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -2,9 +2,10 @@ import { browser } from '$app/environment'; import { afterNavigate, goto } from '$app/navigation'; import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store'; + import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { downloadAssets } from '$lib/stores/download'; import { locale } from '$lib/stores/preferences.store'; - import { openFileUploadDialog } from '$lib/utils/file-uploader'; + import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { AlbumResponseDto, AssetResponseDto, @@ -80,6 +81,13 @@ $: isPublicShared = sharedLink; $: isOwned = currentUser?.id == album.ownerId; + dragAndDropFilesStore.subscribe((value) => { + if (value.isDragging && value.files.length > 0) { + fileUploadHandler(value.files, album.id, sharedLink?.key); + dragAndDropFilesStore.set({ isDragging: false, files: [] }); + } + }); + let multiSelectAsset: Set = new Set(); $: isMultiSelectionMode = multiSelectAsset.size > 0; diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index a6a50f091..65d0bcea5 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -1,8 +1,9 @@ @@ -76,7 +83,7 @@ {/if} - + From ff26d3666e28c47d8764c621a29116054fdc4d2a Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Thu, 29 Jun 2023 21:35:29 +0200 Subject: [PATCH 05/53] fix(mobile): set scrolling state only if changed (#3034) * fix(mobile): set scrolling state only if changed * fix: generate api --------- Co-authored-by: Alex Tran --- .../ui/asset_grid/immich_asset_grid_view.dart | 8 +++++++- .../lib/model/admin_signup_response_dto.dart | 2 +- .../openapi/lib/model/album_response_dto.dart | 6 +++--- .../lib/model/api_key_response_dto.dart | 4 ++-- .../openapi/lib/model/asset_response_dto.dart | 6 +++--- .../openapi/lib/model/exif_response_dto.dart | 18 +++++++++--------- mobile/openapi/lib/model/import_asset_dto.dart | 4 ++-- .../lib/model/shared_link_create_dto.dart | 2 +- .../lib/model/shared_link_edit_dto.dart | 2 +- .../lib/model/shared_link_response_dto.dart | 4 ++-- .../openapi/lib/model/user_response_dto.dart | 6 +++--- .../serialization/native/native_class.mustache | 12 ++++++------ 12 files changed, 40 insertions(+), 34 deletions(-) diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 30a7de669..fb7c9ddc0 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -311,7 +311,13 @@ class ImmichAssetGridViewState extends State { Widget _buildAssetGrid() { final useDragScrolling = widget.renderList.totalAssets >= 20; - void dragScrolling(bool active) => _scrolling = active; + void dragScrolling(bool active) { + if (active != _scrolling) { + setState(() { + _scrolling = active; + }); + } + } final listWidget = ScrollablePositionedList.builder( padding: const EdgeInsets.only( diff --git a/mobile/openapi/lib/model/admin_signup_response_dto.dart b/mobile/openapi/lib/model/admin_signup_response_dto.dart index 70162665a..6eabcd7c3 100644 --- a/mobile/openapi/lib/model/admin_signup_response_dto.dart +++ b/mobile/openapi/lib/model/admin_signup_response_dto.dart @@ -72,7 +72,7 @@ class AdminSignupResponseDto { email: mapValueOfType(json, r'email')!, firstName: mapValueOfType(json, r'firstName')!, lastName: mapValueOfType(json, r'lastName')!, - createdAt: mapDateTime(json, r'createdAt', '')!, + createdAt: mapDateTime(json, r'createdAt', r'')!, ); } return null; diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index 3c2df247a..dc76a6705 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -128,14 +128,14 @@ class AlbumResponseDto { id: mapValueOfType(json, r'id')!, ownerId: mapValueOfType(json, r'ownerId')!, albumName: mapValueOfType(json, r'albumName')!, - createdAt: mapDateTime(json, r'createdAt', '')!, - updatedAt: mapDateTime(json, r'updatedAt', '')!, + createdAt: mapDateTime(json, r'createdAt', r'')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), shared: mapValueOfType(json, r'shared')!, sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers']), assets: AssetResponseDto.listFromJson(json[r'assets']), owner: UserResponseDto.fromJson(json[r'owner'])!, - lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''), + lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), ); } return null; diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index 2125d92c2..5aa143414 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -64,8 +64,8 @@ class APIKeyResponseDto { return APIKeyResponseDto( id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, - createdAt: mapDateTime(json, r'createdAt', '')!, - updatedAt: mapDateTime(json, r'updatedAt', '')!, + createdAt: mapDateTime(json, r'createdAt', r'')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); } return null; diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index c068b61eb..cd74d5721 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -213,9 +213,9 @@ class AssetResponseDto { originalFileName: mapValueOfType(json, r'originalFileName')!, resized: mapValueOfType(json, r'resized')!, thumbhash: mapValueOfType(json, r'thumbhash'), - fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!, - fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!, - updatedAt: mapDateTime(json, r'updatedAt', '')!, + fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, + fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, isFavorite: mapValueOfType(json, r'isFavorite')!, isArchived: mapValueOfType(json, r'isArchived')!, mimeType: mapValueOfType(json, r'mimeType'), diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 201c13d6e..8af6e8f09 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -243,31 +243,31 @@ class ExifResponseDto { model: mapValueOfType(json, r'model'), exifImageWidth: json[r'exifImageWidth'] == null ? null - : num.parse(json[r'exifImageWidth'].toString()), + : num.parse('${json[r'exifImageWidth']}'), exifImageHeight: json[r'exifImageHeight'] == null ? null - : num.parse(json[r'exifImageHeight'].toString()), + : num.parse('${json[r'exifImageHeight']}'), orientation: mapValueOfType(json, r'orientation'), - dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''), - modifyDate: mapDateTime(json, r'modifyDate', ''), + dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), + modifyDate: mapDateTime(json, r'modifyDate', r''), timeZone: mapValueOfType(json, r'timeZone'), lensModel: mapValueOfType(json, r'lensModel'), fNumber: json[r'fNumber'] == null ? null - : num.parse(json[r'fNumber'].toString()), + : num.parse('${json[r'fNumber']}'), focalLength: json[r'focalLength'] == null ? null - : num.parse(json[r'focalLength'].toString()), + : num.parse('${json[r'focalLength']}'), iso: json[r'iso'] == null ? null - : num.parse(json[r'iso'].toString()), + : num.parse('${json[r'iso']}'), exposureTime: mapValueOfType(json, r'exposureTime'), latitude: json[r'latitude'] == null ? null - : num.parse(json[r'latitude'].toString()), + : num.parse('${json[r'latitude']}'), longitude: json[r'longitude'] == null ? null - : num.parse(json[r'longitude'].toString()), + : num.parse('${json[r'longitude']}'), city: mapValueOfType(json, r'city'), state: mapValueOfType(json, r'state'), country: mapValueOfType(json, r'country'), diff --git a/mobile/openapi/lib/model/import_asset_dto.dart b/mobile/openapi/lib/model/import_asset_dto.dart index 9a6994a10..dd67e89fb 100644 --- a/mobile/openapi/lib/model/import_asset_dto.dart +++ b/mobile/openapi/lib/model/import_asset_dto.dart @@ -156,8 +156,8 @@ class ImportAssetDto { sidecarPath: mapValueOfType(json, r'sidecarPath'), deviceAssetId: mapValueOfType(json, r'deviceAssetId')!, deviceId: mapValueOfType(json, r'deviceId')!, - fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!, - fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!, + fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, + fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, isFavorite: mapValueOfType(json, r'isFavorite')!, isArchived: mapValueOfType(json, r'isArchived'), isVisible: mapValueOfType(json, r'isVisible'), diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index cdd481c84..8c699424d 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -116,7 +116,7 @@ class SharedLinkCreateDto { : const [], albumId: mapValueOfType(json, r'albumId'), description: mapValueOfType(json, r'description'), - expiresAt: mapDateTime(json, r'expiresAt', ''), + expiresAt: mapDateTime(json, r'expiresAt', r''), allowUpload: mapValueOfType(json, r'allowUpload') ?? false, allowDownload: mapValueOfType(json, r'allowDownload') ?? true, showExif: mapValueOfType(json, r'showExif') ?? true, diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index a427b60ef..d5693058c 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -113,7 +113,7 @@ class SharedLinkEditDto { return SharedLinkEditDto( description: mapValueOfType(json, r'description'), - expiresAt: mapDateTime(json, r'expiresAt', ''), + expiresAt: mapDateTime(json, r'expiresAt', r''), allowUpload: mapValueOfType(json, r'allowUpload'), allowDownload: mapValueOfType(json, r'allowDownload'), showExif: mapValueOfType(json, r'showExif'), diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index fad2b4383..9e40fd29a 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -133,8 +133,8 @@ class SharedLinkResponseDto { description: mapValueOfType(json, r'description'), userId: mapValueOfType(json, r'userId')!, key: mapValueOfType(json, r'key')!, - createdAt: mapDateTime(json, r'createdAt', '')!, - expiresAt: mapDateTime(json, r'expiresAt', ''), + createdAt: mapDateTime(json, r'createdAt', r'')!, + expiresAt: mapDateTime(json, r'expiresAt', r''), assets: AssetResponseDto.listFromJson(json[r'assets']), album: AlbumResponseDto.fromJson(json[r'album']), allowUpload: mapValueOfType(json, r'allowUpload')!, diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index dcf7e928b..29fd788f5 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -137,9 +137,9 @@ class UserResponseDto { profileImagePath: mapValueOfType(json, r'profileImagePath')!, shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, isAdmin: mapValueOfType(json, r'isAdmin')!, - createdAt: mapDateTime(json, r'createdAt', '')!, - deletedAt: mapDateTime(json, r'deletedAt', ''), - updatedAt: mapDateTime(json, r'updatedAt', '')!, + createdAt: mapDateTime(json, r'createdAt', r'')!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + updatedAt: mapDateTime(json, r'updatedAt', r'')!, oauthId: mapValueOfType(json, r'oauthId')!, ); } diff --git a/server/openapi-generator/templates/mobile/serialization/native/native_class.mustache b/server/openapi-generator/templates/mobile/serialization/native/native_class.mustache index c6a96f29e..c66528c3f 100644 --- a/server/openapi-generator/templates/mobile/serialization/native/native_class.mustache +++ b/server/openapi-generator/templates/mobile/serialization/native/native_class.mustache @@ -66,7 +66,7 @@ class {{{classname}}} { {{/isNullable}} {{#isDateTime}} {{#pattern}} - json[r'{{{baseName}}}'] = _dateEpochMarker == '{{{pattern}}}' + json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}') ? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch : this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc().toIso8601String(); {{/pattern}} @@ -76,7 +76,7 @@ class {{{classname}}} { {{/isDateTime}} {{#isDate}} {{#pattern}} - json[r'{{{baseName}}}'] = _dateEpochMarker == '{{{pattern}}}' + json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}') ? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch : _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc()); {{/pattern}} @@ -117,10 +117,10 @@ class {{{classname}}} { return {{{classname}}}( {{#vars}} {{#isDateTime}} - {{{name}}}: mapDateTime(json, r'{{{baseName}}}', '{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, {{/isDateTime}} {{#isDate}} - {{{name}}}: mapDateTime(json, r'{{{baseName}}}', '{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, {{/isDate}} {{^isDateTime}} {{^isDate}} @@ -200,9 +200,9 @@ class {{{classname}}} { {{/isMap}} {{^isMap}} {{#isNumber}} - {{{name}}}: json[r'{{{baseName}}}'] == null + {{{name}}}: {{#isNullable}}json[r'{{{baseName}}}'] == null ? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}} - : {{{datatypeWithEnum}}}.parse(json[r'{{{baseName}}}'].toString()), + : {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'), {{/isNumber}} {{^isNumber}} {{^isEnum}} From 6fa685d9d8fe0df27e22f22ca2fa23d0893dee11 Mon Sep 17 00:00:00 2001 From: Dhrumil Shah Date: Thu, 29 Jun 2023 15:48:16 -0400 Subject: [PATCH 06/53] chore: add CLI tool to the server image (#2999) * WIP: Added immich cli tool to `immich-server` image * WIP: Added doc entry to show it is preinstalled * WIP: Moved immich upload cli to `immich` and default to `immich-admin` * WIP: undid previous commit * WIP: Updated server docs with new `immich-admin` command --- docs/docs/administration/server-commands.md | 2 +- docs/docs/features/bulk-upload.md | 5 + server/Dockerfile | 1 - server/bin/admin-cli.sh | 1 + server/bin/cli.sh | 2 +- server/package-lock.json | 258 +++++++++++++++++++- server/package.json | 6 +- 7 files changed, 269 insertions(+), 6 deletions(-) create mode 100755 server/bin/admin-cli.sh diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 872f12e65..13167b50c 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -1,6 +1,6 @@ # Server Commands -The `immich-server` docker image comes preinstalled with an administrative CLI (`immich`) that supports the following commands: +The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands: | Command | Description | | ------------------------ | ------------------------------------- | diff --git a/docs/docs/features/bulk-upload.md b/docs/docs/features/bulk-upload.md index fb7de1496..03544578f 100644 --- a/docs/docs/features/bulk-upload.md +++ b/docs/docs/features/bulk-upload.md @@ -15,6 +15,11 @@ You can use the CLI to upload an existing gallery to the Immich server npm i -g immich ``` +Pre-installed on the `immich-server` container and can be easily accessed through +``` +immich +``` + ## Quick Start Specify user's credential, Immich's server address and port and the directory you would like to upload videos/photos from. diff --git a/server/Dockerfile b/server/Dockerfile index 87511fde2..642a0992e 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -10,7 +10,6 @@ RUN npm ci COPY . . - FROM builder as prod RUN npm run build diff --git a/server/bin/admin-cli.sh b/server/bin/admin-cli.sh new file mode 100755 index 000000000..90db9957e --- /dev/null +++ b/server/bin/admin-cli.sh @@ -0,0 +1 @@ +node ./dist/main cli "$@" diff --git a/server/bin/cli.sh b/server/bin/cli.sh index 90db9957e..9f3783805 100755 --- a/server/bin/cli.sh +++ b/server/bin/cli.sh @@ -1 +1 @@ -node ./dist/main cli "$@" +node ./node_modules/immich/bin/index "$@" diff --git a/server/package-lock.json b/server/package-lock.json index 8196e8e47..bb67988fd 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -34,6 +34,7 @@ "fluent-ffmpeg": "^2.1.2", "handlebars": "^4.7.7", "i18n-iso-countries": "^7.5.0", + "immich": "^0.39.0", "ioredis": "^5.3.1", "joi": "^17.5.0", "local-reverse-geocoder": "0.12.5", @@ -54,7 +55,8 @@ "ua-parser-js": "^1.0.35" }, "bin": { - "immich": "bin/cli.sh" + "immich": "./bin/cli.sh", + "immich-admin": "./bin/admin-cli.sh" }, "devDependencies": { "@nestjs/cli": "^9.1.8", @@ -4522,6 +4524,17 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cli-spinners": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.0.tgz", @@ -6081,6 +6094,19 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-5.3.0.tgz", + "integrity": "sha512-BtE53+jaa7nNHT+gPdfU6cFAXOJUWDs2b5GFox8dtl6zLXmfNf/N6im69b9nqNNwDyl27mpIWX8qR7AafWzSdQ==", + "peerDependencies": { + "picomatch": "2.x" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -6818,6 +6844,110 @@ "node": ">= 4" } }, + "node_modules/immich": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/immich/-/immich-0.39.0.tgz", + "integrity": "sha512-FoIj/ZV7QrjuBC7F6o6YZ8jqLZDJCZwrr80CxkzERPI7qX8YrSjR1GM4ocA/9oT7p7iA+dIxT//BF5MKNPkn4g==", + "dependencies": { + "axios": "^0.26.0", + "chalk": "^2.4.1", + "cli-progress": "^3.10.0", + "commander": "^9.0.0", + "fdir": "^5.2.0", + "form-data": "^4.0.0", + "mime-types": "^2.1.34", + "p-limit": "3.1.0", + "systeminformation": "^5.11.6" + }, + "bin": { + "immich": "bin/index.js" + } + }, + "node_modules/immich/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/immich/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/immich/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/immich/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/immich/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/immich/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/immich/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/immich/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/immich/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -10618,6 +10748,31 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/systeminformation": { + "version": "5.18.6", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.18.6.tgz", + "integrity": "sha512-pLXv6kjJZ1xUcVs9SrCqbQ9y0x1rgRWxBUc8/KxpOp9IRxFGFfzVK5efsxBn/KdYog4C9rPcKk+kHNIL2SB/8Q==", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -15457,6 +15612,14 @@ } } }, + "cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "requires": { + "string-width": "^4.2.3" + } + }, "cli-spinners": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.0.tgz", @@ -16656,6 +16819,12 @@ "bser": "2.1.1" } }, + "fdir": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-5.3.0.tgz", + "integrity": "sha512-BtE53+jaa7nNHT+gPdfU6cFAXOJUWDs2b5GFox8dtl6zLXmfNf/N6im69b9nqNNwDyl27mpIWX8qR7AafWzSdQ==", + "requires": {} + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -17192,6 +17361,88 @@ "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", "dev": true }, + "immich": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/immich/-/immich-0.39.0.tgz", + "integrity": "sha512-FoIj/ZV7QrjuBC7F6o6YZ8jqLZDJCZwrr80CxkzERPI7qX8YrSjR1GM4ocA/9oT7p7iA+dIxT//BF5MKNPkn4g==", + "requires": { + "axios": "^0.26.0", + "chalk": "^2.4.1", + "cli-progress": "^3.10.0", + "commander": "^9.0.0", + "fdir": "^5.2.0", + "form-data": "^4.0.0", + "mime-types": "^2.1.34", + "p-limit": "3.1.0", + "systeminformation": "^5.11.6" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -20055,6 +20306,11 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "systeminformation": { + "version": "5.18.6", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.18.6.tgz", + "integrity": "sha512-pLXv6kjJZ1xUcVs9SrCqbQ9y0x1rgRWxBUc8/KxpOp9IRxFGFfzVK5efsxBn/KdYog4C9rPcKk+kHNIL2SB/8Q==" + }, "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", diff --git a/server/package.json b/server/package.json index efc976720..b1f12a2f9 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,8 @@ "private": true, "license": "UNLICENSED", "bin": { - "immich": "./bin/cli.sh" + "immich": "./bin/cli.sh", + "immich-admin": "./bin/admin-cli.sh" }, "scripts": { "build": "nest build", @@ -80,7 +81,8 @@ "thumbhash": "^0.1.1", "typeorm": "^0.3.11", "typesense": "^1.5.3", - "ua-parser-js": "^1.0.35" + "ua-parser-js": "^1.0.35", + "immich": "^0.39.0" }, "devDependencies": { "@nestjs/cli": "^9.1.8", From ca1b9bf7b373991f294543b7445e6ffef8bbb816 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 29 Jun 2023 14:49:23 -0500 Subject: [PATCH 07/53] fix(doc): format --- docs/docs/features/bulk-upload.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/features/bulk-upload.md b/docs/docs/features/bulk-upload.md index 03544578f..eaba3df6a 100644 --- a/docs/docs/features/bulk-upload.md +++ b/docs/docs/features/bulk-upload.md @@ -16,6 +16,7 @@ npm i -g immich ``` Pre-installed on the `immich-server` container and can be easily accessed through + ``` immich ``` From 621fa5ba549ed24893d36be9ae708cfee5e61e4d Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 29 Jun 2023 15:23:55 -0500 Subject: [PATCH 08/53] update readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dcd1c17ae..c96385b6b 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM | Multi-user support | Yes | Yes | | Album and Shared albums | Yes | Yes | | Scrubbable/draggable scrollbar | Yes | Yes | -| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes | +| Support raw formats | Yes | Yes | | Metadata view (EXIF, map) | Yes | Yes | | Search by metadata, objects, faces, and CLIP | Yes | Yes | | Administrative functions (user management) | No | Yes | @@ -84,8 +84,10 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM | Archive and Favorites | Yes | Yes | | Global Map | No | Yes | | Partner Sharing | Yes | Yes | -| Facial recognition and clustering | No | Yes | +| Facial recognition and clustering | Yes | Yes | +| Memories (x years ago) | Yes | Yes | | Offline support | Yes | No | +| Read-only gallery | Yes | Yes | # Support the project From b7ae3be3944edc2e66db13a1c8ec051703c41b42 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Thu, 29 Jun 2023 23:11:56 +0200 Subject: [PATCH 09/53] fix(mobile): rework album detail page header (#3035) --- .../modules/album/ui/album_viewer_appbar.dart | 30 +++++++++++ .../album/views/album_viewer_page.dart | 52 +++++-------------- ...ch_sliver_persistent_app_bar_delegate.dart | 38 -------------- 3 files changed, 43 insertions(+), 77 deletions(-) delete mode 100644 mobile/lib/shared/ui/immich_sliver_persistent_app_bar_delegate.dart diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 91964b5cd..f74118b3d 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -21,6 +21,8 @@ class AlbumViewerAppbar extends HookConsumerWidget required this.selected, required this.selectionDisabled, required this.titleFocusNode, + this.onAddPhotos, + this.onAddUsers, }) : super(key: key); final Album album; @@ -28,6 +30,8 @@ class AlbumViewerAppbar extends HookConsumerWidget final Set selected; final void Function() selectionDisabled; final FocusNode titleFocusNode; + final Function(Album album)? onAddPhotos; + final Function(Album album)? onAddUsers; @override Widget build(BuildContext context, WidgetRef ref) { @@ -157,6 +161,32 @@ class AlbumViewerAppbar extends HookConsumerWidget mainAxisSize: MainAxisSize.min, children: [ buildBottomSheetActionButton(), + if (selected.isEmpty && onAddPhotos != null) + ListTile( + leading: const Icon(Icons.add_photo_alternate_outlined), + onTap: () { + Navigator.pop(context); + onAddPhotos!(album); + }, + title: const Text( + "share_add_photos", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + if (selected.isEmpty && + onAddPhotos != null && + userId == album.ownerId) + ListTile( + leading: const Icon(Icons.person_add_alt_rounded), + onTap: () { + Navigator.pop(context); + onAddUsers!(album); + }, + title: const Text( + "album_viewer_page_share_add_users", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), ], ), ); diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index b1adeb821..9363c7f9b 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -18,7 +18,6 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; -import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; class AlbumViewerPage extends HookConsumerWidget { @@ -33,7 +32,6 @@ class AlbumViewerPage extends HookConsumerWidget { final userId = ref.watch(authenticationProvider).userId; final selection = useState>({}); final multiSelectEnabled = useState(false); - bool? isTop; Future onWillPop() async { if (multiSelectEnabled.value) { @@ -219,8 +217,6 @@ class AlbumViewerPage extends HookConsumerWidget { ); } - final scroll = ScrollController(); - return Scaffold( appBar: album.when( data: (data) => AlbumViewerAppbar( @@ -229,6 +225,8 @@ class AlbumViewerPage extends HookConsumerWidget { userId: userId, selected: selection.value, selectionDisabled: disableSelection, + onAddPhotos: onAddPhotosPressed, + onAddUsers: onAddUsersPressed, ), error: (error, stackTrace) => AppBar(title: const Text("Error")), loading: () => AppBar(), @@ -240,41 +238,17 @@ class AlbumViewerPage extends HookConsumerWidget { onTap: () { titleFocusNode.unfocus(); }, - child: NestedScrollView( - controller: scroll, - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverToBoxAdapter(child: buildHeader(data)), - SliverPersistentHeader( - pinned: true, - delegate: ImmichSliverPersistentAppBarDelegate( - minHeight: 50, - maxHeight: 50, - child: Container( - color: Theme.of(context).scaffoldBackgroundColor, - child: buildControlButton(data), - ), - ), - ) - ], - body: ImmichAssetGrid( - renderList: data.renderList, - listener: selectionListener, - selectionActive: multiSelectEnabled.value, - showMultiSelectIndicator: false, - visibleItemsListener: (start, end) { - final top = start.index == 0 && start.itemLeadingEdge == 0.0; - if (top != isTop) { - isTop = top; - scroll.animateTo( - top - ? scroll.position.minScrollExtent - : scroll.position.maxScrollExtent, - duration: const Duration(milliseconds: 500), - curve: top ? Curves.easeOut : Curves.easeIn, - ); - } - }, + child: ImmichAssetGrid( + renderList: data.renderList, + listener: selectionListener, + selectionActive: multiSelectEnabled.value, + showMultiSelectIndicator: false, + topWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildHeader(data), + if (data.isRemote) buildControlButton(data), + ], ), ), ), diff --git a/mobile/lib/shared/ui/immich_sliver_persistent_app_bar_delegate.dart b/mobile/lib/shared/ui/immich_sliver_persistent_app_bar_delegate.dart deleted file mode 100644 index d9c43cd35..000000000 --- a/mobile/lib/shared/ui/immich_sliver_persistent_app_bar_delegate.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -class ImmichSliverPersistentAppBarDelegate - extends SliverPersistentHeaderDelegate { - final double minHeight; - final double maxHeight; - final Widget child; - - ImmichSliverPersistentAppBarDelegate({ - required this.minHeight, - required this.maxHeight, - required this.child, - }); - - @override - double get minExtent => minHeight; - - @override - double get maxExtent => max(maxHeight, minHeight); - - @override - Widget build( - BuildContext context, - double shrinkOffset, - bool overlapsContent, - ) { - return SizedBox.expand(child: child); - } - - @override - bool shouldRebuild(ImmichSliverPersistentAppBarDelegate oldDelegate) { - return maxHeight != oldDelegate.maxHeight || - minHeight != oldDelegate.minHeight || - child != oldDelegate.child; - } -} From ca98d73d868148f73880d457220ac7911f890869 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 29 Jun 2023 16:28:18 -0500 Subject: [PATCH 10/53] chore(mobile): update flutter to 3.10.5 (#3036) --- .github/workflows/build-mobile.yml | 2 +- .github/workflows/static_analysis.yml | 2 +- .github/workflows/test.yml | 2 +- mobile/.fvm/fvm_config.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index b530c1e09..31d87a285 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -45,7 +45,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: "stable" - flutter-version: "3.10.0" + flutter-version: "3.10.5" cache: true - name: Create the Keystore diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index fb2ffff41..62a606939 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -23,7 +23,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: "stable" - flutter-version: "3.10.0" + flutter-version: "3.10.5" - name: Install dependencies run: dart pub get diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5672316cf..5bb70230e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -116,7 +116,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: "stable" - flutter-version: "3.10.0" + flutter-version: "3.10.5" - name: Run tests working-directory: ./mobile run: flutter test -j 1 diff --git a/mobile/.fvm/fvm_config.json b/mobile/.fvm/fvm_config.json index 2d144d807..0870e7648 100644 --- a/mobile/.fvm/fvm_config.json +++ b/mobile/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.10.0", + "flutterSdkVersion": "3.10.5", "flavors": {} } From b05f3fd266cb5b5282bc5e7dc6a64a9bb873ac83 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 29 Jun 2023 17:05:12 -0500 Subject: [PATCH 11/53] fix(mobile): avatar without last name (#3038) --- mobile/lib/shared/ui/user_avatar.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/lib/shared/ui/user_avatar.dart b/mobile/lib/shared/ui/user_avatar.dart index 23272870e..c736e8f90 100644 --- a/mobile/lib/shared/ui/user_avatar.dart +++ b/mobile/lib/shared/ui/user_avatar.dart @@ -6,6 +6,8 @@ import 'package:immich_mobile/shared/models/user.dart'; Widget userAvatar(BuildContext context, User u, {double? radius}) { final url = "${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${u.id}"; + final firstNameFirstLetter = u.firstName.isNotEmpty ? u.firstName[0] : ""; + final lastNameFirstLetter = u.lastName.isNotEmpty ? u.lastName[0] : ""; return CircleAvatar( radius: radius, backgroundColor: Theme.of(context).primaryColor.withAlpha(50), @@ -16,6 +18,6 @@ Widget userAvatar(BuildContext context, User u, {double? radius}) { ), // silence errors if user has no profile image, use initials as fallback onForegroundImageError: (exception, stackTrace) {}, - child: Text((u.firstName[0] + u.lastName[0]).toUpperCase()), + child: Text((firstNameFirstLetter + lastNameFirstLetter).toUpperCase()), ); } From 6c8c16c85fb4f6a7f55d4ab0c314de31c265f03b Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 29 Jun 2023 21:48:57 -0500 Subject: [PATCH 12/53] chore: update release note notes --- misc/release/notes.tmpl | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/misc/release/notes.tmpl b/misc/release/notes.tmpl index 793f2a4a5..8310a7388 100644 --- a/misc/release/notes.tmpl +++ b/misc/release/notes.tmpl @@ -7,15 +7,28 @@ As always, please consider supporting the project. 🎉 Cheer! 🎉 +## Support + +- - - - + +And as always, bugs are fixed, and many other improvements also come with this release. + +Please consider supporting the project. + ## Support

- +

-If you find the project helpful and it helps you in some ways, you can support the project [one time](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or [monthly](https://github.com/sponsors/alextran1502) from GitHub Sponsors +If you find the project helpful, you can support Immich via the following channels. + +- Monthly donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502) +- One-time donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) +- [Librepay](https://liberapay.com/alex.tran1502/) +- [buymeacoffee](https://www.buymeacoffee.com/altran1502) +- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX + It is a great way to let me know that you want me to continue developing and working on this project for years to come. - -## What's Changed From df9c05bef36bd30c6f183a9042332b318eea4bd3 Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Fri, 30 Jun 2023 03:01:48 +0000 Subject: [PATCH 13/53] Version v1.65.0 --- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- server/immich-openapi-specs.json | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/src/api/open-api/api.ts | 2 +- web/src/api/open-api/base.ts | 2 +- web/src/api/open-api/common.ts | 2 +- web/src/api/open-api/configuration.ts | 2 +- web/src/api/open-api/index.ts | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 58f3c8ef8..c7df97a64 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.64.0" +version = "1.65.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index a35ddd5f8..f14525339 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 87, - "android.injected.version.name" => "1.64.0", + "android.injected.version.code" => 88, + "android.injected.version.name" => "1.65.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 78d59fe41..815a2fef2 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.64.0" + version_number: "1.65.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9a7cc564a..91bad1615 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.64.0 +- API version: 1.65.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 30c06a624..bcceda3de 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.64.0+87 +version: 1.65.0+88 isar_version: &isar_version 3.1.0+1 environment: diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 2bb33fa94..07b8ad0fc 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4374,7 +4374,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.64.0", + "version": "1.65.0", "contact": {} }, "tags": [], diff --git a/server/package-lock.json b/server/package-lock.json index bb67988fd..6a2ecaaf2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.64.0", + "version": "1.65.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.64.0", + "version": "1.65.0", "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.20.13", diff --git a/server/package.json b/server/package.json index b1f12a2f9..4b6d1d821 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.64.0", + "version": "1.65.0", "description": "", "author": "", "private": true, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 079c3fd0d..e3b035b69 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.64.0 + * The version of the OpenAPI document: 1.65.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index b33e3b573..724f10503 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.64.0 + * The version of the OpenAPI document: 1.65.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index 0d0651499..e9e8c41f5 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.64.0 + * The version of the OpenAPI document: 1.65.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index 06041ac91..9dad93679 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.64.0 + * The version of the OpenAPI document: 1.65.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index 670109ee7..7183d73c6 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.64.0 + * The version of the OpenAPI document: 1.65.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). From ad343b7b32f2fdd36186b7e54c85e9a2d8a39b9f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 30 Jun 2023 12:24:28 -0400 Subject: [PATCH 14/53] refactor(server): download assets (#3032) * refactor: download assets * chore: open api * chore: finish tests, make size configurable * chore: defualt to 4GiB * chore: open api * fix: optional archive size * fix: bugs * chore: cleanup --- mobile/openapi/.openapi-generator/FILES | 9 +- mobile/openapi/README.md | 10 +- mobile/openapi/doc/AlbumApi.md | 62 -- mobile/openapi/doc/AssetApi.md | 244 ++++---- ...loadFilesDto.md => DownloadArchiveInfo.md} | 3 +- mobile/openapi/doc/DownloadResponseDto.md | 16 + mobile/openapi/lib/api.dart | 3 +- mobile/openapi/lib/api/album_api.dart | 70 --- mobile/openapi/lib/api/asset_api.dart | 263 +++++---- mobile/openapi/lib/api_client.dart | 6 +- ...es_dto.dart => download_archive_info.dart} | 44 +- .../lib/model/download_response_dto.dart | 106 ++++ mobile/openapi/test/album_api_test.dart | 5 - mobile/openapi/test/asset_api_test.dart | 22 +- ...t.dart => download_archive_info_test.dart} | 11 +- .../test/download_response_dto_test.dart | 32 + server/immich-openapi-specs.json | 195 +++---- server/src/domain/access/access.core.ts | 11 + server/src/domain/asset/asset.repository.ts | 2 + server/src/domain/asset/asset.service.spec.ts | 233 +++++++- server/src/domain/asset/asset.service.ts | 117 +++- server/src/domain/asset/dto/download.dto.ts | 31 + server/src/domain/asset/dto/index.ts | 1 + .../src/domain/storage/storage.repository.ts | 16 +- .../immich/api-v1/album/album.controller.ts | 21 +- .../src/immich/api-v1/album/album.module.ts | 3 +- .../immich/api-v1/album/album.service.spec.ts | 8 +- .../src/immich/api-v1/album/album.service.ts | 30 +- .../immich/api-v1/asset/asset.controller.ts | 42 +- .../src/immich/api-v1/asset/asset.module.ts | 7 +- .../immich/api-v1/asset/asset.service.spec.ts | 37 +- .../src/immich/api-v1/asset/asset.service.ts | 49 -- .../api-v1/asset/dto/download-files.dto.ts | 12 - .../api-v1/asset/dto/download-library.dto.ts | 14 - server/src/immich/app.utils.ts | 20 +- .../immich/controllers/asset.controller.ts | 40 +- .../modules/download/download.module.ts | 8 - .../modules/download/download.service.ts | 63 -- .../infra/repositories/access.repository.ts | 4 +- .../infra/repositories/asset.repository.ts | 26 + .../infra/repositories/filesystem.provider.ts | 19 +- server/test/fixtures.ts | 24 +- .../repositories/asset.repository.mock.ts | 2 + .../repositories/storage.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 551 ++++++++---------- .../components/album-page/album-viewer.svelte | 82 +-- .../asset-viewer/asset-viewer.svelte | 74 +-- .../actions/download-action.svelte | 18 +- .../individual-shared-viewer.svelte | 11 +- web/src/lib/stores/download.ts | 15 + web/src/lib/utils/asset-utils.ts | 149 +++-- web/src/lib/utils/handle-error.ts | 14 +- .../(user)/people/[personId]/+page.svelte | 2 +- 53 files changed, 1455 insertions(+), 1403 deletions(-) rename mobile/openapi/doc/{DownloadFilesDto.md => DownloadArchiveInfo.md} (86%) create mode 100644 mobile/openapi/doc/DownloadResponseDto.md rename mobile/openapi/lib/model/{download_files_dto.dart => download_archive_info.dart} (59%) create mode 100644 mobile/openapi/lib/model/download_response_dto.dart rename mobile/openapi/test/{download_files_dto_test.dart => download_archive_info_test.dart} (70%) create mode 100644 mobile/openapi/test/download_response_dto_test.dart create mode 100644 server/src/domain/asset/dto/download.dto.ts delete mode 100644 server/src/immich/api-v1/asset/dto/download-files.dto.ts delete mode 100644 server/src/immich/api-v1/asset/dto/download-library.dto.ts delete mode 100644 server/src/immich/modules/download/download.module.ts delete mode 100644 server/src/immich/modules/download/download.service.ts diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index e351e3c65..26eeb1c6b 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -45,7 +45,8 @@ doc/CuratedObjectsResponseDto.md doc/DeleteAssetDto.md doc/DeleteAssetResponseDto.md doc/DeleteAssetStatus.md -doc/DownloadFilesDto.md +doc/DownloadArchiveInfo.md +doc/DownloadResponseDto.md doc/ExifResponseDto.md doc/GetAssetByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md @@ -178,7 +179,8 @@ lib/model/curated_objects_response_dto.dart lib/model/delete_asset_dto.dart lib/model/delete_asset_response_dto.dart lib/model/delete_asset_status.dart -lib/model/download_files_dto.dart +lib/model/download_archive_info.dart +lib/model/download_response_dto.dart lib/model/exif_response_dto.dart lib/model/get_asset_by_time_bucket_dto.dart lib/model/get_asset_count_by_time_bucket_dto.dart @@ -282,7 +284,8 @@ test/curated_objects_response_dto_test.dart test/delete_asset_dto_test.dart test/delete_asset_response_dto_test.dart test/delete_asset_status_test.dart -test/download_files_dto_test.dart +test/download_archive_info_test.dart +test/download_response_dto_test.dart test/exif_response_dto_test.dart test/get_asset_by_time_bucket_dto_test.dart test/get_asset_count_by_time_bucket_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 91bad1615..606e4671f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -81,7 +81,6 @@ Class | Method | HTTP request | Description *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | *AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{id} | -*AlbumApi* | [**downloadArchive**](doc//AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | *AlbumApi* | [**getAlbumCount**](doc//AlbumApi.md#getalbumcount) | **GET** /album/count | *AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{id} | *AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album | @@ -92,9 +91,8 @@ Class | Method | HTTP request | Description *AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check | *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | *AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset | -*AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **GET** /asset/download/{id} | -*AssetApi* | [**downloadFiles**](doc//AssetApi.md#downloadfiles) | **POST** /asset/download-files | -*AssetApi* | [**downloadLibrary**](doc//AssetApi.md#downloadlibrary) | **GET** /asset/download-library | +*AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download | +*AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} | *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | *AssetApi* | [**getArchivedAssetCountByUserId**](doc//AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | @@ -105,6 +103,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | *AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | +*AssetApi* | [**getDownloadInfo**](doc//AssetApi.md#getdownloadinfo) | **GET** /asset/download | *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | @@ -215,7 +214,8 @@ Class | Method | HTTP request | Description - [DeleteAssetDto](doc//DeleteAssetDto.md) - [DeleteAssetResponseDto](doc//DeleteAssetResponseDto.md) - [DeleteAssetStatus](doc//DeleteAssetStatus.md) - - [DownloadFilesDto](doc//DownloadFilesDto.md) + - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) + - [DownloadResponseDto](doc//DownloadResponseDto.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md) - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md) diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index b4eba7916..47c418096 100644 --- a/mobile/openapi/doc/AlbumApi.md +++ b/mobile/openapi/doc/AlbumApi.md @@ -13,7 +13,6 @@ Method | HTTP request | Description [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{id} | -[**downloadArchive**](AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | [**getAlbumCount**](AlbumApi.md#getalbumcount) | **GET** /album/count | [**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{id} | [**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album | @@ -247,67 +246,6 @@ void (empty response body) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **downloadArchive** -> MultipartFile downloadArchive(id, name, skip, key) - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AlbumApi(); -final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final name = name_example; // String | -final skip = 8.14; // num | -final key = key_example; // String | - -try { - final result = api_instance.downloadArchive(id, name, skip, key); - print(result); -} catch (e) { - print('Exception when calling AlbumApi->downloadArchive: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **id** | **String**| | - **name** | **String**| | [optional] - **skip** | **num**| | [optional] - **key** | **String**| | [optional] - -### Return type - -[**MultipartFile**](MultipartFile.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/zip - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **getAlbumCount** > AlbumCountResponseDto getAlbumCount() diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index ef3610b00..319deb2ac 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -13,9 +13,8 @@ Method | HTTP request | Description [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | [**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset | -[**downloadFile**](AssetApi.md#downloadfile) | **GET** /asset/download/{id} | -[**downloadFiles**](AssetApi.md#downloadfiles) | **POST** /asset/download-files | -[**downloadLibrary**](AssetApi.md#downloadlibrary) | **GET** /asset/download-library | +[**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download | +[**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} | [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | [**getArchivedAssetCountByUserId**](AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | @@ -26,6 +25,7 @@ Method | HTTP request | Description [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | +[**getDownloadInfo**](AssetApi.md#getdownloadinfo) | **GET** /asset/download | [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | @@ -264,6 +264,63 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **downloadArchive** +> MultipartFile downloadArchive(assetIdsDto, key) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AssetApi(); +final assetIdsDto = AssetIdsDto(); // AssetIdsDto | +final key = key_example; // String | + +try { + final result = api_instance.downloadArchive(assetIdsDto, key); + print(result); +} catch (e) { + print('Exception when calling AssetApi->downloadArchive: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **assetIdsDto** | [**AssetIdsDto**](AssetIdsDto.md)| | + **key** | **String**| | [optional] + +### Return type + +[**MultipartFile**](MultipartFile.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/octet-stream + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **downloadFile** > MultipartFile downloadFile(id, key) @@ -321,124 +378,6 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **downloadFiles** -> MultipartFile downloadFiles(downloadFilesDto, key) - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AssetApi(); -final downloadFilesDto = DownloadFilesDto(); // DownloadFilesDto | -final key = key_example; // String | - -try { - final result = api_instance.downloadFiles(downloadFilesDto, key); - print(result); -} catch (e) { - print('Exception when calling AssetApi->downloadFiles: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **downloadFilesDto** | [**DownloadFilesDto**](DownloadFilesDto.md)| | - **key** | **String**| | [optional] - -### Return type - -[**MultipartFile**](MultipartFile.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/octet-stream - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **downloadLibrary** -> MultipartFile downloadLibrary(name, skip, key) - - - -Current this is not used in any UI element - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AssetApi(); -final name = name_example; // String | -final skip = 8.14; // num | -final key = key_example; // String | - -try { - final result = api_instance.downloadLibrary(name, skip, key); - print(result); -} catch (e) { - print('Exception when calling AssetApi->downloadLibrary: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **name** | **String**| | [optional] - **skip** | **num**| | [optional] - **key** | **String**| | [optional] - -### Return type - -[**MultipartFile**](MultipartFile.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/octet-stream - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **getAllAssets** > List getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch) @@ -989,6 +928,69 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getDownloadInfo** +> DownloadResponseDto getDownloadInfo(assetIds, albumId, userId, archiveSize, key) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AssetApi(); +final assetIds = []; // List | +final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final archiveSize = 8.14; // num | +final key = key_example; // String | + +try { + final result = api_instance.getDownloadInfo(assetIds, albumId, userId, archiveSize, key); + print(result); +} catch (e) { + print('Exception when calling AssetApi->getDownloadInfo: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **assetIds** | [**List**](String.md)| | [optional] [default to const []] + **albumId** | **String**| | [optional] + **userId** | **String**| | [optional] + **archiveSize** | **num**| | [optional] + **key** | **String**| | [optional] + +### Return type + +[**DownloadResponseDto**](DownloadResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **getMapMarkers** > List getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore) diff --git a/mobile/openapi/doc/DownloadFilesDto.md b/mobile/openapi/doc/DownloadArchiveInfo.md similarity index 86% rename from mobile/openapi/doc/DownloadFilesDto.md rename to mobile/openapi/doc/DownloadArchiveInfo.md index 6b44eef05..5ec8c668f 100644 --- a/mobile/openapi/doc/DownloadFilesDto.md +++ b/mobile/openapi/doc/DownloadArchiveInfo.md @@ -1,4 +1,4 @@ -# openapi.model.DownloadFilesDto +# openapi.model.DownloadArchiveInfo ## Load the model package ```dart @@ -8,6 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**size** | **int** | | **assetIds** | **List** | | [default to const []] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/DownloadResponseDto.md b/mobile/openapi/doc/DownloadResponseDto.md new file mode 100644 index 000000000..2a7bbc9b1 --- /dev/null +++ b/mobile/openapi/doc/DownloadResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.DownloadResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**totalSize** | **int** | | +**archives** | [**List**](DownloadArchiveInfo.md) | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 9363e99b1..47cfa9aa2 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -81,7 +81,8 @@ part 'model/curated_objects_response_dto.dart'; part 'model/delete_asset_dto.dart'; part 'model/delete_asset_response_dto.dart'; part 'model/delete_asset_status.dart'; -part 'model/download_files_dto.dart'; +part 'model/download_archive_info.dart'; +part 'model/download_response_dto.dart'; part 'model/exif_response_dto.dart'; part 'model/get_asset_by_time_bucket_dto.dart'; part 'model/get_asset_count_by_time_bucket_dto.dart'; diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 37490881d..1f5bd7b58 100644 --- a/mobile/openapi/lib/api/album_api.dart +++ b/mobile/openapi/lib/api/album_api.dart @@ -215,76 +215,6 @@ class AlbumApi { } } - /// Performs an HTTP 'GET /album/{id}/download' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [String] name: - /// - /// * [num] skip: - /// - /// * [String] key: - Future downloadArchiveWithHttpInfo(String id, { String? name, num? skip, String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/album/{id}/download' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (name != null) { - queryParams.addAll(_queryParams('', 'name', name)); - } - if (skip != null) { - queryParams.addAll(_queryParams('', 'skip', skip)); - } - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [String] name: - /// - /// * [num] skip: - /// - /// * [String] key: - Future downloadArchive(String id, { String? name, num? skip, String? key, }) async { - final response = await downloadArchiveWithHttpInfo(id, name: name, skip: skip, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; - - } - return null; - } - /// Performs an HTTP 'GET /album/count' operation and returns the [Response]. Future getAlbumCountWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index d8d03ca53..a73ec3b1e 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -230,7 +230,62 @@ class AssetApi { return null; } - /// Performs an HTTP 'GET /asset/download/{id}' operation and returns the [Response]. + /// Performs an HTTP 'POST /asset/download' operation and returns the [Response]. + /// Parameters: + /// + /// * [AssetIdsDto] assetIdsDto (required): + /// + /// * [String] key: + Future downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, }) async { + // ignore: prefer_const_declarations + final path = r'/asset/download'; + + // ignore: prefer_final_locals + Object? postBody = assetIdsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [AssetIdsDto] assetIdsDto (required): + /// + /// * [String] key: + Future downloadArchive(AssetIdsDto assetIdsDto, { String? key, }) async { + final response = await downloadArchiveWithHttpInfo(assetIdsDto, key: key, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; + + } + return null; + } + + /// Performs an HTTP 'POST /asset/download/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -257,7 +312,7 @@ class AssetApi { return apiClient.invokeAPI( path, - 'GET', + 'POST', queryParams, postBody, headerParams, @@ -286,131 +341,6 @@ class AssetApi { return null; } - /// Performs an HTTP 'POST /asset/download-files' operation and returns the [Response]. - /// Parameters: - /// - /// * [DownloadFilesDto] downloadFilesDto (required): - /// - /// * [String] key: - Future downloadFilesWithHttpInfo(DownloadFilesDto downloadFilesDto, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/download-files'; - - // ignore: prefer_final_locals - Object? postBody = downloadFilesDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [DownloadFilesDto] downloadFilesDto (required): - /// - /// * [String] key: - Future downloadFiles(DownloadFilesDto downloadFilesDto, { String? key, }) async { - final response = await downloadFilesWithHttpInfo(downloadFilesDto, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; - - } - return null; - } - - /// Current this is not used in any UI element - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [String] name: - /// - /// * [num] skip: - /// - /// * [String] key: - Future downloadLibraryWithHttpInfo({ String? name, num? skip, String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/download-library'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (name != null) { - queryParams.addAll(_queryParams('', 'name', name)); - } - if (skip != null) { - queryParams.addAll(_queryParams('', 'skip', skip)); - } - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Current this is not used in any UI element - /// - /// Parameters: - /// - /// * [String] name: - /// - /// * [num] skip: - /// - /// * [String] key: - Future downloadLibrary({ String? name, num? skip, String? key, }) async { - final response = await downloadLibraryWithHttpInfo( name: name, skip: skip, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; - - } - return null; - } - /// Get all AssetEntity belong to the user /// /// Note: This method returns the HTTP [Response]. @@ -945,6 +875,85 @@ class AssetApi { return null; } + /// Performs an HTTP 'GET /asset/download' operation and returns the [Response]. + /// Parameters: + /// + /// * [List] assetIds: + /// + /// * [String] albumId: + /// + /// * [String] userId: + /// + /// * [num] archiveSize: + /// + /// * [String] key: + Future getDownloadInfoWithHttpInfo({ List? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async { + // ignore: prefer_const_declarations + final path = r'/asset/download'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (assetIds != null) { + queryParams.addAll(_queryParams('multi', 'assetIds', assetIds)); + } + if (albumId != null) { + queryParams.addAll(_queryParams('', 'albumId', albumId)); + } + if (userId != null) { + queryParams.addAll(_queryParams('', 'userId', userId)); + } + if (archiveSize != null) { + queryParams.addAll(_queryParams('', 'archiveSize', archiveSize)); + } + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [List] assetIds: + /// + /// * [String] albumId: + /// + /// * [String] userId: + /// + /// * [num] archiveSize: + /// + /// * [String] key: + Future getDownloadInfo({ List? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async { + final response = await getDownloadInfoWithHttpInfo( assetIds: assetIds, albumId: albumId, userId: userId, archiveSize: archiveSize, key: key, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DownloadResponseDto',) as DownloadResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /asset/map-marker' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9deee81b7..7ba532835 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -257,8 +257,10 @@ class ApiClient { return DeleteAssetResponseDto.fromJson(value); case 'DeleteAssetStatus': return DeleteAssetStatusTypeTransformer().decode(value); - case 'DownloadFilesDto': - return DownloadFilesDto.fromJson(value); + case 'DownloadArchiveInfo': + return DownloadArchiveInfo.fromJson(value); + case 'DownloadResponseDto': + return DownloadResponseDto.fromJson(value); case 'ExifResponseDto': return ExifResponseDto.fromJson(value); case 'GetAssetByTimeBucketDto': diff --git a/mobile/openapi/lib/model/download_files_dto.dart b/mobile/openapi/lib/model/download_archive_info.dart similarity index 59% rename from mobile/openapi/lib/model/download_files_dto.dart rename to mobile/openapi/lib/model/download_archive_info.dart index bd7c3537f..ff370f423 100644 --- a/mobile/openapi/lib/model/download_files_dto.dart +++ b/mobile/openapi/lib/model/download_archive_info.dart @@ -10,40 +10,47 @@ part of openapi.api; -class DownloadFilesDto { - /// Returns a new [DownloadFilesDto] instance. - DownloadFilesDto({ +class DownloadArchiveInfo { + /// Returns a new [DownloadArchiveInfo] instance. + DownloadArchiveInfo({ + required this.size, this.assetIds = const [], }); + int size; + List assetIds; @override - bool operator ==(Object other) => identical(this, other) || other is DownloadFilesDto && + bool operator ==(Object other) => identical(this, other) || other is DownloadArchiveInfo && + other.size == size && other.assetIds == assetIds; @override int get hashCode => // ignore: unnecessary_parenthesis + (size.hashCode) + (assetIds.hashCode); @override - String toString() => 'DownloadFilesDto[assetIds=$assetIds]'; + String toString() => 'DownloadArchiveInfo[size=$size, assetIds=$assetIds]'; Map toJson() { final json = {}; + json[r'size'] = this.size; json[r'assetIds'] = this.assetIds; return json; } - /// Returns a new [DownloadFilesDto] instance and imports its values from + /// Returns a new [DownloadArchiveInfo] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static DownloadFilesDto? fromJson(dynamic value) { + static DownloadArchiveInfo? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return DownloadFilesDto( + return DownloadArchiveInfo( + size: mapValueOfType(json, r'size')!, assetIds: json[r'assetIds'] is Iterable ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) : const [], @@ -52,11 +59,11 @@ class DownloadFilesDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = DownloadFilesDto.fromJson(row); + final value = DownloadArchiveInfo.fromJson(row); if (value != null) { result.add(value); } @@ -65,12 +72,12 @@ class DownloadFilesDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = DownloadFilesDto.fromJson(entry.value); + final value = DownloadArchiveInfo.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -79,14 +86,14 @@ class DownloadFilesDto { return map; } - // maps a json object with a list of DownloadFilesDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of DownloadArchiveInfo-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = DownloadFilesDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = DownloadArchiveInfo.listFromJson(entry.value, growable: growable,); } } return map; @@ -94,6 +101,7 @@ class DownloadFilesDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'size', 'assetIds', }; } diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart new file mode 100644 index 000000000..89269c71a --- /dev/null +++ b/mobile/openapi/lib/model/download_response_dto.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class DownloadResponseDto { + /// Returns a new [DownloadResponseDto] instance. + DownloadResponseDto({ + required this.totalSize, + this.archives = const [], + }); + + int totalSize; + + List archives; + + @override + bool operator ==(Object other) => identical(this, other) || other is DownloadResponseDto && + other.totalSize == totalSize && + other.archives == archives; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (totalSize.hashCode) + + (archives.hashCode); + + @override + String toString() => 'DownloadResponseDto[totalSize=$totalSize, archives=$archives]'; + + Map toJson() { + final json = {}; + json[r'totalSize'] = this.totalSize; + json[r'archives'] = this.archives; + return json; + } + + /// Returns a new [DownloadResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DownloadResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return DownloadResponseDto( + totalSize: mapValueOfType(json, r'totalSize')!, + archives: DownloadArchiveInfo.listFromJson(json[r'archives']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = DownloadResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = DownloadResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DownloadResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = DownloadResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'totalSize', + 'archives', + }; +} + diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index 28c93deb4..5c2331fa9 100644 --- a/mobile/openapi/test/album_api_test.dart +++ b/mobile/openapi/test/album_api_test.dart @@ -37,11 +37,6 @@ void main() { // TODO }); - //Future downloadArchive(String id, { String name, num skip, String key }) async - test('test downloadArchive', () async { - // TODO - }); - //Future getAlbumCount() async test('test getAlbumCount', () async { // TODO diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 1a2e510cf..1c5f08536 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -43,23 +43,16 @@ void main() { // TODO }); + //Future downloadArchive(AssetIdsDto assetIdsDto, { String key }) async + test('test downloadArchive', () async { + // TODO + }); + //Future downloadFile(String id, { String key }) async test('test downloadFile', () async { // TODO }); - //Future downloadFiles(DownloadFilesDto downloadFilesDto, { String key }) async - test('test downloadFiles', () async { - // TODO - }); - - // Current this is not used in any UI element - // - //Future downloadLibrary({ String name, num skip, String key }) async - test('test downloadLibrary', () async { - // TODO - }); - // Get all AssetEntity belong to the user // //Future> getAllAssets({ String userId, bool isFavorite, bool isArchived, bool withoutThumbs, num skip, String ifNoneMatch }) async @@ -114,6 +107,11 @@ void main() { // TODO }); + //Future getDownloadInfo({ List assetIds, String albumId, String userId, num archiveSize, String key }) async + test('test getDownloadInfo', () async { + // TODO + }); + //Future> getMapMarkers({ bool isFavorite, DateTime fileCreatedAfter, DateTime fileCreatedBefore }) async test('test getMapMarkers', () async { // TODO diff --git a/mobile/openapi/test/download_files_dto_test.dart b/mobile/openapi/test/download_archive_info_test.dart similarity index 70% rename from mobile/openapi/test/download_files_dto_test.dart rename to mobile/openapi/test/download_archive_info_test.dart index fcc46a6c3..35f29ef99 100644 --- a/mobile/openapi/test/download_files_dto_test.dart +++ b/mobile/openapi/test/download_archive_info_test.dart @@ -11,11 +11,16 @@ import 'package:openapi/api.dart'; import 'package:test/test.dart'; -// tests for DownloadFilesDto +// tests for DownloadArchiveInfo void main() { - // final instance = DownloadFilesDto(); + // final instance = DownloadArchiveInfo(); + + group('test DownloadArchiveInfo', () { + // int size + test('to test the property `size`', () async { + // TODO + }); - group('test DownloadFilesDto', () { // List assetIds (default value: const []) test('to test the property `assetIds`', () async { // TODO diff --git a/mobile/openapi/test/download_response_dto_test.dart b/mobile/openapi/test/download_response_dto_test.dart new file mode 100644 index 000000000..b823c1441 --- /dev/null +++ b/mobile/openapi/test/download_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for DownloadResponseDto +void main() { + // final instance = DownloadResponseDto(); + + group('test DownloadResponseDto', () { + // int totalSize + test('to test the property `totalSize`', () async { + // TODO + }); + + // List archives (default value: const []) + test('to test the property `archives`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 07b8ad0fc..7717e0ab1 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -370,73 +370,6 @@ ] } }, - "/album/{id}/download": { - "get": { - "operationId": "downloadArchive", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "name", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "description": "" - } - }, - "tags": [ - "Album" - ], - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ] - } - }, "/album/{id}/user/{userId}": { "delete": { "operationId": "removeUserFromAlbum", @@ -1153,10 +1086,48 @@ ] } }, - "/asset/download-files": { - "post": { - "operationId": "downloadFiles", + "/asset/download": { + "get": { + "operationId": "getDownloadInfo", "parameters": [ + { + "name": "assetIds", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "archiveSize", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, { "name": "key", "required": false, @@ -1166,30 +1137,16 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DownloadFilesDto" - } - } - } - }, "responses": { "200": { + "description": "", "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/DownloadResponseDto" } } - }, - "description": "" - }, - "201": { - "description": "" + } } }, "tags": [ @@ -1206,29 +1163,10 @@ "api_key": [] } ] - } - }, - "/asset/download-library": { - "get": { - "operationId": "downloadLibrary", - "description": "Current this is not used in any UI element", + }, + "post": { + "operationId": "downloadArchive", "parameters": [ - { - "name": "name", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, { "name": "key", "required": false, @@ -1238,6 +1176,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetIdsDto" + } + } + } + }, "responses": { "200": { "content": { @@ -1268,7 +1216,7 @@ } }, "/asset/download/{id}": { - "get": { + "post": { "operationId": "downloadFile", "parameters": [ { @@ -5341,11 +5289,13 @@ "FAILED" ] }, - "DownloadFilesDto": { + "DownloadArchiveInfo": { "type": "object", "properties": { + "size": { + "type": "integer" + }, "assetIds": { - "title": "Array of asset ids to be downloaded", "type": "array", "items": { "type": "string" @@ -5353,9 +5303,28 @@ } }, "required": [ + "size", "assetIds" ] }, + "DownloadResponseDto": { + "type": "object", + "properties": { + "totalSize": { + "type": "integer" + }, + "archives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadArchiveInfo" + } + } + }, + "required": [ + "totalSize", + "archives" + ] + }, "ExifResponseDto": { "type": "object", "properties": { diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index f730e1be9..e4a2ed447 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -16,6 +16,7 @@ export enum Permission { ALBUM_UPDATE = 'album.update', ALBUM_DELETE = 'album.delete', ALBUM_SHARE = 'album.share', + ALBUM_DOWNLOAD = 'album.download', LIBRARY_READ = 'library.read', LIBRARY_DOWNLOAD = 'library.download', @@ -68,6 +69,10 @@ export class AccessCore { // TODO: fix this to not use authUser.id for shared link access control return this.repository.asset.hasOwnerAccess(authUser.id, id); + case Permission.ALBUM_DOWNLOAD: { + return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id)); + } + // case Permission.ALBUM_READ: // return this.repository.album.hasSharedLinkAccess(sharedLinkId, id); @@ -122,6 +127,12 @@ export class AccessCore { case Permission.ALBUM_SHARE: return this.repository.album.hasOwnerAccess(authUser.id, id); + case Permission.ALBUM_DOWNLOAD: + return ( + (await this.repository.album.hasOwnerAccess(authUser.id, id)) || + (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) + ); + case Permission.LIBRARY_READ: return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id)); diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 9479d3c12..9bd9c687a 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -44,6 +44,8 @@ export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { getByDate(ownerId: string, date: Date): Promise; getByIds(ids: string[]): Promise; + getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; + getByUserId(pagination: PaginationOptions, userId: string): Paginated; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty): Paginated; getFirstAssetForAlbumId(albumId: string): Promise; diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index b6f253113..ed155c148 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -1,21 +1,48 @@ -import { assetEntityStub, authStub, newAssetRepositoryMock } from '@test'; +import { BadRequestException } from '@nestjs/common'; +import { + assetEntityStub, + authStub, + IAccessRepositoryMock, + newAccessRepositoryMock, + newAssetRepositoryMock, + newStorageRepositoryMock, +} from '@test'; import { when } from 'jest-when'; -import { AssetService, IAssetRepository, mapAsset } from '.'; +import { Readable } from 'stream'; +import { IStorageRepository } from '../storage'; +import { IAssetRepository } from './asset.repository'; +import { AssetService } from './asset.service'; +import { DownloadResponseDto } from './index'; +import { mapAsset } from './response-dto'; + +const downloadResponse: DownloadResponseDto = { + totalSize: 105_000, + archives: [ + { + assetIds: ['asset-id', 'asset-id'], + size: 105_000, + }, + ], +}; describe(AssetService.name, () => { let sut: AssetService; + let accessMock: IAccessRepositoryMock; let assetMock: jest.Mocked; + let storageMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(async () => { + accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); - sut = new AssetService(assetMock); + storageMock = newStorageRepositoryMock(); + sut = new AssetService(accessMock, assetMock, storageMock); }); - describe('get map markers', () => { + describe('getMapMarkers', () => { it('should get geo information of assets', async () => { assetMock.getMapMarkers.mockResolvedValue( [assetEntityStub.withLocation].map((asset) => ({ @@ -76,25 +103,191 @@ describe(AssetService.name, () => { [authStub.admin.id, new Date('2021-06-15T05:00:00.000Z')], ]); }); + + it('should set the title correctly', async () => { + when(assetMock.getByDate) + .calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')) + .mockResolvedValue([assetEntityStub.image]); + when(assetMock.getByDate) + .calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')) + .mockResolvedValue([assetEntityStub.video]); + + await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([ + { title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] }, + { title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] }, + ]); + + expect(assetMock.getByDate).toHaveBeenCalledTimes(2); + expect(assetMock.getByDate.mock.calls).toEqual([ + [authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')], + [authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')], + ]); + }); }); - it('should set the title correctly', async () => { - when(assetMock.getByDate) - .calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')) - .mockResolvedValue([assetEntityStub.image]); - when(assetMock.getByDate) - .calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')) - .mockResolvedValue([assetEntityStub.video]); + describe('downloadFile', () => { + it('should require the asset.download permission', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + accessMock.asset.hasAlbumAccess.mockResolvedValue(false); + accessMock.asset.hasPartnerAccess.mockResolvedValue(false); - await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([ - { title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] }, - { title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] }, - ]); + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.getByDate).toHaveBeenCalledTimes(2); - expect(assetMock.getByDate.mock.calls).toEqual([ - [authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')], - [authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')], - ]); + expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + }); + + it('should throw an error if the asset is not found', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([]); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); + }); + + it('should download a file', async () => { + const stream = new Readable(); + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); + storageMock.createReadStream.mockResolvedValue({ stream }); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream }); + + expect(storageMock.createReadStream).toHaveBeenCalledWith( + assetEntityStub.image.originalPath, + assetEntityStub.image.mimeType, + ); + }); + + it('should download an archive', async () => { + const archiveMock = { + addFile: jest.fn(), + finalize: jest.fn(), + stream: new Readable(), + }; + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noWebpPath]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); + }); + + it('should handle duplicate file names', async () => { + const archiveMock = { + addFile: jest.fn(), + finalize: jest.fn(), + stream: new Readable(), + }; + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noResizePath]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg'); + }); + }); + + describe('getDownloadInfo', () => { + it('should throw an error for an invalid dto', async () => { + await expect(sut.getDownloadInfo(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should return a list of archives (assetIds)', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.image, assetEntityStub.video]); + + const assetIds = ['asset-1', 'asset-2']; + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse); + + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2']); + }); + + it('should return a list of archives (albumId)', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByAlbumId.mockResolvedValue({ + items: [assetEntityStub.image, assetEntityStub.video], + hasNextPage: false, + }); + + await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); + + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-1'); + expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); + }); + + it('should return a list of archives (userId)', async () => { + assetMock.getByUserId.mockResolvedValue({ + items: [assetEntityStub.image, assetEntityStub.video], + hasNextPage: false, + }); + + await expect(sut.getDownloadInfo(authStub.admin, { userId: authStub.admin.id })).resolves.toEqual( + downloadResponse, + ); + + expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.id); + }); + + it('should split archives by size', async () => { + assetMock.getByUserId.mockResolvedValue({ + items: [ + { ...assetEntityStub.image, id: 'asset-1' }, + { ...assetEntityStub.video, id: 'asset-2' }, + { ...assetEntityStub.withLocation, id: 'asset-3' }, + { ...assetEntityStub.noWebpPath, id: 'asset-4' }, + ], + hasNextPage: false, + }); + + await expect( + sut.getDownloadInfo(authStub.admin, { + userId: authStub.admin.id, + archiveSize: 30_000, + }), + ).resolves.toEqual({ + totalSize: 251_456, + archives: [ + { assetIds: ['asset-1', 'asset-2'], size: 105_000 }, + { assetIds: ['asset-3', 'asset-4'], size: 146_456 }, + ], + }); + }); + + it('should include the video portion of a live photo', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getByIds) + .calledWith([assetEntityStub.livePhotoStillAsset.id]) + .mockResolvedValue([assetEntityStub.livePhotoStillAsset]); + when(assetMock.getByIds) + .calledWith([assetEntityStub.livePhotoMotionAsset.id]) + .mockResolvedValue([assetEntityStub.livePhotoMotionAsset]); + + const assetIds = [assetEntityStub.livePhotoStillAsset.id]; + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ + totalSize: 125_000, + archives: [ + { + assetIds: [assetEntityStub.livePhotoStillAsset.id, assetEntityStub.livePhotoMotionAsset.id], + size: 125_000, + }, + ], + }); + }); }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 230192e11..51d3afb8d 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -1,14 +1,27 @@ -import { Inject } from '@nestjs/common'; +import { BadRequestException, Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { extname } from 'path'; +import { AssetEntity } from '../../infra/entities/asset.entity'; import { AuthUserDto } from '../auth'; +import { HumanReadableSize, usePagination } from '../domain.util'; +import { AccessCore, IAccessRepository, Permission } from '../index'; +import { ImmichReadStream, IStorageRepository } from '../storage'; import { IAssetRepository } from './asset.repository'; -import { MemoryLaneDto } from './dto'; +import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto'; import { MapMarkerDto } from './dto/map-marker.dto'; import { mapAsset, MapMarkerResponseDto } from './response-dto'; import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto'; export class AssetService { - constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + ) { + this.access = new AccessCore(accessRepository); + } getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise { return this.assetRepository.getMapMarkers(authUser.id, options); @@ -32,4 +45,102 @@ export class AssetService { return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0)); } + + async downloadFile(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, id); + + const [asset] = await this.assetRepository.getByIds([id]); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType); + } + + async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise { + const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; + const archives: DownloadArchiveInfo[] = []; + let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + + const assetPagination = await this.getDownloadAssets(authUser, 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); + if (motionIds.length > 0) { + assets.push(...(await this.assetRepository.getByIds(motionIds))); + } + + for (const asset of assets) { + archive.size += Number(asset.exifInfo?.fileSizeInByte || 0); + archive.assetIds.push(asset.id); + + if (archive.size > targetSize) { + archives.push(archive); + archive = { size: 0, assetIds: [] }; + } + } + + if (archive.assetIds.length > 0) { + archives.push(archive); + } + } + + return { + totalSize: archives.reduce((total, item) => (total += item.size), 0), + archives, + }; + } + + async downloadArchive(authUser: AuthUserDto, dto: AssetIdsDto): Promise { + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds); + + const zip = this.storageRepository.createZipStream(); + const assets = await this.assetRepository.getByIds(dto.assetIds); + const paths: Record = {}; + + for (const { originalPath, originalFileName } of assets) { + const ext = extname(originalPath); + let filename = `${originalFileName}${ext}`; + for (let i = 0; i < 10_000; i++) { + if (!paths[filename]) { + break; + } + filename = `${originalFileName}+${i + 1}${ext}`; + } + + paths[filename] = true; + zip.addFile(originalPath, filename); + } + + zip.finalize(); + + return { stream: zip.stream }; + } + + private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadDto): Promise> { + const PAGINATION_SIZE = 2500; + + if (dto.assetIds) { + const assetIds = dto.assetIds; + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetIds); + const assets = await this.assetRepository.getByIds(assetIds); + return (async function* () { + yield assets; + })(); + } + + if (dto.albumId) { + const albumId = dto.albumId; + await this.access.requirePermission(authUser, Permission.ALBUM_DOWNLOAD, albumId); + return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); + } + + if (dto.userId) { + const userId = dto.userId; + await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, userId); + return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId)); + } + + throw new BadRequestException('assetIds, albumId, or userId is required'); + } } diff --git a/server/src/domain/asset/dto/download.dto.ts b/server/src/domain/asset/dto/download.dto.ts new file mode 100644 index 000000000..cb6b8f7dd --- /dev/null +++ b/server/src/domain/asset/dto/download.dto.ts @@ -0,0 +1,31 @@ +import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsPositive } from 'class-validator'; + +export class DownloadDto { + @ValidateUUID({ each: true, optional: true }) + assetIds?: string[]; + + @ValidateUUID({ optional: true }) + albumId?: string; + + @ValidateUUID({ optional: true }) + userId?: string; + + @IsInt() + @IsPositive() + @IsOptional() + archiveSize?: number; +} + +export class DownloadResponseDto { + @ApiProperty({ type: 'integer' }) + totalSize!: number; + archives!: DownloadArchiveInfo[]; +} + +export class DownloadArchiveInfo { + @ApiProperty({ type: 'integer' }) + size!: number; + assetIds!: string[]; +} diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 130f28144..9778a9122 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,3 +1,4 @@ export * from './asset-ids.dto'; +export * from './download.dto'; export * from './map-marker.dto'; export * from './memory-lane.dto'; diff --git a/server/src/domain/storage/storage.repository.ts b/server/src/domain/storage/storage.repository.ts index 4ff1b5c01..7d312c075 100644 --- a/server/src/domain/storage/storage.repository.ts +++ b/server/src/domain/storage/storage.repository.ts @@ -1,9 +1,14 @@ -import { ReadStream } from 'fs'; +import { Readable } from 'stream'; export interface ImmichReadStream { - stream: ReadStream; - type: string; - length: number; + stream: Readable; + type?: string; + length?: number; +} + +export interface ImmichZipStream extends ImmichReadStream { + addFile: (inputPath: string, filename: string) => void; + finalize: () => Promise; } export interface DiskUsage { @@ -15,7 +20,8 @@ export interface DiskUsage { export const IStorageRepository = 'IStorageRepository'; export interface IStorageRepository { - createReadStream(filepath: string, mimeType: string): Promise; + createZipStream(): ImmichZipStream; + createReadStream(filepath: string, mimeType?: string | null): Promise; unlink(filepath: string): Promise; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; removeEmptyDirs(folder: string): Promise; diff --git a/server/src/immich/api-v1/album/album.controller.ts b/server/src/immich/api-v1/album/album.controller.ts index 5349f5d65..021cc04ce 100644 --- a/server/src/immich/api-v1/album/album.controller.ts +++ b/server/src/immich/api-v1/album/album.controller.ts @@ -1,13 +1,10 @@ import { AlbumResponseDto } from '@app/domain'; -import { Body, Controller, Delete, Get, Param, Put, Query, Response } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { Response as Res } from 'express'; -import { handleDownload } from '../../app.utils'; +import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator'; import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator'; import { UseValidation } from '../../decorators/use-validation.decorator'; -import { DownloadDto } from '../asset/dto/download-library.dto'; import { AlbumService } from './album.service'; import { AddAssetsDto } from './dto/add-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; @@ -18,7 +15,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; @Authenticated() @UseValidation() export class AlbumController { - constructor(private readonly service: AlbumService) {} + constructor(private service: AlbumService) {} @SharedLinkRoute() @Put(':id/assets') @@ -46,16 +43,4 @@ export class AlbumController { ): Promise { return this.service.removeAssets(authUser, id, dto); } - - @SharedLinkRoute() - @Get(':id/download') - @ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } }) - downloadArchive( - @AuthUser() authUser: AuthUserDto, - @Param() { id }: UUIDParamDto, - @Query() dto: DownloadDto, - @Response({ passthrough: true }) res: Res, - ) { - return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res)); - } } diff --git a/server/src/immich/api-v1/album/album.module.ts b/server/src/immich/api-v1/album/album.module.ts index 3b09fd6ea..e241f9635 100644 --- a/server/src/immich/api-v1/album/album.module.ts +++ b/server/src/immich/api-v1/album/album.module.ts @@ -1,13 +1,12 @@ import { AlbumEntity, AssetEntity } from '@app/infra/entities'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DownloadModule } from '../../modules/download/download.module'; import { AlbumRepository, IAlbumRepository } from './album-repository'; import { AlbumController } from './album.controller'; import { AlbumService } from './album.service'; @Module({ - imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity]), DownloadModule], + imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])], controllers: [AlbumController], providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }], }) diff --git a/server/src/immich/api-v1/album/album.service.spec.ts b/server/src/immich/api-v1/album/album.service.spec.ts index 77ccbb67a..1215e6990 100644 --- a/server/src/immich/api-v1/album/album.service.spec.ts +++ b/server/src/immich/api-v1/album/album.service.spec.ts @@ -3,7 +3,6 @@ import { AlbumEntity, UserEntity } from '@app/infra/entities'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { userEntityStub } from '@test'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { DownloadService } from '../../modules/download/download.service'; import { IAlbumRepository } from './album-repository'; import { AlbumService } from './album.service'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; @@ -11,7 +10,6 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; describe('Album service', () => { let sut: AlbumService; let albumRepositoryMock: jest.Mocked; - let downloadServiceMock: jest.Mocked>; const authUser: AuthUserDto = Object.freeze({ id: '1111', @@ -98,11 +96,7 @@ describe('Album service', () => { updateThumbnails: jest.fn(), }; - downloadServiceMock = { - downloadArchive: jest.fn(), - }; - - sut = new AlbumService(albumRepositoryMock, downloadServiceMock as DownloadService); + sut = new AlbumService(albumRepositoryMock); }); it('gets an owned album', async () => { diff --git a/server/src/immich/api-v1/album/album.service.ts b/server/src/immich/api-v1/album/album.service.ts index 7e5e551e0..5f7fc834e 100644 --- a/server/src/immich/api-v1/album/album.service.ts +++ b/server/src/immich/api-v1/album/album.service.ts @@ -2,8 +2,6 @@ import { AlbumResponseDto, mapAlbum } from '@app/domain'; import { AlbumEntity } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { DownloadService } from '../../modules/download/download.service'; -import { DownloadDto } from '../asset/dto/download-library.dto'; import { IAlbumRepository } from './album-repository'; import { AddAssetsDto } from './dto/add-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; @@ -13,10 +11,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; export class AlbumService { private logger = new Logger(AlbumService.name); - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - private downloadService: DownloadService, - ) {} + constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {} private async _getAlbum({ authUser, @@ -27,9 +22,9 @@ export class AlbumService { albumId: string; validateIsOwner?: boolean; }): Promise { - await this.albumRepository.updateThumbnails(); + await this.repository.updateThumbnails(); - const album = await this.albumRepository.get(albumId); + const album = await this.repository.get(albumId); if (!album) { throw new NotFoundException('Album Not Found'); } @@ -50,7 +45,7 @@ export class AlbumService { async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise { const album = await this._getAlbum({ authUser, albumId }); - const deletedCount = await this.albumRepository.removeAssets(album, dto); + const deletedCount = await this.repository.removeAssets(album, dto); const newAlbum = await this._getAlbum({ authUser, albumId }); if (deletedCount !== dto.assetIds.length) { @@ -67,7 +62,7 @@ export class AlbumService { } const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const result = await this.albumRepository.addAssets(album, dto); + const result = await this.repository.addAssets(album, dto); const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); return { @@ -75,19 +70,4 @@ export class AlbumService { album: mapAlbum(newAlbum), }; } - - async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) { - this.checkDownloadAccess(authUser); - - const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0); - - return this.downloadService.downloadArchive(album.albumName, assets); - } - - private checkDownloadAccess(authUser: AuthUserDto) { - if (authUser.isPublicUser && !authUser.isAllowDownload) { - throw new ForbiddenException(); - } - } } diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index e7cc8a4b1..53e323a0b 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -1,4 +1,4 @@ -import { AssetResponseDto, ImmichReadStream } from '@app/domain'; +import { AssetResponseDto } from '@app/domain'; import { Body, Controller, @@ -14,7 +14,6 @@ import { Put, Query, Response, - StreamableFile, UploadedFiles, UseInterceptors, ValidationPipe, @@ -22,7 +21,6 @@ import { import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; -import { handleDownload } from '../../app.utils'; import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator'; @@ -36,8 +34,6 @@ import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeviceIdDto } from './dto/device-id.dto'; -import { DownloadFilesDto } from './dto/download-files.dto'; -import { DownloadDto } from './dto/download-library.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto'; @@ -54,10 +50,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto'; -function asStreamableFile({ stream, type, length }: ImmichReadStream) { - return new StreamableFile(stream, { type, length }); -} - interface UploadFiles { assetData: ImmichFile[]; livePhotoData?: ImmichFile[]; @@ -128,38 +120,6 @@ export class AssetController { return responseDto; } - @SharedLinkRoute() - @Get('/download/:id') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { - return this.assetService.downloadFile(authUser, id).then(asStreamableFile); - } - - @SharedLinkRoute() - @Post('/download-files') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadFiles( - @AuthUser() authUser: AuthUserDto, - @Response({ passthrough: true }) res: Res, - @Body(new ValidationPipe()) dto: DownloadFilesDto, - ) { - return this.assetService.downloadFiles(authUser, dto).then((download) => handleDownload(download, res)); - } - - /** - * Current this is not used in any UI element - */ - @SharedLinkRoute() - @Get('/download-library') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadLibrary( - @AuthUser() authUser: AuthUserDto, - @Query(new ValidationPipe({ transform: true })) dto: DownloadDto, - @Response({ passthrough: true }) res: Res, - ) { - return this.assetService.downloadLibrary(authUser, dto).then((download) => handleDownload(download, res)); - } - @SharedLinkRoute() @Get('/file/:id') @Header('Cache-Control', 'private, max-age=86400, no-transform') diff --git a/server/src/immich/api-v1/asset/asset.module.ts b/server/src/immich/api-v1/asset/asset.module.ts index 1f633d955..2d9cdd4fe 100644 --- a/server/src/immich/api-v1/asset/asset.module.ts +++ b/server/src/immich/api-v1/asset/asset.module.ts @@ -1,17 +1,12 @@ import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DownloadModule } from '../../modules/download/download.module'; import { AssetRepository, IAssetRepository } from './asset-repository'; import { AssetController } from './asset.controller'; import { AssetService } from './asset.service'; @Module({ - imports: [ - // - TypeOrmModule.forFeature([AssetEntity, ExifEntity]), - DownloadModule, - ], + imports: [TypeOrmModule.forFeature([AssetEntity, ExifEntity])], controllers: [AssetController], providers: [AssetService, { provide: IAssetRepository, useClass: AssetRepository }], }) diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 5963aa0a6..de236ca5f 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -13,7 +13,6 @@ import { } from '@test'; import { when } from 'jest-when'; import { QueryFailedError, Repository } from 'typeorm'; -import { DownloadService } from '../../modules/download/download.service'; import { IAssetRepository } from './asset-repository'; import { AssetService } from './asset.service'; import { CreateAssetDto } from './dto/create-asset.dto'; @@ -124,7 +123,6 @@ describe('AssetService', () => { let accessMock: IAccessRepositoryMock; let assetRepositoryMock: jest.Mocked; let cryptoMock: jest.Mocked; - let downloadServiceMock: jest.Mocked>; let jobMock: jest.Mocked; let storageMock: jest.Mocked; @@ -152,24 +150,12 @@ describe('AssetService', () => { cryptoMock = newCryptoRepositoryMock(); - downloadServiceMock = { - downloadArchive: jest.fn(), - }; - accessMock = newAccessRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new AssetService( - accessMock, - assetRepositoryMock, - a, - cryptoMock, - downloadServiceMock as DownloadService, - jobMock, - storageMock, - ); + sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock); when(assetRepositoryMock.get) .calledWith(assetEntityStub.livePhotoStillAsset.id) @@ -398,27 +384,6 @@ describe('AssetService', () => { }); }); - // describe('checkDownloadAccess', () => { - // it('should validate download access', async () => { - // await sut.checkDownloadAccess(authStub.adminSharedLink); - // }); - - // it('should not allow when user is not allowed to download', async () => { - // expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException); - // }); - // }); - - describe('downloadFile', () => { - it('should download a single file', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - assetRepositoryMock.get.mockResolvedValue(_getAsset_1()); - - await sut.downloadFile(authStub.admin, 'id_1'); - - expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg'); - }); - }); - describe('bulkUploadCheck', () => { it('should accept hex and base64 checksums', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 53335ceaf..1b1dd00ee 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -6,7 +6,6 @@ import { IAccessRepository, ICryptoRepository, IJobRepository, - ImmichReadStream, isSupportedFileType, IStorageRepository, JobName, @@ -33,7 +32,6 @@ import mime from 'mime-types'; import path from 'path'; import { QueryFailedError, Repository } from 'typeorm'; import { promisify } from 'util'; -import { DownloadService } from '../../modules/download/download.service'; import { IAssetRepository } from './asset-repository'; import { AssetCore } from './asset.core'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; @@ -42,8 +40,6 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto'; -import { DownloadFilesDto } from './dto/download-files.dto'; -import { DownloadDto } from './dto/download-library.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto'; @@ -86,7 +82,6 @@ export class AssetService { @Inject(IAssetRepository) private _assetRepository: IAssetRepository, @InjectRepository(AssetEntity) private assetRepository: Repository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - private downloadService: DownloadService, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { @@ -250,50 +245,6 @@ export class AssetService { return mapAsset(updatedAsset); } - public async downloadLibrary(authUser: AuthUserDto, dto: DownloadDto) { - await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, authUser.id); - - const assets = await this._assetRepository.getAllByUserId(authUser.id, dto); - - return this.downloadService.downloadArchive(dto.name || `library`, assets); - } - - public async downloadFiles(authUser: AuthUserDto, dto: DownloadFilesDto) { - await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds); - - const assetToDownload = []; - - for (const assetId of dto.assetIds) { - const asset = await this._assetRepository.getById(assetId); - assetToDownload.push(asset); - - // Get live photo asset - if (asset.livePhotoVideoId) { - const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId); - assetToDownload.push(livePhotoAsset); - } - } - - const now = new Date().toISOString(); - return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload); - } - - public async downloadFile(authUser: AuthUserDto, assetId: string): Promise { - await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetId); - - try { - const asset = await this._assetRepository.get(assetId); - if (asset && asset.originalPath && asset.mimeType) { - return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType); - } - } catch (e) { - Logger.error(`Error download asset ${e}`, 'downloadFile'); - throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile'); - } - - throw new NotFoundException(); - } - async getAssetThumbnail( authUser: AuthUserDto, assetId: string, diff --git a/server/src/immich/api-v1/asset/dto/download-files.dto.ts b/server/src/immich/api-v1/asset/dto/download-files.dto.ts deleted file mode 100644 index 557db73d5..000000000 --- a/server/src/immich/api-v1/asset/dto/download-files.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; - -export class DownloadFilesDto { - @IsNotEmpty() - @ApiProperty({ - isArray: true, - type: String, - title: 'Array of asset ids to be downloaded', - }) - assetIds!: string[]; -} diff --git a/server/src/immich/api-v1/asset/dto/download-library.dto.ts b/server/src/immich/api-v1/asset/dto/download-library.dto.ts deleted file mode 100644 index 7e1dfd12d..000000000 --- a/server/src/immich/api-v1/asset/dto/download-library.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; - -export class DownloadDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsPositive() - @IsNumber() - @Type(() => Number) - skip?: number; -} diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts index ff130bf75..8355964a8 100644 --- a/server/src/immich/app.utils.ts +++ b/server/src/immich/app.utils.ts @@ -1,5 +1,11 @@ -import { IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME, SERVER_VERSION } from '@app/domain'; -import { INestApplication } from '@nestjs/common'; +import { + ImmichReadStream, + IMMICH_ACCESS_COOKIE, + IMMICH_API_KEY_HEADER, + IMMICH_API_KEY_NAME, + SERVER_VERSION, +} from '@app/domain'; +import { INestApplication, StreamableFile } from '@nestjs/common'; import { DocumentBuilder, OpenAPIObject, @@ -7,18 +13,12 @@ import { SwaggerDocumentOptions, SwaggerModule, } from '@nestjs/swagger'; -import { Response } from 'express'; import { writeFileSync } from 'fs'; import path from 'path'; import { Metadata } from './decorators/authenticated.decorator'; -import { DownloadArchive } from './modules/download/download.service'; -export const handleDownload = (download: DownloadArchive, res: Response) => { - res.attachment(download.fileName); - res.setHeader('X-Immich-Content-Length-Hint', download.fileSize); - res.setHeader('X-Immich-Archive-File-Count', download.fileCount); - res.setHeader('X-Immich-Archive-Complete', `${download.complete}`); - return download.stream; +export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { + return new StreamableFile(stream, { type, length }); }; function sortKeys(obj: T): T { diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index c6dbd2218..dfea0d5a7 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -1,11 +1,21 @@ -import { AssetService, AuthUserDto, MapMarkerResponseDto, MemoryLaneDto } from '@app/domain'; +import { + AssetIdsDto, + AssetService, + AuthUserDto, + DownloadDto, + DownloadResponseDto, + MapMarkerResponseDto, + MemoryLaneDto, +} from '@app/domain'; import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto'; import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto'; -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { asStreamableFile } from '../app.utils'; import { AuthUser } from '../decorators/auth-user.decorator'; -import { Authenticated } from '../decorators/authenticated.decorator'; +import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator'; import { UseValidation } from '../decorators/use-validation.decorator'; +import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Asset') @Controller('asset') @@ -23,4 +33,26 @@ export class AssetController { getMemoryLane(@AuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise { return this.service.getMemoryLane(authUser, dto); } + + @SharedLinkRoute() + @Get('download') + getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Query() dto: DownloadDto): Promise { + return this.service.getDownloadInfo(authUser, dto); + } + + @SharedLinkRoute() + @Post('download') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + downloadArchive(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetIdsDto): Promise { + return this.service.downloadArchive(authUser, dto).then(asStreamableFile); + } + + @SharedLinkRoute() + @Post('download/:id') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { + return this.service.downloadFile(authUser, id).then(asStreamableFile); + } } diff --git a/server/src/immich/modules/download/download.module.ts b/server/src/immich/modules/download/download.module.ts deleted file mode 100644 index 354982cc6..000000000 --- a/server/src/immich/modules/download/download.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DownloadService } from './download.service'; - -@Module({ - providers: [DownloadService], - exports: [DownloadService], -}) -export class DownloadModule {} diff --git a/server/src/immich/modules/download/download.service.ts b/server/src/immich/modules/download/download.service.ts deleted file mode 100644 index 65a460278..000000000 --- a/server/src/immich/modules/download/download.service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { asHumanReadable, HumanReadableSize } from '@app/domain'; -import { AssetEntity } from '@app/infra/entities'; -import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common'; -import archiver from 'archiver'; -import { extname } from 'path'; - -export interface DownloadArchive { - stream: StreamableFile; - fileName: string; - fileSize: number; - fileCount: number; - complete: boolean; -} - -@Injectable() -export class DownloadService { - private readonly logger = new Logger(DownloadService.name); - - public async downloadArchive(name: string, assets: AssetEntity[]): Promise { - if (!assets || assets.length === 0) { - throw new BadRequestException('No assets to download.'); - } - - try { - const archive = archiver('zip', { store: true }); - const stream = new StreamableFile(archive); - let totalSize = 0; - let fileCount = 0; - let complete = true; - - for (const { originalPath, exifInfo, originalFileName } of assets) { - const name = `${originalFileName}${extname(originalPath)}`; - archive.file(originalPath, { name }); - totalSize += Number(exifInfo?.fileSizeInByte || 0); - fileCount++; - - // for easier testing, can be changed before merging. - if (totalSize > HumanReadableSize.GiB * 20) { - complete = false; - this.logger.log( - `Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable( - totalSize, - )})`, - ); - break; - } - } - - archive.finalize(); - - return { - stream, - fileName: `${name}.zip`, - fileSize: totalSize, - fileCount, - complete, - }; - } catch (error) { - this.logger.error(`Error creating download archive ${error}`); - throw new InternalServerErrorException(`Failed to download ${name}: ${error}`, 'DownloadArchive'); - } - } -} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index d1d986e72..fe518807e 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -140,7 +140,9 @@ export class AccessRepository implements IAccessRepository { return this.albumRepository.exist({ where: { id: albumId, - ownerId: userId, + sharedUsers: { + id: userId, + }, }, }); }, diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 1139dbf11..a23787252 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -72,6 +72,32 @@ export class AssetRepository implements IAssetRepository { await this.repository.delete({ ownerId }); } + getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { + return paginate(this.repository, pagination, { + where: { + albums: { + id: albumId, + }, + }, + relations: { + albums: true, + exifInfo: true, + }, + }); + } + + getByUserId(pagination: PaginationOptions, userId: string): Paginated { + return paginate(this.repository, pagination, { + where: { + ownerId: userId, + isVisible: true, + }, + relations: { + exifInfo: true, + }, + }); + } + getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { return paginate(this.repository, pagination, { where: { diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index dcb151b4d..d82e776c8 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -1,4 +1,5 @@ -import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain'; +import { DiskUsage, ImmichReadStream, ImmichZipStream, IStorageRepository } from '@app/domain'; +import archiver from 'archiver'; import { constants, createReadStream, existsSync, mkdirSync } from 'fs'; import fs from 'fs/promises'; import mv from 'mv'; @@ -8,13 +9,25 @@ import path from 'path'; const moveFile = promisify(mv); export class FilesystemProvider implements IStorageRepository { - async createReadStream(filepath: string, mimeType: string): Promise { + createZipStream(): ImmichZipStream { + const archive = archiver('zip', { store: true }); + + const addFile = (input: string, filename: string) => { + archive.file(input, { name: filename }); + }; + + const finalize = () => archive.finalize(); + + return { stream: archive, addFile, finalize }; + } + + async createReadStream(filepath: string, mimeType?: string | null): Promise { const { size } = await fs.stat(filepath); await fs.access(filepath, constants.R_OK | constants.W_OK); return { stream: createReadStream(filepath), length: size, - type: mimeType, + type: mimeType || undefined, }; } diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 970f15282..f8dc7a758 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -203,14 +203,14 @@ export const fileStub = { export const assetEntityStub = { noResizePath: Object.freeze({ id: 'asset-id', - originalFileName: 'asset_1.jpeg', + originalFileName: 'IMG_123', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), owner: userEntityStub.user1, ownerId: 'user-id', deviceId: 'device-id', - originalPath: 'upload/upload/path.ext', + originalPath: 'upload/library/IMG_123.jpg', resizePath: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, @@ -240,7 +240,7 @@ export const assetEntityStub = { owner: userEntityStub.user1, ownerId: 'user-id', deviceId: 'device-id', - originalPath: '/original/path.ext', + originalPath: 'upload/library/IMG_456.jpg', resizePath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, @@ -258,10 +258,13 @@ export const assetEntityStub = { livePhotoVideoId: null, tags: [], sharedLinks: [], - originalFileName: 'asset-id.ext', + originalFileName: 'IMG_456', faces: [], sidecarPath: null, isReadOnly: false, + exifInfo: { + fileSizeInByte: 123_000, + } as ExifEntity, }), noThumbhash: Object.freeze({ id: 'asset-id', @@ -324,6 +327,9 @@ export const assetEntityStub = { originalFileName: 'asset-id.ext', faces: [], sidecarPath: null, + exifInfo: { + fileSizeInByte: 5_000, + } as ExifEntity, }), video: Object.freeze({ id: 'asset-id', @@ -355,6 +361,9 @@ export const assetEntityStub = { sharedLinks: [], faces: [], sidecarPath: null, + exifInfo: { + fileSizeInByte: 100_000, + } as ExifEntity, }), livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', @@ -364,6 +373,9 @@ export const assetEntityStub = { isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + exifInfo: { + fileSizeInByte: 100_000, + }, } as AssetEntity), livePhotoStillAsset: Object.freeze({ @@ -375,6 +387,9 @@ export const assetEntityStub = { isVisible: true, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + exifInfo: { + fileSizeInByte: 25_000, + }, } as AssetEntity), withLocation: Object.freeze({ @@ -410,6 +425,7 @@ export const assetEntityStub = { exifInfo: { latitude: 100, longitude: 100, + fileSizeInByte: 23_456, } as ExifEntity, }), sidecar: Object.freeze({ diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 51dbb3a27..7e8a52262 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -4,6 +4,8 @@ export const newAssetRepositoryMock = (): jest.Mocked => { return { getByDate: jest.fn(), getByIds: jest.fn().mockResolvedValue([]), + getByAlbumId: jest.fn(), + getByUserId: jest.fn(), getWithout: jest.fn(), getWith: jest.fn(), getFirstAssetForAlbumId: jest.fn(), diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 21b289f93..08556a081 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -2,6 +2,7 @@ import { IStorageRepository } from '@app/domain'; export const newStorageRepositoryMock = (): jest.Mocked => { return { + createZipStream: jest.fn(), createReadStream: jest.fn(), unlink: jest.fn(), unlinkDir: jest.fn().mockResolvedValue(true), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index e3b035b69..1393f5c20 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1111,16 +1111,41 @@ export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAsse /** * * @export - * @interface DownloadFilesDto + * @interface DownloadArchiveInfo */ -export interface DownloadFilesDto { +export interface DownloadArchiveInfo { + /** + * + * @type {number} + * @memberof DownloadArchiveInfo + */ + 'size': number; /** * * @type {Array} - * @memberof DownloadFilesDto + * @memberof DownloadArchiveInfo */ 'assetIds': Array; } +/** + * + * @export + * @interface DownloadResponseDto + */ +export interface DownloadResponseDto { + /** + * + * @type {number} + * @memberof DownloadResponseDto + */ + 'totalSize': number; + /** + * + * @type {Array} + * @memberof DownloadResponseDto + */ + 'archives': Array; +} /** * * @export @@ -3645,63 +3670,6 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadArchive: async (id: string, name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('downloadArchive', 'id', id) - const localVarPath = `/album/{id}/download` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (name !== undefined) { - localVarQueryParameter['name'] = name; - } - - if (skip !== undefined) { - localVarQueryParameter['skip'] = skip; - } - - if (key !== undefined) { - localVarQueryParameter['key'] = key; - } - - - setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -4039,19 +4007,6 @@ export const AlbumApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadArchive(id: string, name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(id, name, skip, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * * @param {*} [options] Override http request option. @@ -4165,18 +4120,6 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath deleteAlbum(id: string, options?: any): AxiosPromise { return localVarFp.deleteAlbum(id, options).then((request) => request(axios, basePath)); }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadArchive(id: string, name?: string, skip?: number, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadArchive(id, name, skip, key, options).then((request) => request(axios, basePath)); - }, /** * * @param {*} [options] Override http request option. @@ -4315,41 +4258,6 @@ export interface AlbumApiDeleteAlbumRequest { readonly id: string } -/** - * Request parameters for downloadArchive operation in AlbumApi. - * @export - * @interface AlbumApiDownloadArchiveRequest - */ -export interface AlbumApiDownloadArchiveRequest { - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly id: string - - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly name?: string - - /** - * - * @type {number} - * @memberof AlbumApiDownloadArchive - */ - readonly skip?: number - - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly key?: string -} - /** * Request parameters for getAlbumInfo operation in AlbumApi. * @export @@ -4506,17 +4414,6 @@ export class AlbumApi extends BaseAPI { return AlbumApiFp(this.configuration).deleteAlbum(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {AlbumApiDownloadArchiveRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AlbumApi - */ - public downloadArchive(requestParameters: AlbumApiDownloadArchiveRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).downloadArchive(requestParameters.id, requestParameters.name, requestParameters.skip, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - /** * * @param {*} [options] Override http request option. @@ -4773,62 +4670,15 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * - * @param {string} id + * @param {AssetIdsDto} assetIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('downloadFile', 'id', id) - const localVarPath = `/asset/download/{id}` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (key !== undefined) { - localVarQueryParameter['key'] = key; - } - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadFiles: async (downloadFilesDto: DownloadFilesDto, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'downloadFilesDto' is not null or undefined - assertParamExists('downloadFiles', 'downloadFilesDto', downloadFilesDto) - const localVarPath = `/asset/download-files`; + downloadArchive: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetIdsDto' is not null or undefined + assertParamExists('downloadArchive', 'assetIdsDto', assetIdsDto) + const localVarPath = `/asset/download`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -4860,7 +4710,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(downloadFilesDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(assetIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -4868,15 +4718,17 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] + * + * @param {string} id * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadLibrary: async (name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/download-library`; + downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('downloadFile', 'id', id) + const localVarPath = `/asset/download/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -4884,7 +4736,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration baseOptions = configuration.baseOptions; } - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; @@ -4897,14 +4749,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) - if (name !== undefined) { - localVarQueryParameter['name'] = name; - } - - if (skip !== undefined) { - localVarQueryParameter['skip'] = skip; - } - if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -5356,6 +5200,69 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDownloadInfo: async (assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/download`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (assetIds) { + localVarQueryParameter['assetIds'] = assetIds; + } + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (userId !== undefined) { + localVarQueryParameter['userId'] = userId; + } + + if (archiveSize !== undefined) { + localVarQueryParameter['archiveSize'] = archiveSize; + } + + if (key !== undefined) { + localVarQueryParameter['key'] = key; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5888,6 +5795,17 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {AssetIdsDto} assetIdsDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(assetIdsDto, key, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -5899,29 +5817,6 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadFiles(downloadFilesDto: DownloadFilesDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFiles(downloadFilesDto, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadLibrary(name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadLibrary(name, skip, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * Get all AssetEntity belong to the user * @param {string} [userId] @@ -6025,6 +5920,20 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getDownloadInfo(assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {boolean} [isFavorite] @@ -6172,6 +6081,16 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath deleteAsset(deleteAssetDto: DeleteAssetDto, options?: any): AxiosPromise> { return localVarFp.deleteAsset(deleteAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetIdsDto} assetIdsDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: any): AxiosPromise { + return localVarFp.downloadArchive(assetIdsDto, key, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id @@ -6182,27 +6101,6 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath downloadFile(id: string, key?: string, options?: any): AxiosPromise { return localVarFp.downloadFile(id, key, options).then((request) => request(axios, basePath)); }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadFiles(downloadFilesDto: DownloadFilesDto, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadFiles(downloadFilesDto, key, options).then((request) => request(axios, basePath)); - }, - /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadLibrary(name?: string, skip?: number, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadLibrary(name, skip, key, options).then((request) => request(axios, basePath)); - }, /** * Get all AssetEntity belong to the user * @param {string} [userId] @@ -6296,6 +6194,19 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getCuratedObjects(options?: any): AxiosPromise> { return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath)); }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDownloadInfo(assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: any): AxiosPromise { + return localVarFp.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options).then((request) => request(axios, basePath)); + }, /** * * @param {boolean} [isFavorite] @@ -6454,6 +6365,27 @@ export interface AssetApiDeleteAssetRequest { readonly deleteAssetDto: DeleteAssetDto } +/** + * Request parameters for downloadArchive operation in AssetApi. + * @export + * @interface AssetApiDownloadArchiveRequest + */ +export interface AssetApiDownloadArchiveRequest { + /** + * + * @type {AssetIdsDto} + * @memberof AssetApiDownloadArchive + */ + readonly assetIdsDto: AssetIdsDto + + /** + * + * @type {string} + * @memberof AssetApiDownloadArchive + */ + readonly key?: string +} + /** * Request parameters for downloadFile operation in AssetApi. * @export @@ -6475,55 +6407,6 @@ export interface AssetApiDownloadFileRequest { readonly key?: string } -/** - * Request parameters for downloadFiles operation in AssetApi. - * @export - * @interface AssetApiDownloadFilesRequest - */ -export interface AssetApiDownloadFilesRequest { - /** - * - * @type {DownloadFilesDto} - * @memberof AssetApiDownloadFiles - */ - readonly downloadFilesDto: DownloadFilesDto - - /** - * - * @type {string} - * @memberof AssetApiDownloadFiles - */ - readonly key?: string -} - -/** - * Request parameters for downloadLibrary operation in AssetApi. - * @export - * @interface AssetApiDownloadLibraryRequest - */ -export interface AssetApiDownloadLibraryRequest { - /** - * - * @type {string} - * @memberof AssetApiDownloadLibrary - */ - readonly name?: string - - /** - * - * @type {number} - * @memberof AssetApiDownloadLibrary - */ - readonly skip?: number - - /** - * - * @type {string} - * @memberof AssetApiDownloadLibrary - */ - readonly key?: string -} - /** * Request parameters for getAllAssets operation in AssetApi. * @export @@ -6650,6 +6533,48 @@ export interface AssetApiGetAssetThumbnailRequest { readonly key?: string } +/** + * Request parameters for getDownloadInfo operation in AssetApi. + * @export + * @interface AssetApiGetDownloadInfoRequest + */ +export interface AssetApiGetDownloadInfoRequest { + /** + * + * @type {Array} + * @memberof AssetApiGetDownloadInfo + */ + readonly assetIds?: Array + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly albumId?: string + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly userId?: string + + /** + * + * @type {number} + * @memberof AssetApiGetDownloadInfo + */ + readonly archiveSize?: number + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly key?: string +} + /** * Request parameters for getMapMarkers operation in AssetApi. * @export @@ -6953,6 +6878,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiDownloadArchiveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public downloadArchive(requestParameters: AssetApiDownloadArchiveRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).downloadArchive(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiDownloadFileRequest} requestParameters Request parameters. @@ -6964,28 +6900,6 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {AssetApiDownloadFilesRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AssetApi - */ - public downloadFiles(requestParameters: AssetApiDownloadFilesRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).downloadFiles(requestParameters.downloadFilesDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * Current this is not used in any UI element - * @param {AssetApiDownloadLibraryRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AssetApi - */ - public downloadLibrary(requestParameters: AssetApiDownloadLibraryRequest = {}, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).downloadLibrary(requestParameters.name, requestParameters.skip, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - /** * Get all AssetEntity belong to the user * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters. @@ -7091,6 +7005,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getCuratedObjects(options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiGetDownloadInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getDownloadInfo(requestParameters.assetIds, requestParameters.albumId, requestParameters.userId, requestParameters.archiveSize, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiGetMapMarkersRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 1183af14d..9d3933be6 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -3,7 +3,6 @@ import { afterNavigate, goto } from '$app/navigation'; import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; - import { downloadAssets } from '$lib/stores/download'; import { locale } from '$lib/stores/preferences.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { @@ -45,6 +44,7 @@ import ThumbnailSelection from './thumbnail-selection.svelte'; import UserSelectionModal from './user-selection-modal.svelte'; import { handleError } from '../../utils/handle-error'; + import { downloadArchive } from '../../utils/asset-utils'; export let album: AlbumResponseDto; export let sharedLink: SharedLinkResponseDto | undefined = undefined; @@ -242,78 +242,12 @@ }; const downloadAlbum = async () => { - try { - let skip = 0; - let count = 0; - let done = false; - - while (!done) { - count++; - - const fileName = album.albumName + `${count === 1 ? '' : count}.zip`; - - $downloadAssets[fileName] = 0; - - let total = 0; - - const { data, status, headers } = await api.albumApi.downloadArchive( - { id: album.id, skip: skip || undefined, key: sharedLink?.key }, - { - responseType: 'blob', - onDownloadProgress: function (progressEvent) { - const request = this as XMLHttpRequest; - if (!total) { - total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0; - } - - if (total) { - const current = progressEvent.loaded; - $downloadAssets[fileName] = Math.floor((current / total) * 100); - } - } - } - ); - - const isNotComplete = headers['x-immich-archive-complete'] === 'false'; - const fileCount = Number(headers['x-immich-archive-file-count']) || 0; - if (isNotComplete && fileCount > 0) { - skip += fileCount; - } else { - done = true; - } - - if (!(data instanceof Blob)) { - return; - } - - if (status === 200) { - const fileUrl = URL.createObjectURL(data); - const anchor = document.createElement('a'); - anchor.href = fileUrl; - anchor.download = fileName; - - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - - URL.revokeObjectURL(fileUrl); - - // Remove item from download list - setTimeout(() => { - const copy = $downloadAssets; - delete copy[fileName]; - $downloadAssets = copy; - }, 2000); - } - } - } catch (e) { - $downloadAssets = {}; - console.error('Error downloading file ', e); - notificationController.show({ - type: NotificationType.Error, - message: 'Error downloading file, check console for more details.' - }); - } + await downloadArchive( + `${album.albumName}.zip`, + { albumId: album.id }, + undefined, + sharedLink?.key + ); }; const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => { @@ -360,7 +294,7 @@ > {#if sharedLink?.allowDownload || !isPublicShared} - + {/if} {#if isOwned} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 6192518ba..0a073f63f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,6 +1,5 @@ diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 65d0bcea5..97e123bf6 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -1,7 +1,7 @@ -
-
-

- Confirm User Deletion -

-
-
-

- {user.firstName} - {user.lastName} account and assets along will be marked to delete completely after 7 days. are - you sure you want to proceed ? -

- -
- + + +
+

+ {user.firstName} {user.lastName}'s account and assets will be permanently deleted + after 7 days. +

+

Are you sure you want to continue?

-
-
+ + diff --git a/web/src/lib/components/admin-page/restore-dialoge.svelte b/web/src/lib/components/admin-page/restore-dialoge.svelte index 6573e04f6..d78006413 100644 --- a/web/src/lib/components/admin-page/restore-dialoge.svelte +++ b/web/src/lib/components/admin-page/restore-dialoge.svelte @@ -1,7 +1,7 @@ -
-
-

- Restore User -

-
-
-

- {user.firstName} - {user.lastName} account will restored -

- -
- -
-
-
+ +

{user.firstName} {user.lastName}'s account will be restored.

+
+ diff --git a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte index 1a8d238b4..37f740d43 100644 --- a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte +++ b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte @@ -4,12 +4,9 @@ -
-

- Are you sure you want to disable all login methods? Login will be completely disabled. -

- -

+

+

Are you sure you want to disable all login methods? Login will be completely disabled.

+

To re-enable, use a { - if ( - window.confirm( - `Are you sure you want to delete album ${album.albumName}? If the album is shared, other users will not be able to access it.` - ) - ) { - try { - await api.albumApi.deleteAlbum({ id: album.id }); - goto(backUrl); - } catch (e) { - console.error('Error [userDeleteMenu] ', e); - notificationController.show({ - type: NotificationType.Error, - message: 'Error deleting album, check console for more details' - }); - } + try { + await api.albumApi.deleteAlbum({ id: album.id }); + goto(backUrl); + } catch (e) { + console.error('Error [userDeleteMenu] ', e); + notificationController.show({ + type: NotificationType.Error, + message: 'Error deleting album, check console for more details' + }); + } finally { + isShowDeleteConfirmation = false; } }; @@ -348,7 +346,11 @@ on:click={() => (isShowShareUserSelection = true)} logo={ShareVariantOutline} /> - + (isShowDeleteConfirmation = true)} + logo={DeleteOutline} + /> {/if} {/if} @@ -515,3 +517,17 @@ on:thumbnail-selected={setAlbumThumbnailHandler} /> {/if} + +{#if isShowDeleteConfirmation} + (isShowDeleteConfirmation = false)} + > + +

Are you sure you want to delete the album {album.albumName}?

+

If this album is shared, other users will not be able to access it anymore.

+ + +{/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 0a073f63f..358572001 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -21,6 +21,7 @@ import DetailPanel from './detail-panel.svelte'; import PhotoViewer from './photo-viewer.svelte'; import VideoViewer from './video-viewer.svelte'; + import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; import { assetStore } from '$lib/stores/assets.store'; import { isShowDetail } from '$lib/stores/preferences.store'; @@ -37,6 +38,7 @@ let halfRightHover = false; let appearsInAlbums: AlbumResponseDto[] = []; let isShowAlbumPicker = false; + let isShowDeleteConfirmation = false; let addToSharedAlbum = true; let shouldPlayMotionPhoto = false; let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true; @@ -77,7 +79,7 @@ closeViewer(); return; case 'Delete': - deleteAsset(); + isShowDeleteConfirmation = true; return; case 'i': $isShowDetail = !$isShowDetail; @@ -116,23 +118,17 @@ const deleteAsset = async () => { try { - if ( - window.confirm( - `Caution! Are you sure you want to delete this asset? This step also deletes this asset in the album(s) to which it belongs. You can not undo this action!` - ) - ) { - const { data: deletedAssets } = await api.assetApi.deleteAsset({ - deleteAssetDto: { - ids: [asset.id] - } - }); + const { data: deletedAssets } = await api.assetApi.deleteAsset({ + deleteAssetDto: { + ids: [asset.id] + } + }); - navigateAssetForward(); + navigateAssetForward(); - for (const asset of deletedAssets) { - if (asset.status == 'SUCCESS') { - assetStore.removeAsset(asset.id); - } + for (const asset of deletedAssets) { + if (asset.status == 'SUCCESS') { + assetStore.removeAsset(asset.id); } } } catch (e) { @@ -140,7 +136,9 @@ type: NotificationType.Error, message: 'Error deleting this asset, check console for more details' }); - console.error('Error deleteSelectedAssetHandler', e); + console.error('Error deleteAsset', e); + } finally { + isShowDeleteConfirmation = false; } }; @@ -227,6 +225,17 @@ }); } }; + + const getAssetType = () => { + switch (asset.type) { + case 'IMAGE': + return 'Photo'; + case 'VIDEO': + return 'Video'; + default: + return 'Asset'; + } + };
downloadFile(asset, publicSharedKey)} - on:delete={deleteAsset} + on:delete={() => (isShowDeleteConfirmation = true)} on:favorite={toggleFavorite} on:addToAlbum={() => openAlbumPicker(false)} on:addToSharedAlbum={() => openAlbumPicker(true)} @@ -358,6 +367,23 @@ on:close={() => (isShowAlbumPicker = false)} /> {/if} + + {#if isShowDeleteConfirmation} + (isShowDeleteConfirmation = false)} + > + +

+ Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove + it from its album(s). +

+

You cannot undo this action!

+
+
+ {/if}
diff --git a/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte b/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte index e82d9b86f..d1b6aa4d9 100644 --- a/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte +++ b/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte @@ -1,26 +1,26 @@
-
- {#if showResetToDefault} - - {/if} -
+
+ {#if showResetToDefault} + + {/if} +
-
- - -
+
+ + +
diff --git a/web/src/lib/components/admin-page/settings/setting-input-field.svelte b/web/src/lib/components/admin-page/settings/setting-input-field.svelte index 28ac4ca00..931fb896d 100644 --- a/web/src/lib/components/admin-page/settings/setting-input-field.svelte +++ b/web/src/lib/components/admin-page/settings/setting-input-field.svelte @@ -1,65 +1,65 @@
-
- - {#if required} -
*
- {/if} +
+ + {#if required} +
*
+ {/if} - {#if isEdited} -
- Unsaved change -
- {/if} -
+ {#if isEdited} +
+ Unsaved change +
+ {/if} +
- {#if desc} -

- {desc} -

- {/if} + {#if desc} +

+ {desc} +

+ {/if} - +
diff --git a/web/src/lib/components/admin-page/settings/setting-select.svelte b/web/src/lib/components/admin-page/settings/setting-select.svelte index ed9e297a4..2a40a910d 100644 --- a/web/src/lib/components/admin-page/settings/setting-select.svelte +++ b/web/src/lib/components/admin-page/settings/setting-select.svelte @@ -1,49 +1,49 @@
-
- +
+ - {#if isEdited} -
- Unsaved change -
- {/if} -
+ {#if isEdited} +
+ Unsaved change +
+ {/if} +
- {#if desc} -

- {desc} -

- {/if} + {#if desc} +

+ {desc} +

+ {/if} - +
diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte index 22c149c9e..205ee0e24 100644 --- a/web/src/lib/components/admin-page/settings/setting-switch.svelte +++ b/web/src/lib/components/admin-page/settings/setting-switch.svelte @@ -1,96 +1,90 @@
-
-
- - {#if isEdited} -
- Unsaved change -
- {/if} -
+
+
+ + {#if isEdited} +
+ Unsaved change +
+ {/if} +
-

{subtitle}

-
+

{subtitle}

+
-
diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index eae24ffe8..a405d42d9 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -1,241 +1,224 @@
- {#await getConfigs() then} - + {/await}
diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte index d30798fb1..587ef3e0f 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte @@ -1,78 +1,76 @@
-

DATE & TIME

+

DATE & TIME

-
-

Asset's creation timestamp is used for the datetime information

-

Sample time 2022-09-04T20:03:05.250

-
-
-
-

YEAR

-
    - {#each options.yearOptions as yearFormat} -
  • {'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}
  • - {/each} -
-
+
+

Asset's creation timestamp is used for the datetime information

+

Sample time 2022-09-04T20:03:05.250

+
+
+
+

YEAR

+
    + {#each options.yearOptions as yearFormat} +
  • {'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}
  • + {/each} +
+
-
-

MONTH

-
    - {#each options.monthOptions as monthFormat} -
  • {'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}
  • - {/each} -
-
+
+

MONTH

+
    + {#each options.monthOptions as monthFormat} +
  • {'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}
  • + {/each} +
+
-
-

DAY

-
    - {#each options.dayOptions as dayFormat} -
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • - {/each} -
-
+
+

DAY

+
    + {#each options.dayOptions as dayFormat} +
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • + {/each} +
+
-
-

HOUR

-
    - {#each options.hourOptions as dayFormat} -
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • - {/each} -
-
+
+

HOUR

+
    + {#each options.hourOptions as dayFormat} +
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • + {/each} +
+
-
-

MINUTE

-
    - {#each options.minuteOptions as dayFormat} -
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • - {/each} -
-
+
+

MINUTE

+
    + {#each options.minuteOptions as dayFormat} +
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • + {/each} +
+
-
-

SECOND

-
    - {#each options.secondOptions as dayFormat} -
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • - {/each} -
-
-
+
+

SECOND

+
    + {#each options.secondOptions as dayFormat} +
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • + {/each} +
+
+
diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte index a65c2bd7e..e9d815aa6 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte @@ -1,29 +1,29 @@
-

OTHER VARIABLES

+

OTHER VARIABLES

-
-
-

FILE NAME

-
    -
  • {`{{filename}}`}
  • -
-
+
+
+

FILE NAME

+
    +
  • {`{{filename}}`}
  • +
+
-
-

FILE EXTENSION

-
    -
  • {`{{ext}}`}
  • -
-
+
+

FILE EXTENSION

+
    +
  • {`{{ext}}`}
  • +
+
-
-

FILE TYPE

-
    -
  • {`{{filetype}}`} - VID or IMG
  • -
  • {`{{filetypefull}}`} - VIDEO or IMAGE
  • -
-
-
+
+

FILE TYPE

+
    +
  • {`{{filetype}}`} - VID or IMG
  • +
  • {`{{filetypefull}}`} - VIDEO or IMAGE
  • +
+
+
diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index ba7b194c5..33c8c48dd 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -1,141 +1,136 @@ -import { jest, describe, it } from '@jest/globals'; -import { render, RenderResult, waitFor, fireEvent } from '@testing-library/svelte'; import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock'; import { api, ThumbnailFormat } from '@api'; +import { describe, it, jest } from '@jest/globals'; import { albumFactory } from '@test-data'; -import AlbumCard from '../album-card.svelte'; import '@testing-library/jest-dom'; +import { fireEvent, render, RenderResult, waitFor } from '@testing-library/svelte'; +import AlbumCard from '../album-card.svelte'; jest.mock('@api'); const apiMock: jest.MockedObject = api as jest.MockedObject; describe('AlbumCard component', () => { - let sut: RenderResult; + let sut: RenderResult; - it.each([ - { - album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }), - count: 0, - shared: false - }, - { - album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }), - count: 0, - shared: true - }, - { - album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }), - count: 5, - shared: false - }, - { - album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }), - count: 2, - shared: true - } - ])( - 'shows album data without thumbnail with count $count - shared: $shared', - async ({ album, count, shared }) => { - sut = render(AlbumCard, { album, user: album.owner }); + it.each([ + { + album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }), + count: 0, + shared: false, + }, + { + album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }), + count: 0, + shared: true, + }, + { + album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }), + count: 5, + shared: false, + }, + { + album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }), + count: 2, + shared: true, + }, + ])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => { + sut = render(AlbumCard, { album, user: album.owner }); - const albumImgElement = sut.getByTestId('album-image'); - const albumNameElement = sut.getByTestId('album-name'); - const albumDetailsElement = sut.getByTestId('album-details'); - const detailsText = `${count} items` + (shared ? ' . Shared' : ''); + const albumImgElement = sut.getByTestId('album-image'); + const albumNameElement = sut.getByTestId('album-name'); + const albumDetailsElement = sut.getByTestId('album-details'); + const detailsText = `${count} items` + (shared ? ' . Shared' : ''); - expect(albumImgElement).toHaveAttribute('src'); - expect(albumImgElement).toHaveAttribute('alt', album.id); + expect(albumImgElement).toHaveAttribute('src'); + expect(albumImgElement).toHaveAttribute('alt', album.id); - await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); + await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); - expect(albumImgElement).toHaveAttribute('alt', album.id); - expect(apiMock.assetApi.getAssetThumbnail).not.toHaveBeenCalled(); + expect(albumImgElement).toHaveAttribute('alt', album.id); + expect(apiMock.assetApi.getAssetThumbnail).not.toHaveBeenCalled(); - expect(albumNameElement).toHaveTextContent(album.albumName); - expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText)); - } - ); + expect(albumNameElement).toHaveTextContent(album.albumName); + expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText)); + }); - it('shows album data and and loads the thumbnail image when available', async () => { - const thumbnailFile = new File([new Blob()], 'fileThumbnail'); - const thumbnailUrl = 'blob:thumbnailUrlOne'; - apiMock.assetApi.getAssetThumbnail.mockResolvedValue({ - data: thumbnailFile, - config: {}, - headers: {}, - status: 200, - statusText: '' - }); - createObjectURLMock.mockReturnValueOnce(thumbnailUrl); + it('shows album data and and loads the thumbnail image when available', async () => { + const thumbnailFile = new File([new Blob()], 'fileThumbnail'); + const thumbnailUrl = 'blob:thumbnailUrlOne'; + apiMock.assetApi.getAssetThumbnail.mockResolvedValue({ + data: thumbnailFile, + config: {}, + headers: {}, + status: 200, + statusText: '', + }); + createObjectURLMock.mockReturnValueOnce(thumbnailUrl); - const album = albumFactory.build({ - albumThumbnailAssetId: 'thumbnailIdOne', - shared: false, - albumName: 'some album name' - }); - sut = render(AlbumCard, { album, user: album.owner }); + const album = albumFactory.build({ + albumThumbnailAssetId: 'thumbnailIdOne', + shared: false, + albumName: 'some album name', + }); + sut = render(AlbumCard, { album, user: album.owner }); - const albumImgElement = sut.getByTestId('album-image'); - const albumNameElement = sut.getByTestId('album-name'); - const albumDetailsElement = sut.getByTestId('album-details'); - expect(albumImgElement).toHaveAttribute('alt', album.id); + const albumImgElement = sut.getByTestId('album-image'); + const albumNameElement = sut.getByTestId('album-name'); + const albumDetailsElement = sut.getByTestId('album-details'); + expect(albumImgElement).toHaveAttribute('alt', album.id); - await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl)); + await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl)); - expect(albumImgElement).toHaveAttribute('alt', album.id); - expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledTimes(1); - expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith( - { - id: 'thumbnailIdOne', - format: ThumbnailFormat.Jpeg - }, - { responseType: 'blob' } - ); - expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile); + expect(albumImgElement).toHaveAttribute('alt', album.id); + expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledTimes(1); + expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith( + { + id: 'thumbnailIdOne', + format: ThumbnailFormat.Jpeg, + }, + { responseType: 'blob' }, + ); + expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile); - expect(albumNameElement).toHaveTextContent('some album name'); - expect(albumDetailsElement).toHaveTextContent('0 items'); - }); + expect(albumNameElement).toHaveTextContent('some album name'); + expect(albumDetailsElement).toHaveTextContent('0 items'); + }); - describe('with rendered component - no thumbnail', () => { - const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null })); + describe('with rendered component - no thumbnail', () => { + const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null })); - beforeEach(async () => { - sut = render(AlbumCard, { album, user: album.owner }); + beforeEach(async () => { + sut = render(AlbumCard, { album, user: album.owner }); - const albumImgElement = sut.getByTestId('album-image'); - await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); - }); + const albumImgElement = sut.getByTestId('album-image'); + await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); + }); - it('dispatches custom "click" event with the album in context', async () => { - const onClickHandler = jest.fn(); - sut.component.$on('click', onClickHandler); - const albumCardElement = sut.getByTestId('album-card'); + it('dispatches custom "click" event with the album in context', async () => { + const onClickHandler = jest.fn(); + sut.component.$on('click', onClickHandler); + const albumCardElement = sut.getByTestId('album-card'); - await fireEvent.click(albumCardElement); - expect(onClickHandler).toHaveBeenCalledTimes(1); - expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album })); - }); + await fireEvent.click(albumCardElement); + expect(onClickHandler).toHaveBeenCalledTimes(1); + expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album })); + }); - it('dispatches custom "click" event on context menu click with mouse coordinates', async () => { - const onClickHandler = jest.fn(); - sut.component.$on('showalbumcontextmenu', onClickHandler); + it('dispatches custom "click" event on context menu click with mouse coordinates', async () => { + const onClickHandler = jest.fn(); + sut.component.$on('showalbumcontextmenu', onClickHandler); - const contextMenuBtnParent = sut.getByTestId('context-button-parent'); + const contextMenuBtnParent = sut.getByTestId('context-button-parent'); - await fireEvent( - contextMenuBtnParent, - new MouseEvent('click', { - clientX: 123, - clientY: 456 - }) - ); + await fireEvent( + contextMenuBtnParent, + new MouseEvent('click', { + clientX: 123, + clientY: 456, + }), + ); - expect(onClickHandler).toHaveBeenCalledTimes(1); - expect(onClickHandler).toHaveBeenCalledWith( - expect.objectContaining({ detail: { x: 123, y: 456 } }) - ); - }); - }); + expect(onClickHandler).toHaveBeenCalledTimes(1); + expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: { x: 123, y: 456 } })); + }); + }); }); diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte index 17ec88676..62f350f74 100644 --- a/web/src/lib/components/album-page/album-card.svelte +++ b/web/src/lib/components/album-page/album-card.svelte @@ -1,133 +1,133 @@
dispatchClick('click', album)} - on:keydown={() => dispatchClick('click', album)} - data-testid="album-card" + class="group hover:cursor-pointer mt-4 border-[3px] border-transparent dark:hover:border-immich-dark-primary/75 hover:border-immich-primary/75 rounded-3xl p-5 relative" + on:click={() => dispatchClick('click', album)} + on:keydown={() => dispatchClick('click', album)} + data-testid="album-card" > - - {#if showContextMenu} -
- - - -
- {/if} + + {#if showContextMenu} +
+ + + +
+ {/if} -
- {album.id} -
-
+
+ {album.id} +
+
-
-

- {album.albumName} -

+
+

+ {album.albumName} +

- - {#if showItemCount} -

- {album.assetCount.toLocaleString($locale)} - {album.assetCount == 1 ? `item` : `items`} -

- {/if} + + {#if showItemCount} +

+ {album.assetCount.toLocaleString($locale)} + {album.assetCount == 1 ? `item` : `items`} +

+ {/if} - {#if isSharingView || album.shared} -

·

- {/if} + {#if isSharingView || album.shared} +

·

+ {/if} - {#if isSharingView} - {#await getAlbumOwnerInfo() then albumOwner} - {#if user.email == albumOwner.email} -

Owned

- {:else} -

- Shared by {albumOwner.firstName} - {albumOwner.lastName} -

- {/if} - {/await} - {:else if album.shared} -

Shared

- {/if} -
-
+ {#if isSharingView} + {#await getAlbumOwnerInfo() then albumOwner} + {#if user.email == albumOwner.email} +

Owned

+ {:else} +

+ Shared by {albumOwner.firstName} + {albumOwner.lastName} +

+ {/if} + {/await} + {:else if album.shared} +

Shared

+ {/if} + +
diff --git a/web/src/lib/components/album-page/album-card.ts b/web/src/lib/components/album-page/album-card.ts index c69b7e6c3..4d395d3c5 100644 --- a/web/src/lib/components/album-page/album-card.ts +++ b/web/src/lib/components/album-page/album-card.ts @@ -1,11 +1,11 @@ import type { AlbumResponseDto } from '@api'; export type OnShowContextMenu = { - showalbumcontextmenu: OnShowContextMenuDetail; + showalbumcontextmenu: OnShowContextMenuDetail; }; export type OnClick = { - click: OnClickDetail; + click: OnClickDetail; }; export type OnShowContextMenuDetail = { x: number; y: number }; diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 4b0c28cc1..c56a579b9 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,533 +1,490 @@
- - {#if isMultiSelectionMode} - (multiSelectAsset = new Set())} - > - - {#if sharedLink?.allowDownload || !isPublicShared} - - {/if} - {#if isOwned} - - {/if} - - {/if} + + {#if isMultiSelectionMode} + (multiSelectAsset = new Set())}> + + {#if sharedLink?.allowDownload || !isPublicShared} + + {/if} + {#if isOwned} + + {/if} + + {/if} - - {#if !isMultiSelectionMode} - goto(backUrl)} - backIcon={ArrowLeft} - showBackButton={(!isPublicShared && isOwned) || - (!isPublicShared && !isOwned) || - (isPublicShared && isOwned)} - > - - {#if isPublicShared && !isOwned} - - -

- IMMICH -

-
- {/if} -
+ + {#if !isMultiSelectionMode} + goto(backUrl)} + backIcon={ArrowLeft} + showBackButton={(!isPublicShared && isOwned) || (!isPublicShared && !isOwned) || (isPublicShared && isOwned)} + > + + {#if isPublicShared && !isOwned} + + +

IMMICH

+
+ {/if} +
- - {#if !isCreatingSharedAlbum} - {#if !sharedLink} - (isShowAssetSelection = true)} - logo={FileImagePlusOutline} - /> - {:else if sharedLink?.allowUpload} - openFileUploadDialog(album.id, sharedLink?.key)} - logo={FileImagePlusOutline} - /> - {/if} + + {#if !isCreatingSharedAlbum} + {#if !sharedLink} + (isShowAssetSelection = true)} + logo={FileImagePlusOutline} + /> + {:else if sharedLink?.allowUpload} + openFileUploadDialog(album.id, sharedLink?.key)} + logo={FileImagePlusOutline} + /> + {/if} - {#if isOwned} - (isShowShareUserSelection = true)} - logo={ShareVariantOutline} - /> - (isShowDeleteConfirmation = true)} - logo={DeleteOutline} - /> - {/if} - {/if} + {#if isOwned} + (isShowShareUserSelection = true)} + logo={ShareVariantOutline} + /> + (isShowDeleteConfirmation = true)} + logo={DeleteOutline} + /> + {/if} + {/if} - {#if album.assetCount > 0 && !isCreatingSharedAlbum} - {#if !isPublicShared || (isPublicShared && sharedLink?.allowDownload)} - downloadAlbum()} - logo={FolderDownloadOutline} - /> - {/if} + {#if album.assetCount > 0 && !isCreatingSharedAlbum} + {#if !isPublicShared || (isPublicShared && sharedLink?.allowDownload)} + downloadAlbum()} logo={FolderDownloadOutline} /> + {/if} - {#if !isPublicShared && isOwned} - - {#if isShowAlbumOptions} - (isShowAlbumOptions = false)} - > - { - isShowThumbnailSelection = true; - isShowAlbumOptions = false; - }} - text="Set album cover" - /> - - {/if} - - {/if} - {/if} + {#if !isPublicShared && isOwned} + + {#if isShowAlbumOptions} + (isShowAlbumOptions = false)}> + { + isShowThumbnailSelection = true; + isShowAlbumOptions = false; + }} + text="Set album cover" + /> + + {/if} + + {/if} + {/if} - {#if isPublicShared} - - {/if} + {#if isPublicShared} + + {/if} - {#if isCreatingSharedAlbum && album.sharedUsers.length == 0} - - {/if} - -
- {/if} + {#if isCreatingSharedAlbum && album.sharedUsers.length == 0} + + {/if} + +
+ {/if} -
- { - if (e.key == 'Enter') { - isEditingTitle = false; - titleInput.blur(); - } - }} - on:focus={() => (isEditingTitle = true)} - on:blur={() => (isEditingTitle = false)} - class={`transition-all text-6xl text-immich-primary dark:text-immich-dark-primary w-[99%] border-b-2 border-transparent outline-none ${ - isOwned ? 'hover:border-gray-400' : 'hover:border-transparent' - } focus:outline-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary bg-immich-bg dark:bg-immich-dark-bg dark:focus:bg-immich-dark-gray`} - type="text" - bind:value={album.albumName} - disabled={!isOwned} - bind:this={titleInput} - /> +
+ { + if (e.key == 'Enter') { + isEditingTitle = false; + titleInput.blur(); + } + }} + on:focus={() => (isEditingTitle = true)} + on:blur={() => (isEditingTitle = false)} + class={`transition-all text-6xl text-immich-primary dark:text-immich-dark-primary w-[99%] border-b-2 border-transparent outline-none ${ + isOwned ? 'hover:border-gray-400' : 'hover:border-transparent' + } focus:outline-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary bg-immich-bg dark:bg-immich-dark-bg dark:focus:bg-immich-dark-gray`} + type="text" + bind:value={album.albumName} + disabled={!isOwned} + bind:this={titleInput} + /> - {#if album.assetCount > 0} - -

{getDateRange()}

-

·

-

{album.assetCount} items

-
- {/if} - {#if album.shared} -
- {#each album.sharedUsers as user (user.id)} - - {/each} + {#if album.assetCount > 0} + +

{getDateRange()}

+

·

+

{album.assetCount} items

+
+ {/if} + {#if album.shared} +
+ {#each album.sharedUsers as user (user.id)} + + {/each} - -
- {/if} + +
+ {/if} - {#if album.assetCount > 0} - - {:else} - -
-
-

ADD PHOTOS

- -
-
- {/if} -
+ {#if album.assetCount > 0} + + {:else} + +
+
+

ADD PHOTOS

+ +
+
+ {/if} +
{#if isShowAssetSelection} - (isShowAssetSelection = false)} - on:create-album={createAlbumHandler} - /> + (isShowAssetSelection = false)} + on:create-album={createAlbumHandler} + /> {/if} {#if isShowShareUserSelection} - (isShowShareUserSelection = false)} - on:add-user={addUserHandler} - on:sharedlinkclick={onSharedLinkClickHandler} - sharedUsersInAlbum={new Set(album.sharedUsers)} - /> + (isShowShareUserSelection = false)} + on:add-user={addUserHandler} + on:sharedlinkclick={onSharedLinkClickHandler} + sharedUsersInAlbum={new Set(album.sharedUsers)} + /> {/if} {#if isShowShareLinkModal} - (isShowShareLinkModal = false)} - shareType={SharedLinkType.Album} - {album} - /> + (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} /> {/if} {#if isShowShareInfoModal} - (isShowShareInfoModal = false)} - {album} - on:user-deleted={sharedUserDeletedHandler} - /> + (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} /> {/if} {#if isShowThumbnailSelection} - (isShowThumbnailSelection = false)} - on:thumbnail-selected={setAlbumThumbnailHandler} - /> + (isShowThumbnailSelection = false)} + on:thumbnail-selected={setAlbumThumbnailHandler} + /> {/if} {#if isShowDeleteConfirmation} - (isShowDeleteConfirmation = false)} - > - -

Are you sure you want to delete the album {album.albumName}?

-

If this album is shared, other users will not be able to access it anymore.

-
-
+ (isShowDeleteConfirmation = false)} + > + +

Are you sure you want to delete the album {album.albumName}?

+

If this album is shared, other users will not be able to access it anymore.

+
+
{/if} diff --git a/web/src/lib/components/album-page/asset-selection.svelte b/web/src/lib/components/album-page/asset-selection.svelte index 6282da2c3..054ed7c4c 100644 --- a/web/src/lib/components/album-page/asset-selection.svelte +++ b/web/src/lib/components/album-page/asset-selection.svelte @@ -1,80 +1,69 @@
- { - assetInteractionStore.clearMultiselect(); - dispatch('go-back'); - }} - > - - {#if $selectedAssets.size == 0} -

Add to album

- {:else} -

- {$selectedAssets.size.toLocaleString($locale)} selected -

- {/if} -
+ { + assetInteractionStore.clearMultiselect(); + dispatch('go-back'); + }} + > + + {#if $selectedAssets.size == 0} +

Add to album

+ {:else} +

+ {$selectedAssets.size.toLocaleString($locale)} selected +

+ {/if} +
- - - - -
-
- -
+ + + + +
+
+ +
diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte index 593e93b34..f93bd3393 100644 --- a/web/src/lib/components/album-page/share-info-modal.svelte +++ b/web/src/lib/components/album-page/share-info-modal.svelte @@ -1,144 +1,140 @@ {#if !selectedRemoveUser} - dispatch('close')}> - - -

Options

-
-
+ dispatch('close')}> + + +

Options

+
+
-
- {#each album.sharedUsers as user} -
-
- -

{user.firstName} {user.lastName}

-
+
+ {#each album.sharedUsers as user} +
+
+ +

{user.firstName} {user.lastName}

+
-
- {#if isOwned} -
- showContextMenu(user)} - logo={DotsVertical} - backgroundColor="transparent" - hoverColor="#e2e7e9" - size="20" - /> +
+ {#if isOwned} +
+ showContextMenu(user)} + logo={DotsVertical} + backgroundColor="transparent" + hoverColor="#e2e7e9" + size="20" + /> - {#if selectedMenuUser === user} - (selectedMenuUser = null)}> - - - {/if} -
- {:else if user.id == currentUser?.id} - - {/if} -
-
- {/each} -
- + {#if selectedMenuUser === user} + (selectedMenuUser = null)}> + + + {/if} +
+ {:else if user.id == currentUser?.id} + + {/if} +
+
+ {/each} +
+ {/if} {#if selectedRemoveUser && selectedRemoveUser?.id === currentUser?.id} - (selectedRemoveUser = null)} - /> + (selectedRemoveUser = null)} + /> {/if} {#if selectedRemoveUser && selectedRemoveUser?.id !== currentUser?.id} - (selectedRemoveUser = null)} - /> + (selectedRemoveUser = null)} + /> {/if} diff --git a/web/src/lib/components/album-page/thumbnail-selection.svelte b/web/src/lib/components/album-page/thumbnail-selection.svelte index 6486ad60e..0d9d5deb3 100644 --- a/web/src/lib/components/album-page/thumbnail-selection.svelte +++ b/web/src/lib/components/album-page/thumbnail-selection.svelte @@ -1,57 +1,53 @@
- dispatch('close')}> - -

Select album cover

-
+ dispatch('close')}> + +

Select album cover

+
- - - -
+ + + +
-
- -
- {#each album.assets as asset} - (selectedThumbnail = asset)} - selected={isSelected(asset.id)} - /> - {/each} -
-
+
+ +
+ {#each album.assets as asset} + (selectedThumbnail = asset)} selected={isSelected(asset.id)} /> + {/each} +
+
diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 207b9e440..3312dbd1d 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -1,149 +1,146 @@ dispatch('close')}> - - - -

Invite to album

-
-
+ + + +

Invite to album

+
+
-
- {#if selectedUsers.length > 0} -
-

To

+
+ {#if selectedUsers.length > 0} +
+

To

- {#each selectedUsers as user} - {#key user.id} - - {/key} - {/each} -
- {/if} + {#each selectedUsers as user} + {#key user.id} + + {/key} + {/each} +
+ {/if} - {#if users.length > 0} -

SUGGESTIONS

+ {#if users.length > 0} +

SUGGESTIONS

-
- {#each users as user} - - {/each} -
- {:else} -

- Looks like you have shared this album with all users or you don't have any user to share - with. -

- {/if} +
+

+ {user.firstName} + {user.lastName} +

+

+ {user.email} +

+
+ + {/each} +
+ {:else} +

+ Looks like you have shared this album with all users or you don't have any user to share with. +

+ {/if} - {#if selectedUsers.length > 0} -
- -
- {/if} -
+ {#if selectedUsers.length > 0} +
+ +
+ {/if} + -
-
- +
+
+ - {#if sharedLinks.length} - - {/if} -
+ {#if sharedLinks.length} + + {/if} +
diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index dab58f24f..2e8781732 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -1,56 +1,56 @@ diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 241ce2258..35bab9fa8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -1,155 +1,135 @@
-
- dispatch('goBack')} /> -
-
- {#if showMotionPlayButton} - {#if isMotionPhotoPlaying} - dispatch('stopMotionPhoto')} - /> - {:else} - dispatch('playMotionPhoto')} - /> - {/if} - {/if} - {#if showZoomButton} - 1 - ? MagnifyMinusOutline - : MagnifyPlusOutline} - title="Zoom Image" - on:click={() => { - const zoomImage = new CustomEvent('zoomImage'); - window.dispatchEvent(zoomImage); - }} - /> - {/if} - {#if showCopyButton} - { - const copyEvent = new CustomEvent('copyImage'); - window.dispatchEvent(copyEvent); - }} - /> - {/if} +
+ dispatch('goBack')} /> +
+
+ {#if showMotionPlayButton} + {#if isMotionPhotoPlaying} + dispatch('stopMotionPhoto')} + /> + {:else} + dispatch('playMotionPhoto')} + /> + {/if} + {/if} + {#if showZoomButton} + 1 ? MagnifyMinusOutline : MagnifyPlusOutline} + title="Zoom Image" + on:click={() => { + const zoomImage = new CustomEvent('zoomImage'); + window.dispatchEvent(zoomImage); + }} + /> + {/if} + {#if showCopyButton} + { + const copyEvent = new CustomEvent('copyImage'); + window.dispatchEvent(copyEvent); + }} + /> + {/if} - {#if showDownloadButton} - dispatch('download')} - title="Download" - /> - {/if} - dispatch('showDetail')} - title="Info" - /> - {#if isOwner} - dispatch('favorite')} - title="Favorite" - /> - {/if} + {#if showDownloadButton} + dispatch('download')} + title="Download" + /> + {/if} + dispatch('showDetail')} title="Info" /> + {#if isOwner} + dispatch('favorite')} + title="Favorite" + /> + {/if} - {#if isOwner} - dispatch('delete')} - title="Delete" - /> -
(isShowAssetOptions = false)}> - - {#if isShowAssetOptions} - - onMenuClick('addToAlbum')} text="Add to Album" /> - onMenuClick('addToSharedAlbum')} - text="Add to Shared Album" - /> + {#if isOwner} + dispatch('delete')} title="Delete" /> +
(isShowAssetOptions = false)}> + + {#if isShowAssetOptions} + + onMenuClick('addToAlbum')} text="Add to Album" /> + onMenuClick('addToSharedAlbum')} text="Add to Shared Album" /> - {#if isOwner} - dispatch('toggleArchive')} - text={asset.isArchived ? 'Unarchive' : 'Archive'} - /> - {/if} - - {/if} - -
- {/if} -
+ {#if isOwner} + dispatch('toggleArchive')} + text={asset.isArchived ? 'Unarchive' : 'Archive'} + /> + {/if} + + {/if} +
+
+ {/if} +
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 358572001..7de3b2f53 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,399 +1,386 @@
-
- downloadFile(asset, publicSharedKey)} - on:delete={() => (isShowDeleteConfirmation = true)} - on:favorite={toggleFavorite} - on:addToAlbum={() => openAlbumPicker(false)} - on:addToSharedAlbum={() => openAlbumPicker(true)} - on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)} - on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)} - on:toggleArchive={toggleArchive} - /> -
+
+ downloadFile(asset, publicSharedKey)} + on:delete={() => (isShowDeleteConfirmation = true)} + on:favorite={toggleFavorite} + on:addToAlbum={() => openAlbumPicker(false)} + on:addToSharedAlbum={() => openAlbumPicker(true)} + on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)} + on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)} + on:toggleArchive={toggleArchive} + /> +
- {#if showNavigation} -
{ - halfLeftHover = true; - halfRightHover = false; - }} - on:mouseleave={() => { - halfLeftHover = false; - }} - on:click={navigateAssetBackward} - on:keydown={navigateAssetBackward} - > - -
- {/if} + {#if showNavigation} +
{ + halfLeftHover = true; + halfRightHover = false; + }} + on:mouseleave={() => { + halfLeftHover = false; + }} + on:click={navigateAssetBackward} + on:keydown={navigateAssetBackward} + > + +
+ {/if} -
- {#key asset.id} - {#if !asset.resized} -
-
- -
-
- {:else if asset.type === AssetTypeEnum.Image} - {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} - (shouldPlayMotionPhoto = false)} - /> - {:else} - - {/if} - {:else} - - {/if} - {/key} -
+
+ {#key asset.id} + {#if !asset.resized} +
+
+ +
+
+ {:else if asset.type === AssetTypeEnum.Image} + {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} + (shouldPlayMotionPhoto = false)} + /> + {:else} + + {/if} + {:else} + + {/if} + {/key} +
- {#if showNavigation} -
{ - halfLeftHover = false; - halfRightHover = true; - }} - on:mouseleave={() => { - halfRightHover = false; - }} - > - -
- {/if} + {#if showNavigation} +
{ + halfLeftHover = false; + halfRightHover = true; + }} + on:mouseleave={() => { + halfRightHover = false; + }} + > + +
+ {/if} - {#if $isShowDetail} -
- ($isShowDetail = false)} - on:close-viewer={handleCloseViewer} - on:description-focus-in={disableKeyDownEvent} - on:description-focus-out={enableKeyDownEvent} - /> -
- {/if} + {#if $isShowDetail} +
+ ($isShowDetail = false)} + on:close-viewer={handleCloseViewer} + on:description-focus-in={disableKeyDownEvent} + on:description-focus-out={enableKeyDownEvent} + /> +
+ {/if} - {#if isShowAlbumPicker} - (isShowAlbumPicker = false)} - /> - {/if} + {#if isShowAlbumPicker} + (isShowAlbumPicker = false)} + /> + {/if} - {#if isShowDeleteConfirmation} - (isShowDeleteConfirmation = false)} - > - -

- Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove - it from its album(s). -

-

You cannot undo this action!

-
-
- {/if} + {#if isShowDeleteConfirmation} + (isShowDeleteConfirmation = false)} + > + +

+ Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove it from its + album(s). +

+

You cannot undo this action!

+
+
+ {/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 365c9bfa3..42bfb6de7 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -1,296 +1,293 @@
-
- +
+ -

Info

-
+

Info

+
-
-