diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33b6cf5cc..dac6b1f8a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: submodules: "recursive" - name: Run e2e tests - run: docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build + run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build doc-tests: name: Run documentation checks diff --git a/Makefile b/Makefile index a8b86d75c..2f9a50517 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ dev: dev-new: docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans +dev-down: + docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans + dev-new-update: docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans @@ -20,7 +23,7 @@ pull-stage: docker-compose -f ./docker/docker-compose.staging.yml pull test-e2e: - docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build + docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build prod: docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans @@ -32,4 +35,4 @@ api: cd ./server && npm run api:generate attach-server: - docker exec -it docker_immich-server_1 sh \ No newline at end of file + docker exec -it docker_immich-server_1 sh diff --git a/README.md b/README.md index 07cfab52e..3b9a692b1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Français Nederlands 日本語 + Italiano

## Disclaimer diff --git a/README_ca_ES.md b/README_ca_ES.md index 310b52656..05cd6ef9a 100644 --- a/README_ca_ES.md +++ b/README_ca_ES.md @@ -25,6 +25,7 @@ Français Nederlands 日本語 + Italiano

## Avís legal diff --git a/README_es_ES.md b/README_es_ES.md index 47313b4ce..5f88e9e37 100644 --- a/README_es_ES.md +++ b/README_es_ES.md @@ -24,6 +24,7 @@ Català Français 日本語 + Italiano

## Descargo de responsabilidad diff --git a/README_fr_FR.md b/README_fr_FR.md index 5d88c498b..b986b85f3 100644 --- a/README_fr_FR.md +++ b/README_fr_FR.md @@ -25,6 +25,7 @@ Français Nederlands 日本語 + Italiano

## Clause de non-responsabilité diff --git a/README_it_IT.md b/README_it_IT.md new file mode 100644 index 000000000..8eb342bc4 --- /dev/null +++ b/README_it_IT.md @@ -0,0 +1,113 @@ +

+
+ License: MIT + + + +
+
+

+ +

+ +

+

Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video

+
+ + + +
+

+ English + 中文 + Türkçe + Català + Español + Français + Nederlands + 日本語 +

+ +## Declino di responsabilità + +- ⚠️ Il progetto è in fase di sviluppo **molto avanzato**. +- ⚠️ Possibilità di bug e cambiamenti rilevanti. +- ⚠️ **Non utilizzare l'app come unico salvataggio delle tue foto e dei tuoi video.** +- ⚠️ Utilizza sempre una tecnica [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) di backup per le foto e i video a cui tieni! + +## Contenuto + +- [Documentazione Ufficiale](https://immich.app/docs) +- [Roadmap](https://github.com/orgs/immich-app/projects/1) +- [Demo](#demo) +- [Funzionalità](#features) +- [Introduzione](https://immich.app/docs/overview/introduction) +- [Installazione](https://immich.app/docs/install/requirements) +- [Linee Guida per Contribuire](https://immich.app/docs/overview/support-the-project) +- [Supporta il Progetto](#support-the-project) + +## Documentazione + +La documentazione ufficiale, inclusa la guida all'installazione, è disponibile qui: https://immich.app/. + +## Demo + +Prova la demo del progetto https://demo.immich.app + +Sull'app mobile, imposta `https://demo.immich.app/api` come `Server Endpoint URL` + +```bash title="Demo Credential" +Credenziali di accesso +email: demo@immich.app +password: demo +``` + +``` +Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM +``` + +# Funzionalità + +| Funzionalità | Mobile | Web | +| ---------------------------------------------- | ------ | --- | +| Caricamento e visualizzazione di foto e video | Sì | Sì | +| Backup automatico quando l'app è in esecuzione | Sì | N/A | +| Selezione degli album per backup | Sì | N/A | +| Download foto e video sul dispositivo | Sì | Sì | +| Supporto multi utente | Sì | Sì | +| Album e album condivisi | Sì | Sì | +| Barra di scorrimento con trascinamento | Sì | Sì | +| Supporto formati raw | Sì | Sì | +| Visualizzazione metadata (EXIF, map) | Sì | Sì | +| Ricerca per metadata, oggetti, volti e CLIP | Sì | Sì | +| Funzioni di amministrazione degli utenti | No | Sì | +| Backup in background | Sì | N/A | +| Scroll virtuale | Sì | Sì | +| Supporto OAuth | Sì | Sì | +| API Keys | N/A | Sì | +| Backup e riproduzione di LivePhoto | iOS | Sì | +| Archiviazione impostata dall'utente | Sì | Sì | +| Condivisione pubblica | No | Sì | +| Archivio e Preferiti | Sì | Sì | +| Mappa globale | Sì | Sì | +| Collaborazione con utenti | Sì | Sì | +| Riconoscimento facciale e categorizzazione | Sì | Sì | +| Ricordi (x anni fa) | Sì | Sì | +| Supporto offline | Sì | No | +| Galleria sola lettura | Sì | Sì | + +# Supporta il progetto + +Mi dedico al progetto e non smetterò di farlo. Manterrò aggiornata la documentazione, aggiungerò nuove funzioni e risolverò i bug, ma non posso farlo da solo. Ho bisogno del tuo aiuto che mi da motivazione per continuare. + +Come detto dal nostro host [selfhosted.show - Nell'episodio 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418), quello che il team ed io stiamo facendo è un lavoro enorme. Mi piacerebbe dedicarmi al progetto full-time e chiedo il tuo aiuto affinchè sia possibile. + +Se pensi che Immich sia una buona causa e che l'app sia qualcosa che useresti nel lungo termine, sappi che puoi supportare il progetto scegliendo tra le opzioni sotto elencate. + +## Donazioni + +- [Donazione mensile](https://github.com/sponsors/alextran1502) tramite GitHub Sponsors +- [Donazione una tantum](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) tramite GitHub Sponsors +- [Librepay](https://liberapay.com/alex.tran1502/) +- [buymeacoffee](https://www.buymeacoffee.com/altran1502) +- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX diff --git a/README_ja_JP.md b/README_ja_JP.md index 4c2ef59fd..30661068b 100644 --- a/README_ja_JP.md +++ b/README_ja_JP.md @@ -24,6 +24,7 @@ Español Français Nederlands + Italiano

## 免責事項 diff --git a/README_nl_NL.md b/README_nl_NL.md index 5359ddda1..1980b82a3 100644 --- a/README_nl_NL.md +++ b/README_nl_NL.md @@ -25,6 +25,7 @@ Français Nederlands 日本語 + Italiano

## Disclaimer diff --git a/README_tr_TR.md b/README_tr_TR.md index 9575694ed..b36949444 100644 --- a/README_tr_TR.md +++ b/README_tr_TR.md @@ -25,6 +25,7 @@ Français Nederlands 日本語 + Italiano

## Feragatname diff --git a/README_zh_CN.md b/README_zh_CN.md index 55ec3b82f..fdd6777d6 100644 --- a/README_zh_CN.md +++ b/README_zh_CN.md @@ -29,6 +29,7 @@ Français Nederlands 日本語 + Italiano

diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 03fb10f50..8d278b44c 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.82.0 + * The version of the OpenAPI document: 1.82.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto { * @memberof AssetBulkUpdateDto */ 'isFavorite'?: boolean; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'removeParent'?: boolean; + /** + * + * @type {string} + * @memberof AssetBulkUpdateDto + */ + 'stackParentId'?: string; } /** * @@ -748,6 +760,24 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'smartInfo'?: SmartInfoResponseDto; + /** + * + * @type {Array} + * @memberof AssetResponseDto + */ + 'stack'?: Array; + /** + * + * @type {number} + * @memberof AssetResponseDto + */ + 'stackCount': number; + /** + * + * @type {string} + * @memberof AssetResponseDto + */ + 'stackParentId'?: string | null; /** * * @type {Array} @@ -3053,6 +3083,12 @@ export interface SharedLinkEditDto { * @memberof SharedLinkEditDto */ 'allowUpload'?: boolean; + /** + * Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. + * @type {boolean} + * @memberof SharedLinkEditDto + */ + 'changeExpiryTime'?: boolean; /** * * @type {string} @@ -3981,6 +4017,25 @@ export interface UpdateLibraryDto { */ 'name'?: string; } +/** + * + * @export + * @interface UpdateStackParentDto + */ +export interface UpdateStackParentDto { + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'newParentId': string; + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'oldParentId': string; +} /** * * @export @@ -7135,6 +7190,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'updateStackParentDto' is not null or undefined + assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto) + const localVarPath = `/asset/stack/parent`; + // 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: 'PUT', ...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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {File} assetData @@ -7601,6 +7700,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {File} assetData @@ -7892,6 +8001,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. @@ -8499,6 +8617,20 @@ export interface AssetApiUpdateAssetsRequest { readonly assetBulkUpdateDto: AssetBulkUpdateDto } +/** + * Request parameters for updateStackParent operation in AssetApi. + * @export + * @interface AssetApiUpdateStackParentRequest + */ +export interface AssetApiUpdateStackParentRequest { + /** + * + * @type {UpdateStackParentDto} + * @memberof AssetApiUpdateStackParent + */ + readonly updateStackParentDto: UpdateStackParentDto +} + /** * Request parameters for uploadFile operation in AssetApi. * @export @@ -8939,6 +9071,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. diff --git a/cli/src/api/open-api/base.ts b/cli/src/api/open-api/base.ts index 2abcb2b1d..84c4cbb35 100644 --- a/cli/src/api/open-api/base.ts +++ b/cli/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.82.0 + * The version of the OpenAPI document: 1.82.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/common.ts b/cli/src/api/open-api/common.ts index 41329bb53..39307ae08 100644 --- a/cli/src/api/open-api/common.ts +++ b/cli/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.82.0 + * The version of the OpenAPI document: 1.82.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/configuration.ts b/cli/src/api/open-api/configuration.ts index d49752982..c64b2eb3f 100644 --- a/cli/src/api/open-api/configuration.ts +++ b/cli/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.82.0 + * The version of the OpenAPI document: 1.82.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/index.ts b/cli/src/api/open-api/index.ts index 8dab99636..2afccb668 100644 --- a/cli/src/api/open-api/index.ts +++ b/cli/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.82.0 + * The version of the OpenAPI document: 1.82.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index c724eaac5..2d44a1f07 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -21,6 +21,10 @@ services: - .env environment: - NODE_ENV=development + ulimits: + nofile: + soft: 1048576 + hard: 1048576 depends_on: - redis - database @@ -48,6 +52,10 @@ services: - 9231:9230 environment: - NODE_ENV=development + ulimits: + nofile: + soft: 1048576 + hard: 1048576 depends_on: - database - immich-server @@ -73,6 +81,10 @@ services: volumes: - ../web:/usr/src/app - /usr/src/app/node_modules + ulimits: + nofile: + soft: 1048576 + hard: 1048576 restart: unless-stopped depends_on: - immich-server diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 57b012334..df965aa1f 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -1,10 +1,10 @@ version: "3.8" -# Compose file for dockerized end-to-end testing of the backend +name: "immich-test-e2e" services: - immich-server-test: - image: immich-server-test + immich-server: + image: immich-server-dev:latest build: context: ../server dockerfile: Dockerfile @@ -14,27 +14,20 @@ services: - ../server:/usr/src/app - /usr/src/app/node_modules environment: - - DB_HOSTNAME=immich-database-test + - DB_HOSTNAME=database - DB_USERNAME=postgres - DB_PASSWORD=postgres - DB_DATABASE_NAME=e2e_test - IMMICH_RUN_ALL_TESTS=true depends_on: - - immich-database-test - networks: - - immich-test-network + - database - immich-database-test: - container_name: immich-database-test + database: image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441 + command: -c fsync=off environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: e2e_test - networks: - - immich-test-network logging: driver: none - -networks: - immich-test-network: diff --git a/docs/docs/FAQ.md b/docs/docs/FAQ.md index f9863424e..07a109d46 100644 --- a/docs/docs/FAQ.md +++ b/docs/docs/FAQ.md @@ -16,10 +16,6 @@ sidebar_position: 7 Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app/immich/discussions/1006)), but the [command line tool](/docs/features/bulk-upload.md) can bulk upload items from a directory to Immich. -### Why doesn't Immich watch an existing photo gallery directory? - -The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well. - ### Why are only photos and not videos being uploaded to Immich? This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes. Also check the disk space of your reverse proxy, in some cases proxies caches requests to disk before passing them on, and if disk space runs out the request fails. diff --git a/docs/docs/overview/help.md b/docs/docs/overview/help.md index 62261cbe4..33a35b9b3 100644 --- a/docs/docs/overview/help.md +++ b/docs/docs/overview/help.md @@ -11,6 +11,6 @@ Running into an issue or have a question? Try the following: 3. Search through existing [GitHub Issues][github-issues]. 4. Open a help ticket on [Discord][discord-link]. -[github-issues]: https://github.com/immich-app/immich/releases +[github-issues]: https://github.com/immich-app/immich/issues [github-releases]: https://github.com/immich-app/immich/releases [discord-link]: https://discord.com/invite/D8JsnBEuKb diff --git a/docs/docs/overview/introduction.mdx b/docs/docs/overview/introduction.mdx index 900fae868..2a9ad5d12 100644 --- a/docs/docs/overview/introduction.mdx +++ b/docs/docs/overview/introduction.mdx @@ -4,7 +4,7 @@ sidebar_position: 1 # Introduction -Immich +Immich - Self-hosted photos and videos backup tool ## Welcome! diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 493656bf6..72159f51c 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -6,28 +6,31 @@ function HomepageHeader() { return (
-

- IMMICH + Immich logo +

+ Immich

-

SELF-HOSTED BACKUP SOLUTION

-

FOR PHOTOS AND VIDEOS

-

ON MOBILE DEVICE

+

+ Self-hosted backup solution + for photos and videos + on mobile device +

- GET STARTED + Get started - DEMO PORTAL + Demo portal
diff --git a/docs/static/img/immich-logo.svg b/docs/static/img/immich-logo.svg new file mode 100644 index 000000000..e7edba069 --- /dev/null +++ b/docs/static/img/immich-logo.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 8ada03b28..0381540ea 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.82.0" +version = "1.82.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 245c6495b..bbc9a9fe4 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -36,7 +36,7 @@ platform :android do build_type: 'Release', properties: { "android.injected.version.code" => 106, - "android.injected.version.name" => "1.82.0", + "android.injected.version.name" => "1.82.1", } ) 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/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 84c4eae5f..cf37c5d18 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -131,7 +131,10 @@ "control_bottom_app_bar_delete_from_local": "Delete from device", "control_bottom_app_bar_favorite": "Favorite", "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_share_to": "Share To", + "control_bottom_app_bar_stack": "Stack", "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_upload": "Upload", "create_album_page_untitled": "Untitled", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", @@ -167,6 +170,7 @@ "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_share_error": "Share Error", "image_viewer_page_state_provider_download_success": "Download Success", "library_page_albums": "Albums", "library_page_archive": "Archive", @@ -277,6 +281,7 @@ "setting_pages_app_bar_settings": "Settings", "settings_require_restart": "Please restart Immich to apply this setting", "share_add": "Add", + "share_done": "Done", "share_add_photos": "Add photos", "share_add_title": "Add a title", "share_create_album": "Create album", @@ -286,6 +291,7 @@ "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_shared_links": "Shared links", "sharing_silver_appbar_share_partner": "Share with partner", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", @@ -339,5 +345,24 @@ "trash_page_select_assets_btn": "Select assets", "trash_page_empty_trash_btn": "Empty trash", "trash_page_empty_trash_dialog_ok": "Ok", - "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich" + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "viewer_stack_use_as_main_asset": "Use as Main Asset", + "viewer_remove_from_stack": "Remove from Stack", + "viewer_unstack": "Un-Stack", + "delete_shared_link_dialog_title": "Delete Shared Link", + "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", + "shared_link_create_app_bar_title": "Create link to share", + "shared_link_edit_app_bar_title": "Edit link", + "shared_link_create_info": "Let anyone with the link see the selected photo(s)", + "shared_link_edit_description": "Description", + "shared_link_edit_description_hint": "Enter the share description", + "shared_link_edit_show_meta": "Show metadata", + "shared_link_edit_allow_download": "Allow public user to download", + "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_change_expiry": "Change expiration time", + "shared_link_edit_submit_button": "Update link", + "shared_link_create_submit_button": "Create link", + "shared_link_app_bar_title": "Shared Links", + "shared_link_manage_links": "Manage Shared links", + "shared_link_empty": "You don't have any shared links" } diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 661af9732..bccdd26cd 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.82.0" + version_number: "1.82.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 5ca9bb81f..f369a35d1 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -210,6 +210,18 @@ class AlbumViewerAppbar extends HookConsumerWidget style: TextStyle(fontWeight: FontWeight.bold), ).tr(), ), + ListTile( + leading: const Icon(Icons.share_rounded), + onTap: () { + AutoRouter.of(context) + .push(SharedLinkEditRoute(albumId: album.remoteId)); + Navigator.pop(context); + }, + title: const Text( + "control_bottom_app_bar_share", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), ListTile( leading: const Icon(Icons.settings_rounded), onTap: () => diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index cb389059a..23358f335 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; 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/providers/asset.provider.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; @@ -69,7 +70,8 @@ class AlbumViewerPage extends HookConsumerWidget { await AutoRouter.of(context).push( AssetSelectionRoute( existingAssets: albumInfo.assets, - isNewAlbum: false, + canDeselect: false, + query: getRemoteAssetQuery(ref), ), ); diff --git a/mobile/lib/modules/album/views/asset_selection_page.dart b/mobile/lib/modules/album/views/asset_selection_page.dart index afec5f8ae..9c30ba80f 100644 --- a/mobile/lib/modules/album/views/asset_selection_page.dart +++ b/mobile/lib/modules/album/views/asset_selection_page.dart @@ -4,26 +4,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; -import 'package:immich_mobile/shared/providers/user.provider.dart'; +import 'package:isar/isar.dart'; class AssetSelectionPage extends HookConsumerWidget { const AssetSelectionPage({ Key? key, required this.existingAssets, - this.isNewAlbum = false, + this.canDeselect = false, + required this.query, }) : super(key: key); final Set existingAssets; - final bool isNewAlbum; + final QueryBuilder? query; + final bool canDeselect; @override Widget build(BuildContext context, WidgetRef ref) { - final currentUser = ref.watch(currentUserProvider); - final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId)); + final renderList = ref.watch(renderListQueryProvider(query)); final selected = useState>(existingAssets); final selectionEnabledHook = useState(true); @@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget { selected.value = assets; }, selectionActive: true, - preselectedAssets: isNewAlbum ? selected.value : existingAssets, - canDeselect: isNewAlbum, + preselectedAssets: existingAssets, + canDeselect: canDeselect, showMultiSelectIndicator: false, ); } @@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget { ), centerTitle: false, actions: [ - if (selected.value.isNotEmpty) + if (selected.value.isNotEmpty || canDeselect) TextButton( onPressed: () { var payload = @@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget { .popForced(payload); }, child: Text( - "share_add", + canDeselect ? "share_done" : "share_add", style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor, diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index e1f0d65e7..191ce1470 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { @@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget { final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleEmpty = useState(true); final selectedAssets = useState>( - initialAssets != null ? Set.from(initialAssets!) : const {},); + initialAssets != null ? Set.from(initialAssets!) : const {}, + ); final isDarkTheme = Theme.of(context).brightness == Brightness.dark; showSelectUserPage() async { @@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget { await AutoRouter.of(context).push( AssetSelectionRoute( existingAssets: selectedAssets.value, - isNewAlbum: true, + canDeselect: true, + query: getRemoteAssetQuery(ref), ), ); if (selectedAsset == null) { diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index 4e62b16c2..9d0593d28 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -147,13 +147,13 @@ class SharingPage extends HookConsumerWidget { Expanded( child: ElevatedButton.icon( onPressed: () => - AutoRouter.of(context).push(const PartnerRoute()), + AutoRouter.of(context).push(const SharedLinkRoute()), icon: const Icon( - Icons.swap_horizontal_circle_outlined, + Icons.link, size: 20, ), label: const Text( - "sharing_silver_appbar_share_partner", + "sharing_silver_appbar_shared_links", style: TextStyle( fontWeight: FontWeight.bold, fontSize: 11, @@ -179,6 +179,17 @@ class SharingPage extends HookConsumerWidget { fontSize: 22, ), ), + actions: [ + IconButton( + splashRadius: 25, + iconSize: 20, + icon: const Icon( + Icons.swap_horizontal_circle_outlined, + size: 20, + ), + onPressed: () => AutoRouter.of(context).push(const PartnerRoute()), + ), + ], ); } diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart new file mode 100644 index 000000000..8f8e9bbe0 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart @@ -0,0 +1,50 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +class AssetStackNotifier extends StateNotifier> { + final Asset _asset; + final Ref _ref; + + AssetStackNotifier( + this._asset, + this._ref, + ) : super([]) { + fetchStackChildren(); + } + + void fetchStackChildren() async { + if (mounted) { + state = await _ref.read(assetStackProvider(_asset).future); + } + } + + removeChild(int index) { + if (index < state.length) { + state.removeAt(index); + } + } +} + +final assetStackStateProvider = StateNotifierProvider.autoDispose + .family, Asset>( + (ref, asset) => AssetStackNotifier(asset, ref), +); + +final assetStackProvider = + FutureProvider.autoDispose.family, Asset>((ref, asset) async { + // Guard [local asset] + if (asset.remoteId == null) { + return []; + } + + return await ref + .watch(dbProvider) + .assets + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackParentIdEqualTo(asset.remoteId) + .findAll(); +}); diff --git a/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart b/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart index b95a31c13..6df633f12 100644 --- a/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart @@ -57,9 +57,19 @@ class ImageViewerStateNotifier extends StateNotifier { showDialog( context: context, builder: (BuildContext buildContext) { - _shareService - .shareAsset(asset) - .then((_) => Navigator.of(buildContext).pop()); + _shareService.shareAsset(asset).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + Navigator.of(buildContext).pop(); + }, + ); return const ShareDialog(); }, barrierDismissible: false, diff --git a/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart b/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart index 227338084..04532ce1b 100644 --- a/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:isar/isar.dart'; final renderListProvider = FutureProvider.family>((ref, assets) { @@ -13,3 +14,19 @@ final renderListProvider = GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], ); }); + +final renderListQueryProvider = StreamProvider.family?>( + (ref, query) async* { + if (query == null) { + return; + } + final settings = ref.watch(appSettingsServiceProvider); + final groupBy = GroupAssetsBy + .values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; + yield await RenderList.fromQuery(query, groupBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupBy); + } + }, +); diff --git a/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart b/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart new file mode 100644 index 000000000..8efee425c --- /dev/null +++ b/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:openapi/api.dart'; + +class AssetStackService { + AssetStackService(this._api); + + final ApiService _api; + + updateStack( + Asset parentAsset, { + List? childrenToAdd, + List? childrenToRemove, + }) async { + // Guard [local asset] + if (parentAsset.remoteId == null) { + return; + } + + try { + if (childrenToAdd != null) { + final toAdd = childrenToAdd + .where((e) => e.isRemote) + .map((e) => e.remoteId!) + .toList(); + + await _api.assetApi.updateAssets( + AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId), + ); + } + + if (childrenToRemove != null) { + final toRemove = childrenToRemove + .where((e) => e.isRemote) + .map((e) => e.remoteId!) + .toList(); + await _api.assetApi.updateAssets( + AssetBulkUpdateDto(ids: toRemove, removeParent: true), + ); + } + } catch (error) { + debugPrint("Error while updating stack children: ${error.toString()}"); + } + } + + updateStackParent(Asset oldParent, Asset newParent) async { + // Guard [local asset] + if (oldParent.remoteId == null || newParent.remoteId == null) { + return; + } + + try { + await _api.assetApi.updateStackParent( + UpdateStackParentDto( + oldParentId: oldParent.remoteId!, + newParentId: newParent.remoteId!, + ), + ); + } catch (error) { + debugPrint("Error while updating stack parent: ${error.toString()}"); + } + } +} + +final assetStackServiceProvider = Provider( + (ref) => AssetStackService( + ref.watch(apiServiceProvider), + ), +); diff --git a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart index bd4cc8e31..3a356c840 100644 --- a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart +++ b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:path_provider/path_provider.dart'; @@ -14,6 +15,7 @@ final imageViewerServiceProvider = class ImageViewerService { final ApiService _apiService; + final Logger _log = Logger("ImageViewerService"); ImageViewerService(this._apiService); @@ -29,6 +31,16 @@ class ImageViewerService { asset.livePhotoVideoId!, ); + if (imageResponse.statusCode != 200 || + motionReponse.statusCode != 200) { + final failedResponse = + imageResponse.statusCode != 200 ? imageResponse : motionReponse; + _log.severe( + "Motion asset download failed with status - ${failedResponse.statusCode} and response - ${failedResponse.body}", + ); + return false; + } + final AssetEntity? entity; final tempDir = await getTemporaryDirectory(); @@ -48,6 +60,13 @@ class ImageViewerService { var res = await _apiService.assetApi .downloadFileWithHttpInfo(asset.remoteId!); + if (res.statusCode != 200) { + _log.severe( + "Asset download failed with status - ${res.statusCode} and response - ${res.body}", + ); + return false; + } + final AssetEntity? entity; if (asset.isImage) { diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index bb818dcfc..cdc07a2a7 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -8,11 +8,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; @@ -44,6 +46,7 @@ class GalleryViewerPage extends HookConsumerWidget { final int totalAssets; final int initialIndex; final int heroOffset; + final bool showStack; GalleryViewerPage({ super.key, @@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget { required this.loadAsset, required this.totalAssets, this.heroOffset = 0, + this.showStack = false, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget { final isFromTrash = isTrashEnabled && navStack.length > 2 && navStack.elementAt(navStack.length - 2).name == TrashRoute.name; + final stackIndex = useState(-1); + final stack = showStack && currentAsset.stackCount > 0 + ? ref.watch(assetStackStateProvider(currentAsset)) + : []; + final stackElements = showStack ? [currentAsset, ...stack] : []; - Asset asset() => currentAsset; + Asset asset() => stackIndex.value == -1 + ? currentAsset + : stackElements.elementAt(stackIndex.value); + + bool isParent = stackIndex.value == -1 || stackIndex.value == 0; useEffect( () { @@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget { padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), - child: ExifBottomSheet(asset: currentAsset), + child: ExifBottomSheet(asset: asset()), ); }, ); } + void removeAssetFromStack() { + if (stackIndex.value > 0 && showStack) { + ref + .read(assetStackStateProvider(currentAsset).notifier) + .removeChild(stackIndex.value - 1); + stackIndex.value = stackIndex.value - 1; + } + } + void handleDelete(Asset deleteAsset) async { Future onDelete(bool force) async { final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( {deleteAsset}, force: force, ); - if (isDeleted) { + if (isDeleted && isParent) { if (totalAssets == 1) { // Handle only one asset AutoRouter.of(context).pop(); @@ -195,14 +217,17 @@ class GalleryViewerPage extends HookConsumerWidget { // Asset is trashed if (isTrashEnabled && !isFromTrash) { final isDeleted = await onDelete(false); - // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && isDeleted && deleteAsset.isRemote) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'Asset trashed', - gravity: ToastGravity.BOTTOM, - ); + if (isDeleted) { + // Can only trash assets stored in server. Local assets are always permanently removed for now + if (context.mounted && deleteAsset.isRemote && isParent) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'Asset trashed', + gravity: ToastGravity.BOTTOM, + ); + } + removeAssetFromStack(); } return; } @@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget { showDialog( context: context, builder: (BuildContext _) { - return DeleteDialog(onDelete: () => onDelete(true)); + return DeleteDialog( + onDelete: () async { + final isDeleted = await onDelete(true); + if (isDeleted) { + removeAssetFromStack(); + } + }, + ); }, ); } @@ -268,7 +300,11 @@ class GalleryViewerPage extends HookConsumerWidget { ref .watch(assetProvider.notifier) .toggleArchive([asset], !asset.isArchived); - AutoRouter.of(context).pop(); + if (isParent) { + AutoRouter.of(context).pop(); + return; + } + removeAssetFromStack(); } handleUpload(Asset asset) { @@ -385,7 +421,186 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - buildBottomBar() { + Widget buildStackedChildren() { + return ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemCount: stackElements.length, + itemBuilder: (context, index) { + final assetId = stackElements.elementAt(index).remoteId; + return Padding( + padding: const EdgeInsets.only(right: 10), + child: GestureDetector( + onTap: () => stackIndex.value = index, + child: Container( + width: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: index == stackIndex.value + ? Border.all( + color: Colors.white, + width: 2, + ) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + fit: BoxFit.cover, + imageUrl: + '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId', + httpHeaders: { + "Authorization": + "Bearer ${Store.get(StoreKey.accessToken)}", + }, + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), + ), + ), + ), + ), + ); + }, + ); + } + + void showStackActionItems() { + showModalBottomSheet( + context: context, + enableDrag: false, + builder: (BuildContext ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isParent) + ListTile( + leading: const Icon( + Icons.bookmark_border_outlined, + size: 24, + ), + onTap: () async { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + currentAsset, + stackElements.elementAt(stackIndex.value), + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + }, + title: const Text( + "viewer_stack_use_as_main_asset", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.copy_all_outlined, + size: 24, + ), + onTap: () async { + if (isParent) { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + currentAsset, + stackElements + .elementAt(1), // Next asset as parent + ); + // Remove itself from stack + await ref.read(assetStackServiceProvider).updateStack( + stackElements.elementAt(1), + childrenToRemove: [currentAsset], + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + } else { + await ref.read(assetStackServiceProvider).updateStack( + currentAsset, + childrenToRemove: [ + stackElements.elementAt(stackIndex.value), + ], + ); + removeAssetFromStack(); + Navigator.pop(ctx); + } + }, + title: const Text( + "viewer_remove_from_stack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.filter_none_outlined, + size: 18, + ), + onTap: () async { + await ref.read(assetStackServiceProvider).updateStack( + currentAsset, + childrenToRemove: stack, + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + }, + title: const Text( + "viewer_unstack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ), + ), + ); + }, + ); + } + + Widget buildBottomBar() { + // !!!! itemsList and actionlist should always be in sync + final itemsList = [ + BottomNavigationBarItem( + icon: Icon( + Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, + ), + label: 'control_bottom_app_bar_share'.tr(), + tooltip: 'control_bottom_app_bar_share'.tr(), + ), + asset().isArchived + ? BottomNavigationBarItem( + icon: const Icon(Icons.unarchive_rounded), + label: 'control_bottom_app_bar_unarchive'.tr(), + tooltip: 'control_bottom_app_bar_unarchive'.tr(), + ) + : BottomNavigationBarItem( + icon: const Icon(Icons.archive_outlined), + label: 'control_bottom_app_bar_archive'.tr(), + tooltip: 'control_bottom_app_bar_archive'.tr(), + ), + if (stack.isNotEmpty) + BottomNavigationBarItem( + icon: const Icon(Icons.burst_mode_outlined), + label: 'control_bottom_app_bar_stack'.tr(), + tooltip: 'control_bottom_app_bar_stack'.tr(), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.delete_outline), + label: 'control_bottom_app_bar_delete'.tr(), + tooltip: 'control_bottom_app_bar_delete'.tr(), + ), + ]; + + List actionslist = [ + (_) => shareAsset(), + (_) => handleArchive(asset()), + if (stack.isNotEmpty) (_) => showStackActionItems(), + (_) => handleDelete(asset()), + ]; + return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( @@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget { opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, child: Column( children: [ + if (stack.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 10, + bottom: 30, + ), + child: SizedBox( + height: 40, + child: buildStackedChildren(), + ), + ), Visibility( visible: !asset().isImage && !isPlayingMotionVideo.value, child: Container( @@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget { selectedLabelStyle: const TextStyle(color: Colors.black), showSelectedLabels: false, showUnselectedLabels: false, - items: [ - BottomNavigationBarItem( - icon: Icon( - Platform.isAndroid - ? Icons.share_rounded - : Icons.ios_share_rounded, - ), - label: 'control_bottom_app_bar_share'.tr(), - tooltip: 'control_bottom_app_bar_share'.tr(), - ), - asset().isArchived - ? BottomNavigationBarItem( - icon: const Icon(Icons.unarchive_rounded), - label: 'control_bottom_app_bar_unarchive'.tr(), - tooltip: 'control_bottom_app_bar_unarchive'.tr(), - ) - : BottomNavigationBarItem( - icon: const Icon(Icons.archive_outlined), - label: 'control_bottom_app_bar_archive'.tr(), - tooltip: 'control_bottom_app_bar_archive'.tr(), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.delete_outline), - label: 'control_bottom_app_bar_delete'.tr(), - tooltip: 'control_bottom_app_bar_delete'.tr(), - ), - ], + items: itemsList, onTap: (index) { - switch (index) { - case 0: - shareAsset(); - break; - case 1: - handleArchive(asset()); - break; - case 2: - handleDelete(asset()); - break; + if (index < actionslist.length) { + actionslist[index].call(index); } }, ), @@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget { final next = currentIndex.value < value ? value + 1 : value - 1; precacheNextImage(next); currentIndex.value = value; + stackIndex.value = -1; HapticFeedback.selectionClick(); }, loadingBuilder: (context, event, index) { @@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget { : webPThumbnail; }, builder: (context, index) { - final asset = loadAsset(index); - final ImageProvider provider = finalImageProvider(asset); + final a = + index == currentIndex.value ? asset() : loadAsset(index); + final ImageProvider provider = finalImageProvider(a); - if (asset.isImage && !isPlayingMotionVideo.value) { + if (a.isImage && !isPlayingMotionVideo.value) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) => localPosition = details.localPosition, @@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget { }, imageProvider: provider, heroAttributes: PhotoViewHeroAttributes( - tag: asset.id + heroOffset, + tag: a.id + heroOffset, ), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, errorBuilder: (context, error, stackTrace) => ImmichImage( - asset, + a, fit: BoxFit.contain, ), ); @@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget { onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( - tag: asset.id + heroOffset, + tag: a.id + heroOffset, ), filterQuality: FilterQuality.high, maxScale: 1.0, @@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget { child: VideoViewerPage( onPlaying: () => isPlayingVideo.value = true, onPaused: () => isPlayingVideo.value = false, - asset: asset, + asset: a, isMotionVideo: isPlayingMotionVideo.value, placeholder: Image( image: provider, diff --git a/mobile/lib/modules/home/models/selection_state.dart b/mobile/lib/modules/home/models/selection_state.dart new file mode 100644 index 000000000..291b59068 --- /dev/null +++ b/mobile/lib/modules/home/models/selection_state.dart @@ -0,0 +1,47 @@ +import 'package:immich_mobile/shared/models/asset.dart'; + +class SelectionAssetState { + final bool hasRemote; + final bool hasLocal; + final bool hasMerged; + + const SelectionAssetState({ + this.hasRemote = false, + this.hasLocal = false, + this.hasMerged = false, + }); + + SelectionAssetState copyWith({ + bool? hasRemote, + bool? hasLocal, + bool? hasMerged, + }) { + return SelectionAssetState( + hasRemote: hasRemote ?? this.hasRemote, + hasLocal: hasLocal ?? this.hasLocal, + hasMerged: hasMerged ?? this.hasMerged, + ); + } + + SelectionAssetState.fromSelection(Set selection) + : hasLocal = selection.any((e) => e.storage == AssetState.local), + hasMerged = selection.any((e) => e.storage == AssetState.merged), + hasRemote = selection.any((e) => e.storage == AssetState.remote); + + @override + String toString() => + 'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged)'; + + @override + bool operator ==(covariant SelectionAssetState other) { + if (identical(this, other)) return true; + + return other.hasRemote == hasRemote && + other.hasLocal == hasLocal && + other.hasMerged == hasMerged; + } + + @override + int get hashCode => + hasRemote.hashCode ^ hasLocal.hashCode ^ hasMerged.hashCode; +} diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index c4a6d527e..28b25f342 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget { final Widget? topWidget; final bool shrinkWrap; final bool showDragScroll; + final bool showStack; const ImmichAssetGrid({ super.key, @@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget { this.topWidget, this.shrinkWrap = false, this.showDragScroll = true, + this.showStack = false, }); @override @@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget { heroOffset: heroOffset(), shrinkWrap: shrinkWrap, showDragScroll: showDragScroll, + showStack: showStack, ), ); } 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 8f50c2883..b3f031c68 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 @@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget { final int heroOffset; final bool shrinkWrap; final bool showDragScroll; + final bool showStack; const ImmichAssetGridView({ super.key, @@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget { this.heroOffset = 0, this.shrinkWrap = false, this.showDragScroll = true, + this.showStack = false, }); @override @@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State { bool _scrolling = false; final Set _selectedAssets = - HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); + LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); Set _getSelectedAssets() { return Set.from(_selectedAssets); @@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State { void _deselectAssets(List assets) { setState(() { - _selectedAssets.removeAll(assets); + _selectedAssets.removeAll( + assets.where( + (a) => + widget.canDeselect || + !(widget.preselectedAssets?.contains(a) ?? false), + ), + ); _callSelectionListener(_selectedAssets.isNotEmpty); }); } @@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State { useGrayBoxPlaceholder: true, showStorageIndicator: widget.showStorageIndicator, heroOffset: widget.heroOffset, + showStack: widget.showStack, ); } @@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State { setState(() { _selectedAssets.clear(); }); - } else if (widget.preselectedAssets != null) { - setState(() { - _selectedAssets.addAll(widget.preselectedAssets!); - }); } } diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 555475b4b..5b925c86b 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget { final Asset Function(int index) loadAsset; final int totalAssets; final bool showStorageIndicator; + final bool showStack; final bool useGrayBoxPlaceholder; final bool isSelected; final bool multiselectEnabled; @@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget { required this.loadAsset, required this.totalAssets, this.showStorageIndicator = true, + this.showStack = false, this.useGrayBoxPlaceholder = false, this.isSelected = false, this.multiselectEnabled = false, @@ -93,6 +95,35 @@ class ThumbnailImage extends StatelessWidget { ); } + Widget buildStackIcon() { + return Positioned( + top: 5, + right: 5, + child: Row( + children: [ + if (asset.stackCount > 1) + Text( + "${asset.stackCount}", + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + if (asset.stackCount > 1) + const SizedBox( + width: 3, + ), + const Icon( + Icons.burst_mode_rounded, + color: Colors.white, + size: 18, + ), + ], + ), + ); + } + Widget buildImage() { final image = SizedBox( width: 300, @@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget { decoration: BoxDecoration( border: Border.all( width: 0, - color: assetContainerColor, + color: onDeselect == null ? Colors.grey : assetContainerColor, ), - color: assetContainerColor, + color: onDeselect == null ? Colors.grey : assetContainerColor, ), child: ClipRRect( borderRadius: const BorderRadius.only( @@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget { loadAsset: loadAsset, totalAssets: totalAssets, heroOffset: heroOffset, + showStack: showStack, ), ); } @@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget { ), ), if (!asset.isImage) buildVideoIcon(), + if (asset.isImage && asset.stackCount > 0) buildStackIcon(), ], ), ); diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 4e91866f0..57fadc440 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -1,18 +1,16 @@ -import 'dart:io'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; +import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/models/album.dart'; class ControlBottomAppBar extends ConsumerWidget { - final void Function() onShare; + final void Function(bool shareLocal) onShare; final void Function() onFavorite; final void Function() onArchive; final void Function() onDelete; @@ -20,11 +18,12 @@ class ControlBottomAppBar extends ConsumerWidget { final Function(Album album) onAddToAlbum; final void Function() onCreateNewAlbum; final void Function() onUpload; + final void Function() onStack; final List albums; final List sharedAlbums; final bool enabled; - final AssetState selectionAssetState; + final SelectionAssetState selectionAssetState; const ControlBottomAppBar({ Key? key, @@ -38,32 +37,37 @@ class ControlBottomAppBar extends ConsumerWidget { required this.onAddToAlbum, required this.onCreateNewAlbum, required this.onUpload, - this.selectionAssetState = AssetState.remote, + required this.onStack, + this.selectionAssetState = const SelectionAssetState(), this.enabled = true, }) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { var isDarkMode = Theme.of(context).brightness == Brightness.dark; - var hasRemote = selectionAssetState == AssetState.remote || - selectionAssetState == AssetState.merged; - var hasLocal = selectionAssetState == AssetState.merged || - selectionAssetState == AssetState.local; + var hasRemote = + selectionAssetState.hasRemote || selectionAssetState.hasMerged; + var hasLocal = + selectionAssetState.hasLocal || selectionAssetState.hasMerged; final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); List renderActionButtons() { return [ + if (hasRemote) + ControlBoxButton( + iconData: Icons.share_rounded, + label: "control_bottom_app_bar_share".tr(), + onPressed: enabled ? () => onShare(false) : null, + ), ControlBoxButton( - iconData: Platform.isAndroid - ? Icons.share_rounded - : Icons.ios_share_rounded, - label: "control_bottom_app_bar_share".tr(), - onPressed: enabled ? onShare : null, + iconData: Icons.ios_share_rounded, + label: "control_bottom_app_bar_share_to".tr(), + onPressed: enabled ? () => onShare(true) : null, ), if (hasRemote) ControlBoxButton( - iconData: Icons.archive_outlined, + iconData: Icons.archive, label: "control_bottom_app_bar_archive".tr(), onPressed: enabled ? onArchive : null, ), @@ -114,6 +118,12 @@ class ControlBottomAppBar extends ConsumerWidget { : null, ), ), + if (!hasLocal) + ControlBoxButton( + iconData: Icons.filter_none_rounded, + label: "control_bottom_app_bar_stack".tr(), + onPressed: enabled ? onStack : null, + ), if (!hasRemote) ControlBoxButton( iconData: Icons.backup_outlined, @@ -135,7 +145,7 @@ class ControlBottomAppBar extends ConsumerWidget { return DraggableScrollableSheet( initialChildSize: hasRemote ? 0.30 : 0.18, minChildSize: 0.18, - maxChildSize: hasRemote ? 0.57 : 0.18, + maxChildSize: hasRemote ? 0.60 : 0.18, snap: true, builder: ( BuildContext context, @@ -191,10 +201,6 @@ class ControlBottomAppBar extends ConsumerWidget { enabled: enabled, ), ), - if (hasRemote) - const SliverToBoxAdapter( - child: SizedBox(height: 200), - ), ], ), ); @@ -227,7 +233,10 @@ class AddToAlbumTitleRow extends StatelessWidget { ).tr(), TextButton.icon( onPressed: onCreateNewAlbum, - icon: const Icon(Icons.add), + icon: Icon( + Icons.add, + color: Theme.of(context).primaryColor, + ), label: Text( "common_create_new_album", style: TextStyle( diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 5b6a4bb87..eca78d74f 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -7,11 +7,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; @@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final multiselectEnabled = ref.watch(multiselectProvider.notifier); final selectionEnabledHook = useState(false); - final selectionAssetState = useState(AssetState.remote); + final selectionAssetState = useState(const SelectionAssetState()); final selection = useState({}); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); @@ -83,17 +87,8 @@ class HomePage extends HookConsumerWidget { ) { selectionEnabledHook.value = multiselect; selection.value = selectedAssets; - selectionAssetState.value = selectedAssets.any((e) => e.isRemote) - ? selectedAssets.any((e) => e.isLocal) - ? AssetState.merged - : AssetState.remote - : AssetState.local; - } - - void onShareAssets() { - handleShareAssets(ref, context, selection.value.toList()); - - selectionEnabledHook.value = false; + selectionAssetState.value = + SelectionAssetState.fromSelection(selectedAssets); } List remoteOnlySelection({String? localErrorMessage}) { @@ -112,6 +107,19 @@ class HomePage extends HookConsumerWidget { return assets.toList(); } + void onShareAssets(bool shareLocal) { + processing.value = true; + if (shareLocal) { + handleShareAssets(ref, context, selection.value.toList()); + } else { + final ids = remoteOnlySelection().map((e) => e.remoteId!); + AutoRouter.of(context) + .push(SharedLinkEditRoute(assetsList: ids.toList())); + } + processing.value = false; + selectionEnabledHook.value = false; + } + void onFavoriteAssets() async { processing.value = true; try { @@ -270,6 +278,55 @@ class HomePage extends HookConsumerWidget { } } + void onStack() async { + try { + processing.value = true; + if (!selectionEnabledHook.value) { + return; + } + + final selectedAsset = selection.value.elementAt(0); + + if (selection.value.length == 1) { + final stackChildren = + (await ref.read(assetStackProvider(selectedAsset).future)) + .toSet(); + AssetSelectionPageResult? returnPayload = + await AutoRouter.of(context).push( + AssetSelectionRoute( + existingAssets: stackChildren, + canDeselect: true, + query: getAssetStackSelectionQuery(ref, selectedAsset), + ), + ); + + if (returnPayload != null) { + Set selectedAssets = returnPayload.selectedAssets; + // Do not add itself as its stack child + selectedAssets.remove(selectedAsset); + final removedChildren = stackChildren.difference(selectedAssets); + final addedChildren = selectedAssets.difference(stackChildren); + await ref.read(assetStackServiceProvider).updateStack( + selectedAsset, + childrenToAdd: addedChildren.toList(), + childrenToRemove: removedChildren.toList(), + ); + } + } else { + // Merge assets + selection.value.remove(selectedAsset); + final selectedAssets = selection.value; + await ref.read(assetStackServiceProvider).updateStack( + selectedAsset, + childrenToAdd: selectedAssets.toList(), + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + Future refreshAssets() async { final fullRefresh = refreshCount.value > 0; await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); @@ -346,6 +403,7 @@ class HomePage extends HookConsumerWidget { currentUser.memoryEnabled!) ? const MemoryLane() : const SizedBox(), + showStack: true, ), error: (error, _) => Center(child: Text(error.toString())), loading: buildLoadingIndicator, @@ -364,6 +422,7 @@ class HomePage extends HookConsumerWidget { onUpload: onUpload, enabled: !processing.value, selectionAssetState: selectionAssetState.value, + onStack: onStack, ), if (processing.value) const Center(child: ImmichLoadingIndicator()), ], diff --git a/mobile/lib/modules/shared_link/models/shared_link.dart b/mobile/lib/modules/shared_link/models/shared_link.dart new file mode 100644 index 000000000..5beabb566 --- /dev/null +++ b/mobile/lib/modules/shared_link/models/shared_link.dart @@ -0,0 +1,107 @@ +import 'package:openapi/api.dart'; + +enum SharedLinkSource { album, individual } + +class SharedLink { + final String id; + final String title; + final bool allowDownload; + final bool allowUpload; + final String? thumbAssetId; + final String? description; + final DateTime? expiresAt; + final String key; + final bool showMetadata; + final SharedLinkSource type; + + const SharedLink({ + required this.id, + required this.title, + required this.allowDownload, + required this.allowUpload, + required this.thumbAssetId, + required this.description, + required this.expiresAt, + required this.key, + required this.showMetadata, + required this.type, + }); + + SharedLink copyWith({ + String? id, + String? title, + String? thumbAssetId, + bool? allowDownload, + bool? allowUpload, + String? description, + DateTime? expiresAt, + String? key, + bool? showMetadata, + SharedLinkSource? type, + }) { + return SharedLink( + id: id ?? this.id, + title: title ?? this.title, + thumbAssetId: thumbAssetId ?? this.thumbAssetId, + allowDownload: allowDownload ?? this.allowDownload, + allowUpload: allowUpload ?? this.allowUpload, + description: description ?? this.description, + expiresAt: expiresAt ?? this.expiresAt, + key: key ?? this.key, + showMetadata: showMetadata ?? this.showMetadata, + type: type ?? this.type, + ); + } + + SharedLink.fromDto(SharedLinkResponseDto dto) + : id = dto.id, + allowDownload = dto.allowDownload, + allowUpload = dto.allowUpload, + description = dto.description, + expiresAt = dto.expiresAt, + key = dto.key, + showMetadata = dto.showMetadata, + type = dto.type == SharedLinkType.ALBUM + ? SharedLinkSource.album + : SharedLinkSource.individual, + title = dto.type == SharedLinkType.ALBUM + ? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE" + : "INDIVIDUAL SHARE", + thumbAssetId = dto.type == SharedLinkType.ALBUM + ? dto.album?.albumThumbnailAssetId + : dto.assets.isNotEmpty + ? dto.assets[0].id + : null; + + @override + String toString() => + 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SharedLink && + other.id == id && + other.title == title && + other.thumbAssetId == thumbAssetId && + other.allowDownload == allowDownload && + other.allowUpload == allowUpload && + other.description == description && + other.expiresAt == expiresAt && + other.key == key && + other.showMetadata == showMetadata && + other.type == type; + + @override + int get hashCode => + id.hashCode ^ + title.hashCode ^ + thumbAssetId.hashCode ^ + allowDownload.hashCode ^ + allowUpload.hashCode ^ + description.hashCode ^ + expiresAt.hashCode ^ + key.hashCode ^ + showMetadata.hashCode ^ + type.hashCode; +} diff --git a/mobile/lib/modules/shared_link/providers/shared_link.provider.dart b/mobile/lib/modules/shared_link/providers/shared_link.provider.dart new file mode 100644 index 000000000..d72b88dd8 --- /dev/null +++ b/mobile/lib/modules/shared_link/providers/shared_link.provider.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/services/shared_link.service.dart'; + +class SharedLinksNotifier extends StateNotifier>> { + final SharedLinkService _sharedLinkService; + + SharedLinksNotifier(this._sharedLinkService) : super(const AsyncLoading()) { + fetchLinks(); + } + + Future fetchLinks() async { + state = await _sharedLinkService.getAllSharedLinks(); + } + + Future deleteLink(String id) async { + await _sharedLinkService.deleteSharedLink(id); + state = const AsyncLoading(); + fetchLinks(); + } +} + +final sharedLinksStateProvider = + StateNotifierProvider>>( + (ref) { + return SharedLinksNotifier( + ref.watch(sharedLinkServiceProvider), + ); +}); diff --git a/mobile/lib/modules/shared_link/services/shared_link.service.dart b/mobile/lib/modules/shared_link/services/shared_link.service.dart new file mode 100644 index 000000000..2e28c20da --- /dev/null +++ b/mobile/lib/modules/shared_link/services/shared_link.service.dart @@ -0,0 +1,115 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final sharedLinkServiceProvider = Provider( + (ref) => SharedLinkService(ref.watch(apiServiceProvider)), +); + +class SharedLinkService { + final ApiService _apiService; + final Logger _log = Logger("SharedLinkService"); + + SharedLinkService(this._apiService); + + Future>> getAllSharedLinks() async { + try { + final list = await _apiService.sharedLinkApi.getAllSharedLinks(); + return list != null + ? AsyncData(list.map(SharedLink.fromDto).toList()) + : const AsyncData([]); + } catch (e, stack) { + _log.severe("failed to fetch shared links - $e"); + return AsyncError(e, stack); + } + } + + Future deleteSharedLink(String id) async { + try { + return await _apiService.sharedLinkApi.removeSharedLink(id); + } catch (e) { + _log.severe("failed to delete shared link id - $id with error - $e"); + } + } + + Future createSharedLink({ + required bool showMeta, + required bool allowDownload, + required bool allowUpload, + String? description, + String? albumId, + List? assetIds, + DateTime? expiresAt, + }) async { + try { + final type = + albumId != null ? SharedLinkType.ALBUM : SharedLinkType.INDIVIDUAL; + SharedLinkCreateDto? dto; + if (type == SharedLinkType.ALBUM) { + dto = SharedLinkCreateDto( + type: type, + albumId: albumId, + showMetadata: showMeta, + allowDownload: allowDownload, + allowUpload: allowUpload, + expiresAt: expiresAt, + description: description, + ); + } else if (assetIds != null) { + dto = SharedLinkCreateDto( + type: type, + showMetadata: showMeta, + allowDownload: allowDownload, + allowUpload: allowUpload, + expiresAt: expiresAt, + description: description, + assetIds: assetIds, + ); + } + + if (dto != null) { + final responseDto = + await _apiService.sharedLinkApi.createSharedLink(dto); + if (responseDto != null) { + return SharedLink.fromDto(responseDto); + } + } + } catch (e) { + _log.severe("failed to create shared link with error - $e"); + } + return null; + } + + Future updateSharedLink( + String id, { + required bool? showMeta, + required bool? allowDownload, + required bool? allowUpload, + bool? changeExpiry = false, + String? description, + DateTime? expiresAt, + }) async { + try { + final responseDto = await _apiService.sharedLinkApi.updateSharedLink( + id, + SharedLinkEditDto( + showMetadata: showMeta, + allowDownload: allowDownload, + allowUpload: allowUpload, + expiresAt: expiresAt, + description: description, + changeExpiryTime: changeExpiry, + ), + ); + if (responseDto != null) { + return SharedLink.fromDto(responseDto); + } + } catch (e) { + _log.severe("failed to update shared link id - $id with error - $e"); + } + return null; + } +} diff --git a/mobile/lib/modules/shared_link/ui/shared_link_item.dart b/mobile/lib/modules/shared_link/ui/shared_link_item.dart new file mode 100644 index 000000000..907006f77 --- /dev/null +++ b/mobile/lib/modules/shared_link/ui/shared_link_item.dart @@ -0,0 +1,307 @@ +import 'dart:math' as math; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; + +class SharedLinkItem extends ConsumerWidget { + final SharedLink sharedLink; + + const SharedLinkItem(this.sharedLink, {super.key}); + + bool isExpired() { + if (sharedLink.expiresAt != null) { + return DateTime.now().isAfter(sharedLink.expiresAt!); + } + return false; + } + + Widget getExpiryDuration(bool isDarkMode) { + var expiresText = "Expires ∞"; + if (sharedLink.expiresAt != null) { + if (isExpired()) { + return Text( + "Expired", + style: TextStyle(color: Colors.red[300]), + ); + } + final difference = sharedLink.expiresAt!.difference(DateTime.now()); + debugPrint("Difference: $difference"); + if (difference.inDays > 0) { + var dayDifference = difference.inDays; + if (difference.inHours % 24 > 12) { + dayDifference += 1; + } + expiresText = "in $dayDifference days"; + } else if (difference.inHours > 0) { + expiresText = "in ${difference.inHours} hours"; + } else if (difference.inMinutes > 0) { + expiresText = "in ${difference.inMinutes} minutes"; + } else if (difference.inSeconds > 0) { + expiresText = "in ${difference.inSeconds} seconds"; + } + } + return Text( + expiresText, + style: TextStyle(color: isDarkMode ? Colors.grey[400] : Colors.grey[600]), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeData = Theme.of(context); + final isDarkMode = themeData.brightness == Brightness.dark; + final thumbnailUrl = sharedLink.thumbAssetId != null + ? getThumbnailUrlForRemoteId(sharedLink.thumbAssetId!) + : null; + final imageSize = math.min(MediaQuery.of(context).size.width / 4, 100.0); + + void copyShareLinkToClipboard() { + final serverUrl = getServerUrl(); + if (serverUrl == null) { + ImmichToast.show( + context: context, + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + msg: 'Cannot fetch the server url', + ); + return; + } + + Clipboard.setData( + ClipboardData( + text: "$serverUrl/share/${sharedLink.key}", + ), + ).then((_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Copied to clipboard", + ), + duration: Duration(seconds: 2), + ), + ); + }); + } + + Future deleteShareLink() async { + return showDialog( + context: context, + builder: (BuildContext context) { + return ConfirmDialog( + title: "delete_shared_link_dialog_title", + content: "delete_shared_link_dialog_content", + onOk: () => ref + .read(sharedLinksStateProvider.notifier) + .deleteLink(sharedLink.id), + ); + }, + ); + } + + Widget buildThumbnail() { + if (thumbnailUrl == null) { + return Container( + height: imageSize * 1.2, + width: imageSize, + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], + ), + child: Center( + child: Icon( + Icons.image_not_supported_outlined, + color: isDarkMode ? Colors.grey[100] : Colors.grey[700], + ), + ), + ); + } + return SizedBox( + height: imageSize * 1.2, + width: imageSize, + child: Padding( + padding: const EdgeInsets.only(right: 4.0), + child: ThumbnailWithInfo( + imageUrl: thumbnailUrl, + key: key, + textInfo: '', + noImageIcon: Icons.image_not_supported_outlined, + onTap: () {}, + ), + ), + ); + } + + Widget buildInfoChip(String labelText) { + return Padding( + padding: const EdgeInsets.only(right: 10), + child: Chip( + backgroundColor: themeData.primaryColor, + label: Text( + labelText, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.black : Colors.white, + ), + ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(25)), + ), + ), + ); + } + + Widget buildBottomInfo() { + return Row( + children: [ + if (sharedLink.allowUpload) buildInfoChip("Upload"), + if (sharedLink.allowDownload) buildInfoChip("Download"), + if (sharedLink.showMetadata) buildInfoChip("EXIF"), + ], + ); + } + + Widget buildSharedLinkActions() { + const actionIconSize = 20.0; + return Row( + children: [ + IconButton( + splashRadius: 25, + constraints: const BoxConstraints(), + iconSize: actionIconSize, + icon: const Icon(Icons.delete_outline), + style: const ButtonStyle( + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, // the '2023' part + ), + onPressed: deleteShareLink, + ), + IconButton( + splashRadius: 25, + constraints: const BoxConstraints(), + iconSize: actionIconSize, + icon: const Icon(Icons.edit_outlined), + style: const ButtonStyle( + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, // the '2023' part + ), + onPressed: () => AutoRouter.of(context) + .push(SharedLinkEditRoute(existingLink: sharedLink)), + ), + IconButton( + splashRadius: 25, + constraints: const BoxConstraints(), + iconSize: actionIconSize, + icon: const Icon(Icons.copy_outlined), + style: const ButtonStyle( + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, // the '2023' part + ), + onPressed: copyShareLinkToClipboard, + ), + ], + ); + } + + Widget buildSharedLinkDetails() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + getExpiryDuration(isDarkMode), + Padding( + padding: const EdgeInsets.only(top: 5), + child: Tooltip( + verticalOffset: 0, + decoration: BoxDecoration( + color: themeData.primaryColor.withOpacity(0.9), + borderRadius: BorderRadius.circular(10), + ), + textStyle: TextStyle( + color: isDarkMode ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + message: sharedLink.title, + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + child: Text( + sharedLink.title, + style: TextStyle( + color: themeData.primaryColor, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Tooltip( + verticalOffset: 0, + decoration: BoxDecoration( + color: themeData.primaryColor.withOpacity(0.9), + borderRadius: BorderRadius.circular(10), + ), + textStyle: TextStyle( + color: isDarkMode ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + message: sharedLink.description ?? "", + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + child: Text( + sharedLink.description ?? "", + overflow: TextOverflow.ellipsis, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 15), + child: buildSharedLinkActions(), + ), + ], + ), + buildBottomInfo(), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 15), + child: buildThumbnail(), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 15), + child: buildSharedLinkDetails(), + ), + ), + ], + ), + const Padding( + padding: EdgeInsets.all(20), + child: Divider( + height: 0, + ), + ), + ], + ); + } +} diff --git a/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart new file mode 100644 index 000000000..d2a1aaeed --- /dev/null +++ b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart @@ -0,0 +1,459 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart'; +import 'package:immich_mobile/modules/shared_link/services/shared_link.service.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; + +class SharedLinkEditPage extends HookConsumerWidget { + final SharedLink? existingLink; + final List? assetsList; + final String? albumId; + + const SharedLinkEditPage({ + super.key, + this.existingLink, + this.assetsList, + this.albumId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + const padding = 20.0; + final themeData = Theme.of(context); + final descriptionController = + useTextEditingController(text: existingLink?.description ?? ""); + final descriptionFocusNode = useFocusNode(); + final showMetadata = useState(existingLink?.showMetadata ?? true); + final allowDownload = useState(existingLink?.allowDownload ?? true); + final allowUpload = useState(existingLink?.allowUpload ?? false); + final editExpiry = useState(false); + final expiryAfter = useState(0); + final newShareLink = useState(""); + + Widget buildLinkTitle() { + if (existingLink != null) { + if (existingLink!.type == SharedLinkSource.album) { + return Row( + children: [ + const Text( + "Public album | ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + existingLink!.title, + style: TextStyle( + color: themeData.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + if (existingLink!.type == SharedLinkSource.individual) { + return Row( + children: [ + const Text( + "Individual shared | ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Expanded( + child: Text( + existingLink!.description ?? "--", + style: TextStyle( + color: themeData.primaryColor, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + } + + return const Text( + "shared_link_create_info", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(); + } + + Widget buildDescriptionField() { + return TextField( + controller: descriptionController, + enabled: newShareLink.value.isEmpty, + focusNode: descriptionFocusNode, + textInputAction: TextInputAction.done, + autofocus: false, + decoration: InputDecoration( + labelText: 'shared_link_edit_description'.tr(), + labelStyle: TextStyle( + fontWeight: FontWeight.bold, + color: themeData.primaryColor, + ), + floatingLabelBehavior: FloatingLabelBehavior.always, + border: const OutlineInputBorder(), + hintText: 'shared_link_edit_description_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)), + ), + ), + onTapOutside: (_) => descriptionFocusNode.unfocus(), + ); + } + + Widget buildShowMetaButton() { + return SwitchListTile.adaptive( + value: showMetadata.value, + onChanged: newShareLink.value.isEmpty + ? (value) => showMetadata.value = value + : null, + activeColor: themeData.primaryColor, + dense: true, + title: Text( + "shared_link_edit_show_meta", + style: themeData.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + ); + } + + Widget buildAllowDownloadButton() { + return SwitchListTile.adaptive( + value: allowDownload.value, + onChanged: newShareLink.value.isEmpty + ? (value) => allowDownload.value = value + : null, + activeColor: themeData.primaryColor, + dense: true, + title: Text( + "shared_link_edit_allow_download", + style: themeData.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + ); + } + + Widget buildAllowUploadButton() { + return SwitchListTile.adaptive( + value: allowUpload.value, + onChanged: newShareLink.value.isEmpty + ? (value) => allowUpload.value = value + : null, + activeColor: themeData.primaryColor, + dense: true, + title: Text( + "shared_link_edit_allow_upload", + style: themeData.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + ); + } + + Widget buildEditExpiryButton() { + return SwitchListTile.adaptive( + value: editExpiry.value, + onChanged: newShareLink.value.isEmpty + ? (value) => editExpiry.value = value + : null, + activeColor: themeData.primaryColor, + dense: true, + title: Text( + "shared_link_edit_change_expiry", + style: themeData.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + ); + } + + Widget buildExpiryAfterButton() { + return DropdownMenu( + enableSearch: false, + enableFilter: false, + width: MediaQuery.of(context).size.width - 40, + initialSelection: expiryAfter.value, + enabled: newShareLink.value.isEmpty && + (existingLink == null || editExpiry.value), + onSelected: (value) { + expiryAfter.value = value!; + }, + inputDecorationTheme: themeData.inputDecorationTheme.copyWith( + disabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)), + ), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + ), + dropdownMenuEntries: const [ + DropdownMenuEntry(value: 0, label: "Never"), + DropdownMenuEntry( + value: 30, + label: '30 minutes', + ), + DropdownMenuEntry( + value: 60, + label: '1 hour', + ), + DropdownMenuEntry( + value: 60 * 6, + label: '6 hours', + ), + DropdownMenuEntry( + value: 60 * 24, + label: '1 day', + ), + DropdownMenuEntry( + value: 60 * 24 * 7, + label: '7 days', + ), + DropdownMenuEntry( + value: 60 * 24 * 30, + label: '30 days', + ), + ], + ); + } + + void copyLinkToClipboard() { + Clipboard.setData( + ClipboardData( + text: newShareLink.value, + ), + ).then((_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Copied to clipboard", + ), + duration: Duration(seconds: 2), + ), + ); + }); + } + + Widget buildNewLinkField() { + return Column( + children: [ + const Padding( + padding: EdgeInsets.only( + top: 20, + bottom: 20, + ), + child: Divider(), + ), + TextFormField( + readOnly: true, + initialValue: newShareLink.value, + decoration: InputDecoration( + border: const OutlineInputBorder(), + enabledBorder: themeData.inputDecorationTheme.focusedBorder, + suffixIcon: IconButton( + onPressed: copyLinkToClipboard, + icon: const Icon(Icons.copy), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Align( + alignment: Alignment.bottomRight, + child: ElevatedButton( + onPressed: () { + AutoRouter.of(context).pop(); + }, + child: const Text( + "Done", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ), + ), + ], + ); + } + + DateTime calculateExpiry() { + return DateTime.now().add(Duration(minutes: expiryAfter.value)); + } + + Future handleNewLink() async { + final newLink = + await ref.read(sharedLinkServiceProvider).createSharedLink( + albumId: albumId, + assetIds: assetsList, + showMeta: showMetadata.value, + allowDownload: allowDownload.value, + allowUpload: allowUpload.value, + description: descriptionController.text.isEmpty + ? null + : descriptionController.text, + expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(), + ); + ref.invalidate(sharedLinksStateProvider); + final serverUrl = getServerUrl(); + if (newLink != null && serverUrl != null) { + newShareLink.value = "$serverUrl/share/${newLink.key}"; + copyLinkToClipboard(); + } else if (newLink == null) { + ImmichToast.show( + context: context, + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + msg: 'Error while creating shared link', + ); + } + } + + Future handleEditLink() async { + bool? download; + bool? upload; + bool? meta; + String? desc; + DateTime? expiry; + bool? changeExpiry; + + if (allowDownload.value != existingLink!.allowDownload) { + download = allowDownload.value; + } + + if (allowUpload.value != existingLink!.allowUpload) { + upload = allowUpload.value; + } + + if (showMetadata.value != existingLink!.showMetadata) { + meta = showMetadata.value; + } + + if (descriptionController.text != existingLink!.description) { + desc = descriptionController.text; + } + + if (editExpiry.value) { + expiry = expiryAfter.value == 0 ? null : calculateExpiry(); + changeExpiry = true; + } + + await ref.read(sharedLinkServiceProvider).updateSharedLink( + existingLink!.id, + showMeta: meta, + allowDownload: download, + allowUpload: upload, + description: desc, + expiresAt: expiry, + changeExpiry: changeExpiry, + ); + ref.invalidate(sharedLinksStateProvider); + AutoRouter.of(context).pop(); + } + + return Scaffold( + appBar: AppBar( + title: Text( + existingLink == null + ? "shared_link_create_app_bar_title" + : "shared_link_edit_app_bar_title", + ).tr(), + elevation: 0, + leading: const CloseButton(), + centerTitle: false, + ), + resizeToAvoidBottomInset: false, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(padding), + child: buildLinkTitle(), + ), + Padding( + padding: const EdgeInsets.all(padding), + child: buildDescriptionField(), + ), + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + bottom: padding, + ), + child: buildShowMetaButton(), + ), + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + bottom: padding, + ), + child: buildAllowDownloadButton(), + ), + Padding( + padding: + const EdgeInsets.only(left: padding, right: 20, bottom: 20), + child: buildAllowUploadButton(), + ), + if (existingLink != null) + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + bottom: padding, + ), + child: buildEditExpiryButton(), + ), + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + bottom: padding, + ), + child: buildExpiryAfterButton(), + ), + if (newShareLink.value.isEmpty) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: padding + 10), + child: ElevatedButton( + onPressed: + existingLink != null ? handleEditLink : handleNewLink, + child: Text( + existingLink != null + ? "shared_link_edit_submit_button" + : "shared_link_create_submit_button", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ), + ), + if (newShareLink.value.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + ), + child: buildNewLinkField(), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/shared_link/views/shared_link_page.dart b/mobile/lib/modules/shared_link/views/shared_link_page.dart new file mode 100644 index 000000000..19bede4bd --- /dev/null +++ b/mobile/lib/modules/shared_link/views/shared_link_page.dart @@ -0,0 +1,126 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart'; +import 'package:immich_mobile/modules/shared_link/ui/shared_link_item.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; + +class SharedLinkPage extends HookConsumerWidget { + const SharedLinkPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sharedLinks = ref.watch(sharedLinksStateProvider); + + useEffect( + () { + ref.read(sharedLinksStateProvider.notifier).fetchLinks(); + return () => ref.invalidate(sharedLinksStateProvider); + }, + [], + ); + + Widget buildNoShares() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0), + child: const Text( + "shared_link_manage_links", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: const Text( + "shared_link_empty", + style: TextStyle(fontSize: 14), + ).tr(), + ), + ), + Expanded( + child: Center( + child: Icon( + Icons.link_off, + size: 100, + color: Theme.of(context).iconTheme.color?.withOpacity(0.5), + ), + ), + ), + ], + ); + } + + Widget buildSharesList(List links) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0), + child: const Text( + "shared_link_manage_links", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + // Two column + return GridView.builder( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 180, + ), + itemCount: links.length, + itemBuilder: (context, index) { + return SharedLinkItem(links.elementAt(index)); + }, + ); + } + + // Single column + return ListView.builder( + itemCount: links.length, + itemBuilder: (context, index) { + return SharedLinkItem(links.elementAt(index)); + }, + ); + }, + ), + ), + ], + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text("shared_link_app_bar_title").tr(), + elevation: 0, + centerTitle: false, + ), + body: SafeArea( + child: sharedLinks.when( + data: (links) => + links.isNotEmpty ? buildSharesList(links) : buildNoShares(), + error: (error, stackTrace) => buildNoShares(), + loading: () => const Center(child: ImmichLoadingIndicator()), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/trash/views/trash_page.dart b/mobile/lib/modules/trash/views/trash_page.dart index 797b08a75..8c128b61d 100644 --- a/mobile/lib/modules/trash/views/trash_page.dart +++ b/mobile/lib/modules/trash/views/trash_page.dart @@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget { listener: selectionListener, selectionActive: selectionEnabledHook.value, showMultiSelectIndicator: false, + showStack: true, topWidget: Padding( padding: const EdgeInsets.only( top: 24, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index c3f4c2c1a..0c3da5c02 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -28,6 +28,9 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/views/shared_link_edit_page.dart'; +import 'package:immich_mobile/modules/shared_link/views/shared_link_page.dart'; import 'package:immich_mobile/modules/trash/views/trash_page.dart'; import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart'; import 'package:immich_mobile/modules/search/views/all_people_page.dart'; @@ -51,6 +54,7 @@ import 'package:immich_mobile/shared/views/app_log_detail_page.dart'; import 'package:immich_mobile/shared/views/app_log_page.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart'; +import 'package:isar/isar.dart'; import 'package:photo_manager/photo_manager.dart'; part 'router.gr.dart'; @@ -157,6 +161,8 @@ part 'router.gr.dart'; AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 6502d5585..6e49df9f4 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter { loadAsset: args.loadAsset, totalAssets: args.totalAssets, heroOffset: args.heroOffset, + showStack: args.showStack, ), ); }, @@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter { child: AssetSelectionPage( key: args.key, existingAssets: args.existingAssets, - isNewAlbum: args.isNewAlbum, + canDeselect: args.canDeselect, + query: args.query, ), transitionsBuilder: TransitionsBuilders.slideBottom, opaque: true, @@ -318,6 +320,25 @@ class _$AppRouter extends RootStackRouter { child: const TrashPage(), ); }, + SharedLinkRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const SharedLinkPage(), + ); + }, + SharedLinkEditRoute.name: (routeData) { + final args = routeData.argsAs( + orElse: () => const SharedLinkEditRouteArgs()); + return MaterialPageX( + routeData: routeData, + child: SharedLinkEditPage( + key: args.key, + existingLink: args.existingLink, + assetsList: args.assetsList, + albumId: args.albumId, + ), + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( routeData: routeData, @@ -638,6 +659,22 @@ class _$AppRouter extends RootStackRouter { duplicateGuard, ], ), + RouteConfig( + SharedLinkRoute.name, + path: '/shared-link-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), + RouteConfig( + SharedLinkEditRoute.name, + path: '/shared-link-edit-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), ]; } @@ -711,6 +748,7 @@ class GalleryViewerRoute extends PageRouteInfo { required Asset Function(int) loadAsset, required int totalAssets, int heroOffset = 0, + bool showStack = false, }) : super( GalleryViewerRoute.name, path: '/gallery-viewer-page', @@ -720,6 +758,7 @@ class GalleryViewerRoute extends PageRouteInfo { loadAsset: loadAsset, totalAssets: totalAssets, heroOffset: heroOffset, + showStack: showStack, ), ); @@ -733,6 +772,7 @@ class GalleryViewerRouteArgs { required this.loadAsset, required this.totalAssets, this.heroOffset = 0, + this.showStack = false, }); final Key? key; @@ -745,9 +785,11 @@ class GalleryViewerRouteArgs { final int heroOffset; + final bool showStack; + @override String toString() { - return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset}'; + return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}'; } } @@ -961,14 +1003,16 @@ class AssetSelectionRoute extends PageRouteInfo { AssetSelectionRoute({ Key? key, required Set existingAssets, - bool isNewAlbum = false, + bool canDeselect = false, + required QueryBuilder? query, }) : super( AssetSelectionRoute.name, path: '/asset-selection-page', args: AssetSelectionRouteArgs( key: key, existingAssets: existingAssets, - isNewAlbum: isNewAlbum, + canDeselect: canDeselect, + query: query, ), ); @@ -979,18 +1023,21 @@ class AssetSelectionRouteArgs { const AssetSelectionRouteArgs({ this.key, required this.existingAssets, - this.isNewAlbum = false, + this.canDeselect = false, + required this.query, }); final Key? key; final Set existingAssets; - final bool isNewAlbum; + final bool canDeselect; + + final QueryBuilder? query; @override String toString() { - return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}'; + return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}'; } } @@ -1420,6 +1467,62 @@ class TrashRoute extends PageRouteInfo { static const String name = 'TrashRoute'; } +/// generated route for +/// [SharedLinkPage] +class SharedLinkRoute extends PageRouteInfo { + const SharedLinkRoute() + : super( + SharedLinkRoute.name, + path: '/shared-link-page', + ); + + static const String name = 'SharedLinkRoute'; +} + +/// generated route for +/// [SharedLinkEditPage] +class SharedLinkEditRoute extends PageRouteInfo { + SharedLinkEditRoute({ + Key? key, + SharedLink? existingLink, + List? assetsList, + String? albumId, + }) : super( + SharedLinkEditRoute.name, + path: '/shared-link-edit-page', + args: SharedLinkEditRouteArgs( + key: key, + existingLink: existingLink, + assetsList: assetsList, + albumId: albumId, + ), + ); + + static const String name = 'SharedLinkEditRoute'; +} + +class SharedLinkEditRouteArgs { + const SharedLinkEditRouteArgs({ + this.key, + this.existingLink, + this.assetsList, + this.albumId, + }); + + final Key? key; + + final SharedLink? existingLink; + + final List? assetsList; + + final String? albumId; + + @override + String toString() { + return 'SharedLinkEditRouteArgs{key: $key, existingLink: $existingLink, assetsList: $assetsList, albumId: $albumId}'; + } +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 74d9380be..66f2cc9f3 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -31,7 +31,9 @@ class Asset { remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null, isFavorite = remote.isFavorite, isArchived = remote.isArchived, - isTrashed = remote.isTrashed; + isTrashed = remote.isTrashed, + stackParentId = remote.stackParentId, + stackCount = remote.stackCount; Asset.local(AssetEntity local, List hash) : localId = local.id, @@ -47,6 +49,7 @@ class Asset { isFavorite = local.isFavorite, isArchived = false, isTrashed = false, + stackCount = 0, fileCreatedAt = local.createDateTime { if (fileCreatedAt.year == 1970) { fileCreatedAt = fileModifiedAt; @@ -77,6 +80,8 @@ class Asset { required this.isFavorite, required this.isArchived, required this.isTrashed, + this.stackParentId, + required this.stackCount, }); @ignore @@ -146,6 +151,10 @@ class Asset { @ignore ExifInfo? exifInfo; + String? stackParentId; + + int stackCount; + /// `true` if this [Asset] is present on the device @ignore bool get isLocal => localId != null; @@ -200,7 +209,9 @@ class Asset { isFavorite == other.isFavorite && isLocal == other.isLocal && isArchived == other.isArchived && - isTrashed == other.isTrashed; + isTrashed == other.isTrashed && + stackCount == other.stackCount && + stackParentId == other.stackParentId; } @override @@ -223,7 +234,9 @@ class Asset { isFavorite.hashCode ^ isLocal.hashCode ^ isArchived.hashCode ^ - isTrashed.hashCode; + isTrashed.hashCode ^ + stackCount.hashCode ^ + stackParentId.hashCode; /// Returns `true` if this [Asset] can updated with values from parameter [a] bool canUpdate(Asset a) { @@ -236,9 +249,11 @@ class Asset { width == null && a.width != null || height == null && a.height != null || livePhotoVideoId == null && a.livePhotoVideoId != null || + stackParentId == null && a.stackParentId != null || isFavorite != a.isFavorite || isArchived != a.isArchived || - isTrashed != a.isTrashed; + isTrashed != a.isTrashed || + stackCount != a.stackCount; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -267,6 +282,8 @@ class Asset { id: id, remoteId: remoteId, livePhotoVideoId: livePhotoVideoId, + stackParentId: stackParentId, + stackCount: stackCount, isFavorite: isFavorite, isArchived: isArchived, isTrashed: isTrashed, @@ -281,6 +298,8 @@ class Asset { width: a.width, height: a.height, livePhotoVideoId: a.livePhotoVideoId, + stackParentId: a.stackParentId, + stackCount: a.stackCount, // isFavorite + isArchived are not set by device-only assets isFavorite: a.isFavorite, isArchived: a.isArchived, @@ -318,6 +337,8 @@ class Asset { bool? isArchived, bool? isTrashed, ExifInfo? exifInfo, + String? stackParentId, + int? stackCount, }) => Asset( id: id ?? this.id, @@ -338,6 +359,8 @@ class Asset { isArchived: isArchived ?? this.isArchived, isTrashed: isTrashed ?? this.isTrashed, exifInfo: exifInfo ?? this.exifInfo, + stackParentId: stackParentId ?? this.stackParentId, + stackCount: stackCount ?? this.stackCount, ); Future put(Isar db) async { @@ -379,6 +402,8 @@ class Asset { "checksum": "$checksum", "ownerId": $ownerId, "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", + "stackCount": "$stackCount", + "stackParentId": "${stackParentId ?? "N/A"}", "fileCreatedAt": "$fileCreatedAt", "fileModifiedAt": "$fileModifiedAt", "updatedAt": "$updatedAt", diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index f06e556ea..4f485dfb0 100644 --- a/mobile/lib/shared/models/asset.g.dart +++ b/mobile/lib/shared/models/asset.g.dart @@ -82,19 +82,29 @@ const AssetSchema = CollectionSchema( name: r'remoteId', type: IsarType.string, ), - r'type': PropertySchema( + r'stackCount': PropertySchema( id: 13, + name: r'stackCount', + type: IsarType.long, + ), + r'stackParentId': PropertySchema( + id: 14, + name: r'stackParentId', + type: IsarType.string, + ), + r'type': PropertySchema( + id: 15, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 14, + id: 16, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 15, + id: 17, name: r'width', type: IsarType.int, ) @@ -184,6 +194,12 @@ int _assetEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.stackParentId; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } return bytesCount; } @@ -206,9 +222,11 @@ void _assetSerialize( writer.writeString(offsets[10], object.localId); writer.writeLong(offsets[11], object.ownerId); writer.writeString(offsets[12], object.remoteId); - writer.writeByte(offsets[13], object.type.index); - writer.writeDateTime(offsets[14], object.updatedAt); - writer.writeInt(offsets[15], object.width); + writer.writeLong(offsets[13], object.stackCount); + writer.writeString(offsets[14], object.stackParentId); + writer.writeByte(offsets[15], object.type.index); + writer.writeDateTime(offsets[16], object.updatedAt); + writer.writeInt(offsets[17], object.width); } Asset _assetDeserialize( @@ -232,10 +250,12 @@ Asset _assetDeserialize( localId: reader.readStringOrNull(offsets[10]), ownerId: reader.readLong(offsets[11]), remoteId: reader.readStringOrNull(offsets[12]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ?? + stackCount: reader.readLong(offsets[13]), + stackParentId: reader.readStringOrNull(offsets[14]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[15])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[14]), - width: reader.readIntOrNull(offsets[15]), + updatedAt: reader.readDateTime(offsets[16]), + width: reader.readIntOrNull(offsets[17]), ); return object; } @@ -274,11 +294,15 @@ P _assetDeserializeProp

( case 12: return (reader.readStringOrNull(offset)) as P; case 13: + return (reader.readLong(offset)) as P; + case 14: + return (reader.readStringOrNull(offset)) as P; + case 15: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 14: + case 16: return (reader.readDateTime(offset)) as P; - case 15: + case 17: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1801,6 +1825,205 @@ extension AssetQueryFilter on QueryBuilder { }); } + QueryBuilder stackCountEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackCount', + value: value, + )); + }); + } + + QueryBuilder stackCountGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'stackCount', + value: value, + )); + }); + } + + QueryBuilder stackCountLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'stackCount', + value: value, + )); + }); + } + + QueryBuilder stackCountBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'stackCount', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder stackParentIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'stackParentId', + )); + }); + } + + QueryBuilder stackParentIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'stackParentId', + )); + }); + } + + QueryBuilder stackParentIdEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'stackParentId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'stackParentId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackParentId', + value: '', + )); + }); + } + + QueryBuilder stackParentIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'stackParentId', + value: '', + )); + }); + } + QueryBuilder typeEqualTo( AssetType value) { return QueryBuilder.apply(this, (query) { @@ -2137,6 +2360,30 @@ extension AssetQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByStackCount() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackCount', Sort.asc); + }); + } + + QueryBuilder sortByStackCountDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackCount', Sort.desc); + }); + } + + QueryBuilder sortByStackParentId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackParentId', Sort.asc); + }); + } + + QueryBuilder sortByStackParentIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackParentId', Sort.desc); + }); + } + QueryBuilder sortByType() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'type', Sort.asc); @@ -2343,6 +2590,30 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByStackCount() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackCount', Sort.asc); + }); + } + + QueryBuilder thenByStackCountDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackCount', Sort.desc); + }); + } + + QueryBuilder thenByStackParentId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackParentId', Sort.asc); + }); + } + + QueryBuilder thenByStackParentIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackParentId', Sort.desc); + }); + } + QueryBuilder thenByType() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'type', Sort.asc); @@ -2465,6 +2736,20 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByStackCount() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'stackCount'); + }); + } + + QueryBuilder distinctByStackParentId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'stackParentId', + caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByType() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'type'); @@ -2569,6 +2854,18 @@ extension AssetQueryProperty on QueryBuilder { }); } + QueryBuilder stackCountProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'stackCount'); + }); + } + + QueryBuilder stackParentIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'stackParentId'); + }); + } + QueryBuilder typeProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'type'); diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 8bffa38a0..78c1c5968 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/services/asset.service.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; @@ -254,6 +255,7 @@ final assetsProvider = .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) + .stackParentIdIsNull() .sortByFileCreatedAtDesc(); final settings = ref.watch(appSettingsServiceProvider); final groupBy = @@ -264,10 +266,12 @@ final assetsProvider = } }); -final remoteAssetsProvider = - StreamProvider.family((ref, userId) async* { - if (userId == null) return; - final query = ref +QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { + final userId = ref.watch(currentUserProvider)?.isarId; + if (userId == null) { + return null; + } + return ref .watch(dbProvider) .assets .where() @@ -275,12 +279,34 @@ final remoteAssetsProvider = .filter() .ownerIdEqualTo(userId) .isTrashedEqualTo(false) + .stackParentIdIsNull() .sortByFileCreatedAtDesc(); - final settings = ref.watch(appSettingsServiceProvider); - final groupBy = - GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; - yield await RenderList.fromQuery(query, groupBy); - await for (final _ in query.watchLazy()) { - yield await RenderList.fromQuery(query, groupBy); +} + +QueryBuilder? getAssetStackSelectionQuery( + WidgetRef ref, + Asset parentAsset, +) { + final userId = ref.watch(currentUserProvider)?.isarId; + if (userId == null || !parentAsset.isRemote) { + return null; } -}); + return ref + .watch(dbProvider) + .assets + .where() + .remoteIdIsNotNull() + .filter() + .isArchivedEqualTo(false) + .ownerIdEqualTo(userId) + .not() + .remoteIdEqualTo(parentAsset.remoteId) + // Show existing stack children in selection page + .group( + (q) => q + .stackParentIdIsNull() + .or() + .stackParentIdEqualTo(parentAsset.remoteId), + ) + .sortByFileCreatedAtDesc(); +} diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index ed529a6d8..1dda262f5 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier { socket.on('on_asset_delete', _handleOnAssetDelete); socket.on('on_asset_trash', _handleServerUpdates); socket.on('on_asset_restore', _handleServerUpdates); + socket.on('on_asset_update', _handleServerUpdates); } catch (e) { debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); } diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index b19860cc8..7c1dfc8fc 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -21,6 +21,7 @@ class ApiService { late PartnerApi partnerApi; late PersonApi personApi; late AuditApi auditApi; + late SharedLinkApi sharedLinkApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -45,6 +46,7 @@ class ApiService { partnerApi = PartnerApi(_apiClient); personApi = PersonApi(_apiClient); auditApi = AuditApi(_apiClient); + sharedLinkApi = SharedLinkApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart index df8f138fb..ab3a096ae 100644 --- a/mobile/lib/shared/services/share.service.dart +++ b/mobile/lib/shared/services/share.service.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'api.service.dart'; @@ -13,32 +14,60 @@ final shareServiceProvider = class ShareService { final ApiService _apiService; + final Logger _log = Logger("ShareService"); ShareService(this._apiService); - Future shareAsset(Asset asset) async { - await shareAssets([asset]); + Future shareAsset(Asset asset) async { + return await shareAssets([asset]); } - Future shareAssets(List assets) async { - final downloadedXFiles = assets.map>((asset) async { - if (asset.isRemote) { - final tempDir = await getTemporaryDirectory(); - final fileName = asset.fileName; - final tempFile = await File('${tempDir.path}/$fileName').create(); - final res = await _apiService.assetApi - .downloadFileWithHttpInfo(asset.remoteId!); - tempFile.writeAsBytesSync(res.bodyBytes); - return XFile(tempFile.path); - } else { - File? f = await asset.local!.file; - return XFile(f!.path); - } - }); + Future shareAssets(List assets) async { + try { + final downloadedXFiles = []; - Share.shareXFiles( - await Future.wait(downloadedXFiles), - sharePositionOrigin: Rect.zero, - ); + for (var asset in assets) { + if (asset.isRemote) { + final tempDir = await getTemporaryDirectory(); + final fileName = asset.fileName; + final tempFile = await File('${tempDir.path}/$fileName').create(); + final res = await _apiService.assetApi + .downloadFileWithHttpInfo(asset.remoteId!); + + if (res.statusCode != 200) { + _log.severe( + "Asset download failed with status - ${res.statusCode} and response - ${res.body}", + ); + continue; + } + + tempFile.writeAsBytesSync(res.bodyBytes); + downloadedXFiles.add(XFile(tempFile.path)); + } else { + File? f = await asset.local!.file; + downloadedXFiles.add(XFile(f!.path)); + } + } + + if (downloadedXFiles.isEmpty) { + _log.warning("No asset can be retrieved for share"); + return false; + } + + if (downloadedXFiles.length != assets.length) { + _log.warning( + "Partial share - Requested: ${assets.length}, Sharing: ${downloadedXFiles.length}", + ); + } + + Share.shareXFiles( + downloadedXFiles, + sharePositionOrigin: Rect.zero, + ); + return true; + } catch (error) { + _log.severe("Share failed with error $error"); + } + return false; } } diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 5eb355173..670a7660d 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -41,7 +41,12 @@ ThemeData immichLightTheme = ThemeData( fontFamily: 'WorkSans', scaffoldBackgroundColor: immichBackgroundColor, snackBarTheme: const SnackBarThemeData( - contentTextStyle: TextStyle(fontFamily: 'WorkSans'), + contentTextStyle: TextStyle( + fontFamily: 'WorkSans', + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), + backgroundColor: Colors.white, ), appBarTheme: AppBarTheme( titleTextStyle: const TextStyle( @@ -156,8 +161,13 @@ ThemeData immichDarkTheme = ThemeData( scaffoldBackgroundColor: immichDarkBackgroundColor, hintColor: Colors.grey[600], fontFamily: 'WorkSans', - snackBarTheme: const SnackBarThemeData( - contentTextStyle: TextStyle(fontFamily: 'WorkSans'), + snackBarTheme: SnackBarThemeData( + contentTextStyle: TextStyle( + fontFamily: 'WorkSans', + color: immichDarkThemePrimaryColor, + fontWeight: FontWeight.bold, + ), + backgroundColor: Colors.grey[900], ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index 08de7961b..511dcf81e 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -15,10 +16,19 @@ void handleShareAssets( showDialog( context: context, builder: (BuildContext buildContext) { - ref - .watch(shareServiceProvider) - .shareAssets(selection.toList()) - .then((_) => Navigator.of(buildContext).pop()); + ref.watch(shareServiceProvider).shareAssets(selection.toList()).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + Navigator.of(buildContext).pop(); + }, + ); return const ShareDialog(); }, barrierDismissible: false, diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index 66ce723a9..b771a6f70 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -1,3 +1,5 @@ +import 'package:immich_mobile/shared/models/store.dart'; + String sanitizeUrl(String url) { // Add schema if none is set final urlWithSchema = @@ -6,3 +8,15 @@ String sanitizeUrl(String url) { // Remove trailing slash(es) return urlWithSchema.replaceFirst(RegExp(r"/+$"), ""); } + +String? getServerUrl() { + final serverUrl = Store.tryGet(StoreKey.serverEndpoint); + final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null; + if (serverUri == null) { + return null; + } + + return serverUri.hasPort + ? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}" + : "${serverUri.scheme}://${serverUri.host}"; +} diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index bf699a313..85b96e647 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -149,6 +149,7 @@ doc/TranscodePolicy.md doc/UpdateAlbumDto.md doc/UpdateAssetDto.md doc/UpdateLibraryDto.md +doc/UpdateStackParentDto.md doc/UpdateTagDto.md doc/UpdateUserDto.md doc/UsageByUserDto.md @@ -314,6 +315,7 @@ lib/model/transcode_policy.dart lib/model/update_album_dto.dart lib/model/update_asset_dto.dart lib/model/update_library_dto.dart +lib/model/update_stack_parent_dto.dart lib/model/update_tag_dto.dart lib/model/update_user_dto.dart lib/model/usage_by_user_dto.dart @@ -468,6 +470,7 @@ test/transcode_policy_test.dart test/update_album_dto_test.dart test/update_asset_dto_test.dart test/update_library_dto_test.dart +test/update_stack_parent_dto_test.dart test/update_tag_dto_test.dart test/update_user_dto_test.dart test/usage_by_user_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 8ccdc36a4..47d04b9bd 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.82.0 +- API version: 1.82.1 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements @@ -116,6 +116,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | *AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset | +*AssetApi* | [**updateStackParent**](doc//AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent | *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | *AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | @@ -330,6 +331,7 @@ Class | Method | HTTP request | Description - [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) + - [UpdateStackParentDto](doc//UpdateStackParentDto.md) - [UpdateTagDto](doc//UpdateTagDto.md) - [UpdateUserDto](doc//UpdateUserDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 30914b10c..075c32bd9 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -38,6 +38,7 @@ Method | HTTP request | Description [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | [**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset | +[**updateStackParent**](AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent | [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | @@ -1696,6 +1697,60 @@ 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) +# **updateStackParent** +> updateStackParent(updateStackParentDto) + + + +### 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 updateStackParentDto = UpdateStackParentDto(); // UpdateStackParentDto | + +try { + api_instance.updateStackParent(updateStackParentDto); +} catch (e) { + print('Exception when calling AssetApi->updateStackParent: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **updateStackParentDto** | [**UpdateStackParentDto**](UpdateStackParentDto.md)| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: Not defined + +[[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) + # **uploadFile** > AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData) diff --git a/mobile/openapi/doc/AssetBulkUpdateDto.md b/mobile/openapi/doc/AssetBulkUpdateDto.md index b48268464..74fd5ec45 100644 --- a/mobile/openapi/doc/AssetBulkUpdateDto.md +++ b/mobile/openapi/doc/AssetBulkUpdateDto.md @@ -11,6 +11,8 @@ Name | Type | Description | Notes **ids** | **List** | | [default to const []] **isArchived** | **bool** | | [optional] **isFavorite** | **bool** | | [optional] +**removeParent** | **bool** | | [optional] +**stackParentId** | **String** | | [optional] [[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/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index a08be71ac..8c4d1db4a 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -33,6 +33,9 @@ Name | Type | Description | Notes **people** | [**List**](PersonResponseDto.md) | | [optional] [default to const []] **resized** | **bool** | | **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional] +**stack** | [**List**](AssetResponseDto.md) | | [optional] [default to const []] +**stackCount** | **int** | | +**stackParentId** | **String** | | [optional] **tags** | [**List**](TagResponseDto.md) | | [optional] [default to const []] **thumbhash** | **String** | | **type** | [**AssetTypeEnum**](AssetTypeEnum.md) | | diff --git a/mobile/openapi/doc/SharedLinkEditDto.md b/mobile/openapi/doc/SharedLinkEditDto.md index f035e23c6..ccd0d3b54 100644 --- a/mobile/openapi/doc/SharedLinkEditDto.md +++ b/mobile/openapi/doc/SharedLinkEditDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **allowDownload** | **bool** | | [optional] **allowUpload** | **bool** | | [optional] +**changeExpiryTime** | **bool** | Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. | [optional] **description** | **String** | | [optional] **expiresAt** | [**DateTime**](DateTime.md) | | [optional] **showMetadata** | **bool** | | [optional] diff --git a/mobile/openapi/doc/UpdateStackParentDto.md b/mobile/openapi/doc/UpdateStackParentDto.md new file mode 100644 index 000000000..750daace0 --- /dev/null +++ b/mobile/openapi/doc/UpdateStackParentDto.md @@ -0,0 +1,16 @@ +# openapi.model.UpdateStackParentDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**newParentId** | **String** | | +**oldParentId** | **String** | | + +[[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 48745a162..e72c1da16 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -176,6 +176,7 @@ part 'model/transcode_policy.dart'; part 'model/update_album_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; +part 'model/update_stack_parent_dto.dart'; part 'model/update_tag_dto.dart'; part 'model/update_user_dto.dart'; part 'model/usage_by_user_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 91429ce7e..5935f5676 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1654,6 +1654,45 @@ class AssetApi { } } + /// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response]. + /// Parameters: + /// + /// * [UpdateStackParentDto] updateStackParentDto (required): + Future updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async { + // ignore: prefer_const_declarations + final path = r'/asset/stack/parent'; + + // ignore: prefer_final_locals + Object? postBody = updateStackParentDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [UpdateStackParentDto] updateStackParentDto (required): + Future updateStackParent(UpdateStackParentDto updateStackParentDto,) async { + final response = await updateStackParentWithHttpInfo(updateStackParentDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9a98b4997..34b9a431d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -443,6 +443,8 @@ class ApiClient { return UpdateAssetDto.fromJson(value); case 'UpdateLibraryDto': return UpdateLibraryDto.fromJson(value); + case 'UpdateStackParentDto': + return UpdateStackParentDto.fromJson(value); case 'UpdateTagDto': return UpdateTagDto.fromJson(value); case 'UpdateUserDto': diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 7eb0e31af..64c8d1e7e 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -16,6 +16,8 @@ class AssetBulkUpdateDto { this.ids = const [], this.isArchived, this.isFavorite, + this.removeParent, + this.stackParentId, }); List ids; @@ -36,21 +38,41 @@ class AssetBulkUpdateDto { /// bool? isFavorite; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? removeParent; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? stackParentId; + @override bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && other.ids == ids && other.isArchived == isArchived && - other.isFavorite == isFavorite; + other.isFavorite == isFavorite && + other.removeParent == removeParent && + other.stackParentId == stackParentId; @override int get hashCode => // ignore: unnecessary_parenthesis (ids.hashCode) + (isArchived == null ? 0 : isArchived!.hashCode) + - (isFavorite == null ? 0 : isFavorite!.hashCode); + (isFavorite == null ? 0 : isFavorite!.hashCode) + + (removeParent == null ? 0 : removeParent!.hashCode) + + (stackParentId == null ? 0 : stackParentId!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]'; + String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, removeParent=$removeParent, stackParentId=$stackParentId]'; Map toJson() { final json = {}; @@ -65,6 +87,16 @@ class AssetBulkUpdateDto { } else { // json[r'isFavorite'] = null; } + if (this.removeParent != null) { + json[r'removeParent'] = this.removeParent; + } else { + // json[r'removeParent'] = null; + } + if (this.stackParentId != null) { + json[r'stackParentId'] = this.stackParentId; + } else { + // json[r'stackParentId'] = null; + } return json; } @@ -81,6 +113,8 @@ class AssetBulkUpdateDto { : const [], isArchived: mapValueOfType(json, r'isArchived'), isFavorite: mapValueOfType(json, r'isFavorite'), + removeParent: mapValueOfType(json, r'removeParent'), + stackParentId: mapValueOfType(json, r'stackParentId'), ); } return null; diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index b2feb0ee8..e580ca5a2 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -38,6 +38,9 @@ class AssetResponseDto { this.people = const [], required this.resized, this.smartInfo, + this.stack = const [], + required this.stackCount, + this.stackParentId, this.tags = const [], required this.thumbhash, required this.type, @@ -113,6 +116,12 @@ class AssetResponseDto { /// SmartInfoResponseDto? smartInfo; + List stack; + + int stackCount; + + String? stackParentId; + List tags; String? thumbhash; @@ -148,6 +157,9 @@ class AssetResponseDto { other.people == people && other.resized == resized && other.smartInfo == smartInfo && + other.stack == stack && + other.stackCount == stackCount && + other.stackParentId == stackParentId && other.tags == tags && other.thumbhash == thumbhash && other.type == type && @@ -181,13 +193,16 @@ class AssetResponseDto { (people.hashCode) + (resized.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) + + (stack.hashCode) + + (stackCount.hashCode) + + (stackParentId == null ? 0 : stackParentId!.hashCode) + (tags.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -231,6 +246,13 @@ class AssetResponseDto { json[r'smartInfo'] = this.smartInfo; } else { // json[r'smartInfo'] = null; + } + json[r'stack'] = this.stack; + json[r'stackCount'] = this.stackCount; + if (this.stackParentId != null) { + json[r'stackParentId'] = this.stackParentId; + } else { + // json[r'stackParentId'] = null; } json[r'tags'] = this.tags; if (this.thumbhash != null) { @@ -276,6 +298,9 @@ class AssetResponseDto { people: PersonResponseDto.listFromJson(json[r'people']), resized: mapValueOfType(json, r'resized')!, smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), + stack: AssetResponseDto.listFromJson(json[r'stack']), + stackCount: mapValueOfType(json, r'stackCount')!, + stackParentId: mapValueOfType(json, r'stackParentId'), tags: TagResponseDto.listFromJson(json[r'tags']), thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, @@ -347,6 +372,7 @@ class AssetResponseDto { 'originalPath', 'ownerId', 'resized', + 'stackCount', 'thumbhash', 'type', 'updatedAt', diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 6b72e025d..108734999 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -15,6 +15,7 @@ class SharedLinkEditDto { SharedLinkEditDto({ this.allowDownload, this.allowUpload, + this.changeExpiryTime, this.description, this.expiresAt, this.showMetadata, @@ -36,6 +37,15 @@ class SharedLinkEditDto { /// bool? allowUpload; + /// Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? changeExpiryTime; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -58,6 +68,7 @@ class SharedLinkEditDto { bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto && other.allowDownload == allowDownload && other.allowUpload == allowUpload && + other.changeExpiryTime == changeExpiryTime && other.description == description && other.expiresAt == expiresAt && other.showMetadata == showMetadata; @@ -67,12 +78,13 @@ class SharedLinkEditDto { // ignore: unnecessary_parenthesis (allowDownload == null ? 0 : allowDownload!.hashCode) + (allowUpload == null ? 0 : allowUpload!.hashCode) + + (changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) + (description == null ? 0 : description!.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) + (showMetadata == null ? 0 : showMetadata!.hashCode); @override - String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]'; + String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]'; Map toJson() { final json = {}; @@ -86,6 +98,11 @@ class SharedLinkEditDto { } else { // json[r'allowUpload'] = null; } + if (this.changeExpiryTime != null) { + json[r'changeExpiryTime'] = this.changeExpiryTime; + } else { + // json[r'changeExpiryTime'] = null; + } if (this.description != null) { json[r'description'] = this.description; } else { @@ -114,6 +131,7 @@ class SharedLinkEditDto { return SharedLinkEditDto( allowDownload: mapValueOfType(json, r'allowDownload'), allowUpload: mapValueOfType(json, r'allowUpload'), + changeExpiryTime: mapValueOfType(json, r'changeExpiryTime'), description: mapValueOfType(json, r'description'), expiresAt: mapDateTime(json, r'expiresAt', ''), showMetadata: mapValueOfType(json, r'showMetadata'), diff --git a/mobile/openapi/lib/model/update_stack_parent_dto.dart b/mobile/openapi/lib/model/update_stack_parent_dto.dart new file mode 100644 index 000000000..8940f748d --- /dev/null +++ b/mobile/openapi/lib/model/update_stack_parent_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 UpdateStackParentDto { + /// Returns a new [UpdateStackParentDto] instance. + UpdateStackParentDto({ + required this.newParentId, + required this.oldParentId, + }); + + String newParentId; + + String oldParentId; + + @override + bool operator ==(Object other) => identical(this, other) || other is UpdateStackParentDto && + other.newParentId == newParentId && + other.oldParentId == oldParentId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (newParentId.hashCode) + + (oldParentId.hashCode); + + @override + String toString() => 'UpdateStackParentDto[newParentId=$newParentId, oldParentId=$oldParentId]'; + + Map toJson() { + final json = {}; + json[r'newParentId'] = this.newParentId; + json[r'oldParentId'] = this.oldParentId; + return json; + } + + /// Returns a new [UpdateStackParentDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UpdateStackParentDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return UpdateStackParentDto( + newParentId: mapValueOfType(json, r'newParentId')!, + oldParentId: mapValueOfType(json, r'oldParentId')!, + ); + } + 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 = UpdateStackParentDto.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 = UpdateStackParentDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UpdateStackParentDto-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] = UpdateStackParentDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'newParentId', + 'oldParentId', + }; +} + diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 640652167..8e45a1e3c 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -174,6 +174,11 @@ void main() { // TODO }); + //Future updateStackParent(UpdateStackParentDto updateStackParentDto) async + test('test updateStackParent', () async { + // TODO + }); + //Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async test('test uploadFile', () async { // TODO diff --git a/mobile/openapi/test/asset_bulk_update_dto_test.dart b/mobile/openapi/test/asset_bulk_update_dto_test.dart index cb23751e0..06f65de66 100644 --- a/mobile/openapi/test/asset_bulk_update_dto_test.dart +++ b/mobile/openapi/test/asset_bulk_update_dto_test.dart @@ -31,6 +31,16 @@ void main() { // TODO }); + // bool removeParent + test('to test the property `removeParent`', () async { + // TODO + }); + + // String stackParentId + test('to test the property `stackParentId`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index f450aae27..63668934a 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -142,6 +142,21 @@ void main() { // TODO }); + // List stack (default value: const []) + test('to test the property `stack`', () async { + // TODO + }); + + // int stackCount + test('to test the property `stackCount`', () async { + // TODO + }); + + // String stackParentId + test('to test the property `stackParentId`', () async { + // TODO + }); + // List tags (default value: const []) test('to test the property `tags`', () async { // TODO diff --git a/mobile/openapi/test/shared_link_edit_dto_test.dart b/mobile/openapi/test/shared_link_edit_dto_test.dart index 26fbb92fd..893d12efe 100644 --- a/mobile/openapi/test/shared_link_edit_dto_test.dart +++ b/mobile/openapi/test/shared_link_edit_dto_test.dart @@ -26,6 +26,12 @@ void main() { // TODO }); + // Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. + // bool changeExpiryTime + test('to test the property `changeExpiryTime`', () async { + // TODO + }); + // String description test('to test the property `description`', () async { // TODO diff --git a/mobile/openapi/test/update_stack_parent_dto_test.dart b/mobile/openapi/test/update_stack_parent_dto_test.dart new file mode 100644 index 000000000..6af71854e --- /dev/null +++ b/mobile/openapi/test/update_stack_parent_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 UpdateStackParentDto +void main() { + // final instance = UpdateStackParentDto(); + + group('test UpdateStackParentDto', () { + // String newParentId + test('to test the property `newParentId`', () async { + // TODO + }); + + // String oldParentId + test('to test the property `oldParentId`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f902fe7cb..9bae30657 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.82.0+106 +version: 1.82.1+106 isar_version: &isar_version 3.1.0+1 environment: diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index a124f5214..6b8f08063 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -25,6 +25,7 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, + stackCount: 0, ), ); } diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart index 9c03ec689..b2543c663 100644 --- a/mobile/test/sync_service_test.dart +++ b/mobile/test/sync_service_test.dart @@ -35,6 +35,7 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, + stackCount: 0, ); } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 25631ffda..e6230fcc3 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1673,6 +1673,41 @@ ] } }, + "/asset/stack/parent": { + "put": { + "operationId": "updateStackParent", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStackParentDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/asset/statistics": { "get": { "operationId": "getAssetStats", @@ -5379,7 +5414,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.82.0", + "version": "1.82.1", "contact": {} }, "tags": [], @@ -5696,6 +5731,13 @@ }, "isFavorite": { "type": "boolean" + }, + "removeParent": { + "type": "boolean" + }, + "stackParentId": { + "format": "uuid", + "type": "string" } }, "required": [ @@ -5941,6 +5983,19 @@ "smartInfo": { "$ref": "#/components/schemas/SmartInfoResponseDto" }, + "stack": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "stackCount": { + "type": "integer" + }, + "stackParentId": { + "nullable": true, + "type": "string" + }, "tags": { "items": { "$ref": "#/components/schemas/TagResponseDto" @@ -5961,6 +6016,7 @@ }, "required": [ "type", + "stackCount", "deviceAssetId", "deviceId", "ownerId", @@ -7846,6 +7902,10 @@ "allowUpload": { "type": "boolean" }, + "changeExpiryTime": { + "description": "Few clients cannot send null to set the expiryTime to never.\nSetting this flag and not sending expiryAt is considered as null instead.\nClients that can send null values can ignore this.", + "type": "boolean" + }, "description": { "type": "string" }, @@ -8521,6 +8581,23 @@ }, "type": "object" }, + "UpdateStackParentDto": { + "properties": { + "newParentId": { + "format": "uuid", + "type": "string" + }, + "oldParentId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "oldParentId", + "newParentId" + ], + "type": "object" + }, "UpdateTagDto": { "properties": { "name": { diff --git a/server/package-lock.json b/server/package-lock.json index a9480d52f..0b54b2f7f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.82.0", + "version": "1.82.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.82.0", + "version": "1.82.1", "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index e4a51cbbe..def7a64ac 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.82.0", + "version": "1.82.1", "description": "", "author": "", "private": true, @@ -26,7 +26,7 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand --forceExit", + "test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand", "typeorm": "typeorm", "typeorm:migrations:create": "typeorm migration:create", "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js", diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index e326a2711..453539129 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -1,7 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { albumStub, - assetStub, authStub, IAccessRepositoryMock, newAccessRepositoryMock, @@ -225,7 +224,7 @@ describe(AlbumService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.hasAsset).toHaveBeenCalledWith(albumStub.oneAsset.id, 'not-in-album'); + expect(albumMock.hasAsset).toHaveBeenCalledWith({ albumId: 'album-4', assetId: 'not-in-album' }); expect(albumMock.update).not.toHaveBeenCalled(); }); @@ -461,6 +460,7 @@ describe(AlbumService.name, () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + albumMock.hasAsset.mockResolvedValue(false); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -473,9 +473,12 @@ describe(AlbumService.name, () => { expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date), - assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], albumThumbnailAssetId: 'asset-1', }); + expect(albumMock.addAssets).toHaveBeenCalledWith({ + albumId: 'album-123', + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }); }); it('should not set the thumbnail if the album has one already', async () => { @@ -490,9 +493,9 @@ describe(AlbumService.name, () => { expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date), - assets: [{ id: 'asset-1' }], albumThumbnailAssetId: 'asset-id', }); + expect(albumMock.addAssets).toHaveBeenCalled(); }); it('should allow a shared user to add assets', async () => { @@ -512,9 +515,12 @@ describe(AlbumService.name, () => { expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date), - assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], albumThumbnailAssetId: 'asset-1', }); + expect(albumMock.addAssets).toHaveBeenCalledWith({ + albumId: 'album-123', + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }); }); it('should allow a shared link user to add assets', async () => { @@ -523,6 +529,7 @@ describe(AlbumService.name, () => { accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + albumMock.hasAsset.mockResolvedValue(false); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -535,9 +542,12 @@ describe(AlbumService.name, () => { expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date), - assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], albumThumbnailAssetId: 'asset-1', }); + expect(albumMock.addAssets).toHaveBeenCalledWith({ + albumId: 'album-123', + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }); expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLinkId, @@ -550,6 +560,7 @@ describe(AlbumService.name, () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.hasPartnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + albumMock.hasAsset.mockResolvedValue(false); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, @@ -558,10 +569,8 @@ describe(AlbumService.name, () => { expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date), - assets: [assetStub.image, { id: 'asset-1' }], albumThumbnailAssetId: 'asset-1', }); - expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); }); @@ -569,6 +578,7 @@ describe(AlbumService.name, () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + albumMock.hasAsset.mockResolvedValue(true); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, @@ -620,17 +630,14 @@ describe(AlbumService.name, () => { it('should allow the owner to remove assets', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + albumMock.hasAsset.mockResolvedValue(true); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, ]); - expect(albumMock.update).toHaveBeenCalledWith({ - id: 'album-123', - updatedAt: expect.any(Date), - assets: [], - albumThumbnailAssetId: null, - }); + expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) }); + expect(albumMock.removeAssets).toHaveBeenCalledWith({ assetIds: ['asset-id'], albumId: 'album-123' }); }); it('should skip assets not in the album', async () => { @@ -647,9 +654,14 @@ describe(AlbumService.name, () => { it('should skip assets without user permission to remove', async () => { accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + albumMock.hasAsset.mockResolvedValue(true); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ - { success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION }, + { + success: false, + id: 'asset-id', + error: BulkIdErrorReason.NO_PERMISSION, + }, ]); expect(albumMock.update).not.toHaveBeenCalled(); @@ -658,6 +670,7 @@ describe(AlbumService.name, () => { it('should reset the thumbnail if it is removed', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); + albumMock.hasAsset.mockResolvedValue(true); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, @@ -666,9 +679,8 @@ describe(AlbumService.name, () => { expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date), - assets: [assetStub.withLocation], - albumThumbnailAssetId: assetStub.withLocation.id, }); + expect(albumMock.updateThumbnails).toHaveBeenCalled(); }); }); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 5da0b3440..04b885040 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -120,7 +120,7 @@ export class AlbumService { const album = await this.findOrFail(id, { withAssets: true }); if (dto.albumThumbnailAssetId) { - const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId); + const valid = await this.albumRepository.hasAsset({ albumId: id, assetId: dto.albumThumbnailAssetId }); if (!valid) { throw new BadRequestException('Invalid album thumbnail'); } @@ -148,35 +148,34 @@ export class AlbumService { } async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise { - const album = await this.findOrFail(id, { withAssets: true }); + const album = await this.findOrFail(id, { withAssets: false }); await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); const results: BulkIdResponseDto[] = []; - for (const id of dto.ids) { - const hasAsset = album.assets.find((asset) => asset.id === id); + for (const assetId of dto.ids) { + const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId }); if (hasAsset) { - results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE }); + results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE }); continue; } - const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id); + const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId); if (!hasAccess) { - results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION }); + results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); continue; } - results.push({ id, success: true }); - album.assets.push({ id } as AssetEntity); + results.push({ id: assetId, success: true }); } - const newAsset = results.find(({ success }) => success); - if (newAsset) { + const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id); + if (newAssetIds.length > 0) { + await this.albumRepository.addAssets({ albumId: id, assetIds: newAssetIds }); await this.albumRepository.update({ id, - assets: album.assets, updatedAt: new Date(), - albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id, + albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAssetIds[0], }); } @@ -184,42 +183,37 @@ export class AlbumService { } async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise { - const album = await this.findOrFail(id, { withAssets: true }); + const album = await this.findOrFail(id, { withAssets: false }); await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); const results: BulkIdResponseDto[] = []; - for (const id of dto.ids) { - const hasAsset = album.assets.find((asset) => asset.id === id); + for (const assetId of dto.ids) { + const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId }); if (!hasAsset) { - results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND }); + results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND }); continue; } const hasAccess = await this.access.hasAny(authUser, [ - { permission: Permission.ALBUM_REMOVE_ASSET, id }, - { permission: Permission.ASSET_SHARE, id }, + { permission: Permission.ALBUM_REMOVE_ASSET, id: assetId }, + { permission: Permission.ASSET_SHARE, id: assetId }, ]); if (!hasAccess) { - results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION }); + results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); continue; } - results.push({ id, success: true }); - album.assets = album.assets.filter((asset) => asset.id !== id); - if (album.albumThumbnailAssetId === id) { - album.albumThumbnailAssetId = null; - } + results.push({ id: assetId, success: true }); } - const hasSuccess = results.find(({ success }) => success); - if (hasSuccess) { - await this.albumRepository.update({ - id, - assets: album.assets, - updatedAt: new Date(), - albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null, - }); + const removedIds = results.filter(({ success }) => success).map(({ id }) => id); + if (removedIds.length > 0) { + await this.albumRepository.removeAssets({ albumId: id, assetIds: removedIds }); + await this.albumRepository.update({ id, updatedAt: new Date() }); + if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) { + await this.albumRepository.updateThumbnails(); + } } return results; diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 20e86f159..763256d0d 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -20,6 +20,7 @@ import { Readable } from 'stream'; import { JobName } from '../job'; import { AssetStats, + CommunicationEvent, IAssetRepository, ICommunicationRepository, ICryptoRepository, @@ -636,10 +637,89 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); + + /// Stack related + + it('should require asset update access for parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false); + await expect( + sut.updateAll(authStub.user1, { + ids: ['asset-1'], + stackParentId: 'parent', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should update parent asset when children are added', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + ids: [], + stackParentId: 'parent', + }), + expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null }); + }); + + it('should update parent asset when children are removed', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]); + + await sut.updateAll(authStub.user1, { + ids: ['child-1'], + removeParent: true, + }), + expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null }); + }); + + it('update parentId for new children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + stackParentId: 'parent', + ids: ['child-1', 'child-2'], + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' }); + }); + + it('nullify parentId for remove children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + removeParent: true, + ids: ['child-1', 'child-2'], + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null }); + }); + + it('merge stacks if new child has children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([ + { id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity, + ]); + + await sut.updateAll(authStub.user1, { + ids: ['child-1'], + stackParentId: 'parent', + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' }); + }); + + it('should send ws asset update event', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + ids: ['asset-1'], + stackParentId: 'parent', + }); + + expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [ + 'asset-1', + ]); + }); }); describe('deleteAll', () => { - it('should required asset delete access for all ids', async () => { + it('should require asset delete access for all ids', async () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.deleteAll(authStub.user1, { @@ -677,7 +757,7 @@ describe(AssetService.name, () => { }); describe('restoreAll', () => { - it('should required asset restore access for all ids', async () => { + it('should require asset restore access for all ids', async () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.deleteAll(authStub.user1, { @@ -757,6 +837,21 @@ describe(AssetService.name, () => { expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); }); + it('should update stack parent if asset has stack children', async () => { + when(assetMock.getById) + .calledWith(assetStub.primaryImage.id) + .mockResolvedValue(assetStub.primaryImage as AssetEntity); + + await sut.handleAssetDeletion({ id: assetStub.primaryImage.id }); + + expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], { + stackParentId: 'stack-child-asset-1', + }); + expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], { + stackParentId: null, + }); + }); + it('should not schedule delete-files job for readonly assets', async () => { when(assetMock.getById) .calledWith(assetStub.readOnly.id) @@ -854,4 +949,70 @@ describe(AssetService.name, () => { expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }); }); }); + + describe('updateStackParent', () => { + it('should require asset update access for new parent', async () => { + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false); + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + await expect( + sut.updateStackParent(authStub.user1, { + oldParentId: 'old', + newParentId: 'new', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should require asset read access for old parent', async () => { + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true); + await expect( + sut.updateStackParent(authStub.user1, { + oldParentId: 'old', + newParentId: 'new', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('make old parent the child of new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getById) + .calledWith(assetStub.image.id) + .mockResolvedValue(assetStub.image as AssetEntity); + + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.image.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' }); + }); + + it('remove stackParentId of new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.primaryImage.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null }); + }); + + it('update stackParentId of old parents children to new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getById) + .calledWith(assetStub.primaryImage.id) + .mockResolvedValue(assetStub.primaryImage as AssetEntity); + + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.primaryImage.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith( + [assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'], + { stackParentId: 'new' }, + ); + }); + }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index abd0dbe0d..57623fa1b 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -40,6 +40,7 @@ import { TimeBucketDto, TrashAction, UpdateAssetDto, + UpdateStackParentDto, mapStats, } from './dto'; import { @@ -208,7 +209,7 @@ export class AssetService { if (authUser.isShowMetadata) { return assets.map((asset) => mapAsset(asset)); } else { - return assets.map((asset) => mapAsset(asset, true)); + return assets.map((asset) => mapAsset(asset, { stripMetadata: true })); } } @@ -338,10 +339,29 @@ export class AssetService { } async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise { - const { ids, ...options } = dto; + const { ids, removeParent, ...options } = dto; await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); + + if (removeParent) { + (options as Partial).stackParentId = null; + const assets = await this.assetRepository.getByIds(ids); + // This updates the updatedAt column of the parents to indicate that one of its children is removed + // All the unique parent's -> parent is set to null + ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!))); + } else if (options.stackParentId) { + await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId); + // Merge stacks + const assets = await this.assetRepository.getByIds(ids); + const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0); + ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id))); + + // This updates the updatedAt column of the parent to indicate that a new child has been added + await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null }); + } + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); await this.assetRepository.updateAll(ids, options); + this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids); } async handleAssetDeletionCheck() { @@ -384,6 +404,14 @@ export class AssetService { ); } + // Replace the parent of the stack children with a new asset + if (asset.stack && asset.stack.length != 0) { + const stackIds = asset.stack.map((a) => a.id); + const newParentId = stackIds[0]; + await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId }); + await this.assetRepository.updateAll([newParentId], { stackParentId: null }); + } + await this.assetRepository.remove(asset); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id); @@ -454,6 +482,25 @@ export class AssetService { this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids); } + async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise { + const { oldParentId, newParentId } = dto; + await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId); + await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId); + + const childIds: string[] = []; + const oldParent = await this.assetRepository.getById(oldParentId); + if (oldParent != null) { + childIds.push(oldParent.id); + // Get all children of old parent + childIds.push(...(oldParent.stack?.map((a) => a.id) ?? [])); + } + + this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]); + await this.assetRepository.updateAll(childIds, { stackParentId: newParentId }); + // Remove ParentId of new parent if this was previously a child of some other asset + return this.assetRepository.updateAll([newParentId], { stackParentId: null }); + } + async run(authUser: AuthUserDto, dto: AssetJobsDto) { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds); diff --git a/server/src/domain/asset/dto/asset-stack.dto.ts b/server/src/domain/asset/dto/asset-stack.dto.ts new file mode 100644 index 000000000..80dabdb34 --- /dev/null +++ b/server/src/domain/asset/dto/asset-stack.dto.ts @@ -0,0 +1,9 @@ +import { ValidateUUID } from '../../domain.util'; + +export class UpdateStackParentDto { + @ValidateUUID() + oldParentId!: string; + + @ValidateUUID() + newParentId!: string; +} diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index f5ada315c..0b3ce68d5 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,6 +1,6 @@ import { Type } from 'class-transformer'; import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator'; -import { Optional } from '../../domain.util'; +import { Optional, ValidateUUID } from '../../domain.util'; import { BulkIdsDto } from '../response-dto'; export class AssetBulkUpdateDto extends BulkIdsDto { @@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto { @Optional() @IsBoolean() isArchived?: boolean; + + @Optional() + @ValidateUUID() + stackParentId?: string; + + @Optional() + @IsBoolean() + removeParent?: boolean; } export class UpdateAssetDto { diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 8e780869a..281d924f3 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,4 +1,5 @@ export * from './asset-ids.dto'; +export * from './asset-stack.dto'; export * from './asset-statistics.dto'; export * from './asset.dto'; export * from './download.dto'; diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index e7d5061be..0e5784055 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { people?: PersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; + stackParentId?: string | null; + stack?: AssetResponseDto[]; + @ApiProperty({ type: 'integer' }) + stackCount!: number; } -export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto { +export type AssetMapOptions = { + stripMetadata?: boolean; + withStack?: boolean; +}; + +export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { + const { stripMetadata = false, withStack = false } = options; + const sanitizedAssetResponse: SanitizedAssetResponseDto = { id: entity.id, type: entity.type, @@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo tags: entity.tags?.map(mapTag), people: entity.faces?.map(mapFace).filter((person) => !person.isHidden), checksum: entity.checksum.toString('base64'), + stackParentId: entity.stackParentId, + stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined, + stackCount: entity.stack?.length ?? 0, isExternal: entity.isExternal, isOffline: entity.isOffline, isReadOnly: entity.isReadOnly, diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 1096db613..86befcee8 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -409,6 +409,54 @@ describe(MetadataService.name, () => { localDateTime: new Date('1970-01-01'), }); }); + + it('should handle duration', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.getExifTags.mockResolvedValue({ Duration: 6.21 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalled(); + expect(assetMock.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: assetStub.image.id, + duration: '00:00:06.210', + }), + ); + }); + + it('should handle duration as an object without Scale', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.getExifTags.mockResolvedValue({ Duration: { Value: 6.2 } }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalled(); + expect(assetMock.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: assetStub.image.id, + duration: '00:00:06.200', + }), + ); + }); + + it('should handle duration with scale', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.getExifTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalled(); + expect(assetMock.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: assetStub.image.id, + duration: '00:00:06.207', + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 2779df54c..0b6855ddf 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -7,6 +7,7 @@ import { Duration } from 'luxon'; import { usePagination } from '../domain.util'; import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { + ExifDuration, IAlbumRepository, IAssetRepository, ICryptoRepository, @@ -109,6 +110,10 @@ export class MetadataService { } } + async teardown() { + await this.repository.teardown(); + } + async handleLivePhotoLinking(job: IEntityJob) { const { id } = job; const [asset] = await this.assetRepository.getByIds([id]); @@ -394,7 +399,11 @@ export class MetadataService { return bitsPerSample; } - private getDuration(seconds?: number): string { - return Duration.fromObject({ seconds }).toFormat('hh:mm:ss.SSS'); + private getDuration(seconds?: number | ExifDuration): string { + let _seconds = seconds as number; + if (typeof seconds === 'object') { + _seconds = seconds.Value * (seconds?.Scale || 1); + } + return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS'); } } diff --git a/server/src/domain/repositories/album.repository.ts b/server/src/domain/repositories/album.repository.ts index 5a54dbc80..276ab796b 100644 --- a/server/src/domain/repositories/album.repository.ts +++ b/server/src/domain/repositories/album.repository.ts @@ -11,13 +11,24 @@ export interface AlbumInfoOptions { withAssets: boolean; } +export interface AlbumAsset { + albumId: string; + assetId: string; +} + +export interface AlbumAssets { + albumId: string; + assetIds: string[]; +} + export interface IAlbumRepository { getById(id: string, options: AlbumInfoOptions): Promise; getByIds(ids: string[]): Promise; getByAssetId(ownerId: string, assetId: string): Promise; - hasAsset(id: string, assetId: string): Promise; - /** Remove an asset from _all_ albums */ - removeAsset(id: string): Promise; + addAssets(assets: AlbumAssets): Promise; + hasAsset(asset: AlbumAsset): Promise; + removeAsset(assetId: string): Promise; + removeAssets(assets: AlbumAssets): Promise; getAssetCountForIds(ids: string[]): Promise; getInvalidThumbnail(): Promise; getOwned(ownerId: string): Promise; diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index f49beeb50..f4c06a1e9 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -4,6 +4,7 @@ export enum CommunicationEvent { UPLOAD_SUCCESS = 'on_upload_success', ASSET_DELETE = 'on_asset_delete', ASSET_TRASH = 'on_asset_trash', + ASSET_UPDATE = 'on_asset_update', ASSET_RESTORE = 'on_asset_restore', PERSON_THUMBNAIL = 'on_person_thumbnail', SERVER_VERSION = 'on_server_version', diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts index a037964f4..0c3b78462 100644 --- a/server/src/domain/repositories/metadata.repository.ts +++ b/server/src/domain/repositories/metadata.repository.ts @@ -14,7 +14,12 @@ export interface ReverseGeocodeResult { city: string | null; } -export interface ImmichTags extends Omit { +export interface ExifDuration { + Value: number; + Scale?: number; +} + +export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; MotionPhotoVersion?: number; @@ -22,10 +27,12 @@ export interface ImmichTags extends Omit { MediaGroupUUID?: string; ImagePixelDepth?: string; FocalLength?: number; + Duration?: number | ExifDuration; } export interface IMetadataRepository { init(options: Partial): Promise; + teardown(): Promise; reverseGeocode(point: GeoPoint): Promise; deleteCache(): Promise; getExifTags(path: string): Promise; diff --git a/server/src/domain/shared-link/shared-link-response.dto.ts b/server/src/domain/shared-link/shared-link-response.dto.ts index 52592d36f..4e35f6546 100644 --- a/server/src/domain/shared-link/shared-link-response.dto.ts +++ b/server/src/domain/shared-link/shared-link-response.dto.ts @@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[], + assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[], album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts index 4c86afb62..ed38cf984 100644 --- a/server/src/domain/shared-link/shared-link.dto.ts +++ b/server/src/domain/shared-link/shared-link.dto.ts @@ -52,4 +52,13 @@ export class SharedLinkEditDto { @Optional() showMetadata?: boolean; + + /** + * Few clients cannot send null to set the expiryTime to never. + * Setting this flag and not sending expiryAt is considered as null instead. + * Clients that can send null values can ignore this. + */ + @Optional() + @IsBoolean() + changeExpiryTime?: boolean; } diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts index 06b5b7897..9e2a0fc8a 100644 --- a/server/src/domain/shared-link/shared-link.service.ts +++ b/server/src/domain/shared-link/shared-link.service.ts @@ -81,7 +81,7 @@ export class SharedLinkService { id, userId: authUser.id, description: dto.description, - expiresAt: dto.expiresAt, + expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt, allowUpload: dto.allowUpload, allowDownload: dto.allowDownload, showExif: dto.showMetadata, diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index b04ffc89a..9aa3a0e5e 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -191,6 +191,7 @@ export class StorageTemplateService { fileCreatedAt: new Date(), originalPath: '/upload/test/IMG_123.jpg', type: AssetType.IMAGE, + id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', } as AssetEntity; try { this.render(this.compile(config.storageTemplate.template), testAsset, 'IMG_123', 'jpg'); @@ -218,6 +219,7 @@ export class StorageTemplateService { ext, filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID', filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', + assetId: asset.id, }; const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts index 330560ea8..38d977f88 100644 --- a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts +++ b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts @@ -6,7 +6,7 @@ export class SystemConfigMachineLearningDto { @IsBoolean() enabled!: boolean; - @IsUrl({ require_tld: false }) + @IsUrl({ require_tld: false, allow_underscores: true }) @ValidateIf((dto) => dto.enabled) url!: string; diff --git a/server/src/domain/system-config/system-config.constants.ts b/server/src/domain/system-config/system-config.constants.ts index ca0497a9d..a1bb03a4c 100644 --- a/server/src/domain/system-config/system-config.constants.ts +++ b/server/src/domain/system-config/system-config.constants.ts @@ -20,6 +20,9 @@ export const supportedPresetTokens = [ '{{y}}-{{MMMM}}-{{dd}}/{{filename}}', '{{y}}/{{y}}-{{MM}}/{{filename}}', '{{y}}/{{y}}-{{WW}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', + '{{y}}/{{y}}-{{MM}}/{{assetId}}', + '{{y}}/{{y}}-{{WW}}/{{assetId}}', ]; export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG'; diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 9a9ea969e..66c72fc92 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -13,11 +13,10 @@ import { VideoCodec, } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common'; -import { plainToClass } from 'class-transformer'; +import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import * as _ from 'lodash'; import { Subject } from 'rxjs'; -import { DeepPartial } from 'typeorm'; import { QueueName } from '../job/job.constants'; import { ISystemConfigRepository } from '../repositories'; import { SystemConfigDto } from './dto'; @@ -140,7 +139,7 @@ let instance: SystemConfigCore | null; export class SystemConfigCore { private logger = new Logger(SystemConfigCore.name); private validators: SystemConfigValidator[] = []; - private configCache: SystemConfig | null = null; + private configCache: SystemConfigEntity[] | null = null; public config$ = new Subject(); @@ -218,9 +217,28 @@ export class SystemConfigCore { this.validators.push(validator); } - public getConfig(force = false): Promise { + public async getConfig(force = false): Promise { const configFilePath = process.env.IMMICH_CONFIG_FILE; - return configFilePath ? this.loadFromFile(configFilePath, force) : this.loadFromDatabase(); + const config = _.cloneDeep(defaults); + const overrides = configFilePath ? await this.loadFromFile(configFilePath, force) : await this.repository.load(); + + for (const { key, value } of overrides) { + // set via dot notation + _.set(config, key, value); + } + + const errors = await validate(plainToInstance(SystemConfigDto, config), { + forbidNonWhitelisted: true, + forbidUnknownValues: true, + }); + if (errors.length > 0) { + this.logger.error('Validation error', errors); + if (configFilePath) { + throw new Error(`Invalid value(s) in file: ${errors}`); + } + } + + return config; } public async updateConfig(config: SystemConfig): Promise { @@ -246,7 +264,13 @@ export class SystemConfigCore { const defaultValue = _.get(defaults, key); const isMissing = !_.has(config, key); - if (isMissing || item.value === null || item.value === '' || item.value === defaultValue) { + if ( + isMissing || + item.value === null || + item.value === '' || + item.value === defaultValue || + _.isEqual(item.value, defaultValue) + ) { deletes.push(item); continue; } @@ -275,34 +299,25 @@ export class SystemConfigCore { this.config$.next(newConfig); } - private async loadFromDatabase() { - const config: DeepPartial = {}; - const overrides = await this.repository.load(); - for (const { key, value } of overrides) { - // set via dot notation - _.set(config, key, value); - } - - return plainToClass(SystemConfigDto, _.defaultsDeep(config, defaults)); - } - private async loadFromFile(filepath: string, force = false) { if (force || !this.configCache) { try { - const overrides = JSON.parse((await this.repository.readFile(filepath)).toString()); - const config = plainToClass(SystemConfigDto, _.defaultsDeep(overrides, defaults)); + const file = JSON.parse((await this.repository.readFile(filepath)).toString()); + const overrides: SystemConfigEntity[] = []; - const errors = await validate(config, { - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - }); - if (errors.length > 0) { - this.logger.error('Validation error', errors); - throw new Error(`Invalid value(s) in file: ${errors}`); + for (const key of Object.values(SystemConfigKey)) { + const value = _.get(file, key); + this.unsetDeep(file, key); + if (value !== undefined) { + overrides.push({ key, value }); + } } - this.configCache = config; + if (!_.isEmpty(file)) { + throw new Error(`Unknown keys found: ${file}`); + } + + this.configCache = overrides; } catch (error: Error | any) { this.logger.error(`Unable to load configuration file: ${filepath} due to ${error}`, error?.stack); throw new Error('Invalid configuration file'); @@ -311,4 +326,15 @@ export class SystemConfigCore { return this.configCache; } + + private unsetDeep(object: object, key: string) { + _.unset(object, key); + const path = key.split('.'); + while (path.pop()) { + if (!_.isEmpty(_.get(object, path))) { + return; + } + _.unset(object, path); + } + } } diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index ecdec41fd..b094b328e 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -189,6 +189,15 @@ describe(SystemConfigService.name, () => { expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); + it('should allow underscores in the machine learning url', async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; + configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig))); + + const config = await sut.getConfig(); + expect(config.machineLearning.url).toEqual('immich_machine_learning'); + }); + const tests = [ { should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } }, { should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } }, @@ -230,6 +239,9 @@ describe(SystemConfigService.name, () => { '{{y}}-{{MMMM}}-{{dd}}/{{filename}}', '{{y}}/{{y}}-{{MM}}/{{filename}}', '{{y}}/{{y}}-{{WW}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', + '{{y}}/{{y}}-{{MM}}/{{assetId}}', + '{{y}}/{{y}}-{{WW}}/{{assetId}}', ], secondOptions: ['s', 'ss'], weekOptions: ['W', 'WW'], diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index a41b18341..e0e239f6d 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, + stack: true, }, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, @@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository { relations: { exifInfo: true, tags: true, + stack: true, }, skip: dto.skip || 0, order: { diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index d3c1fe876..415fb380d 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -196,7 +196,7 @@ export class AssetService { const includeMetadata = this.getExifPermission(authUser); const asset = await this._assetRepository.getById(assetId); if (includeMetadata) { - const data = mapAsset(asset); + const data = mapAsset(asset, { withStack: true }); if (data.ownerId !== authUser.id) { data.people = []; @@ -208,7 +208,7 @@ export class AssetService { return data; } else { - return mapAsset(asset, true); + return mapAsset(asset, { stripMetadata: true, withStack: true }); } } diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index f4f376e98..6a91bad30 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -21,6 +21,7 @@ import { TimeBucketResponseDto, TrashAction, UpdateAssetDto as UpdateDto, + UpdateStackParentDto, } from '@app/domain'; import { Body, @@ -137,6 +138,12 @@ export class AssetController { return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL); } + @Put('stack/parent') + @HttpCode(HttpStatus.OK) + updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise { + return this.service.updateStackParent(authUser, dto); + } + @Put(':id') updateAsset( @AuthUser() authUser: AuthUserDto, diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 31935ae5f..937107f9d 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -148,6 +148,16 @@ export class AssetEntity { @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset) faces!: AssetFaceEntity[]; + + @Column({ nullable: true }) + stackParentId?: string | null; + + @ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) + @JoinColumn({ name: 'stackParentId' }) + stackParent?: AssetEntity | null; + + @OneToMany(() => AssetEntity, (asset) => asset.stackParent) + stack?: AssetEntity[]; } export enum AssetType { diff --git a/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts b/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts new file mode 100644 index 000000000..d5150d3a8 --- /dev/null +++ b/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddStackParentIdToAssets1695354433573 implements MigrationInterface { + name = 'AddStackParentIdToAssets1695354433573' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "stackParentId" uuid`); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`); + } + +} diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index b53c93471..a8cd50414 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -1,4 +1,4 @@ -import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from '@app/domain'; +import { AlbumAsset, AlbumAssetCount, AlbumAssets, AlbumInfoOptions, IAlbumRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; @@ -168,16 +168,27 @@ export class AlbumRepository implements IAlbumRepository { .createQueryBuilder() .delete() .from('albums_assets_assets') - .where('"albums_assets_assets"."assetsId" = :assetId', { assetId }) + .where('"albums_assets_assets"."assetsId" = :assetId', { assetId }); + } + + async removeAssets(asset: AlbumAssets): Promise { + await this.dataSource + .createQueryBuilder() + .delete() + .from('albums_assets_assets') + .where({ + albumsId: asset.albumId, + assetsId: In(asset.assetIds), + }) .execute(); } - hasAsset(id: string, assetId: string): Promise { + hasAsset(asset: AlbumAsset): Promise { return this.repository.exist({ where: { - id, + id: asset.albumId, assets: { - id: assetId, + id: asset.assetId, }, }, relations: { @@ -186,6 +197,15 @@ export class AlbumRepository implements IAlbumRepository { }); } + async addAssets({ albumId, assetIds }: AlbumAssets): Promise { + await this.dataSource + .createQueryBuilder() + .insert() + .into('albums_assets_assets', ['albumsId', 'assetsId']) + .values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId }))) + .execute(); + } + async create(album: Partial): Promise { return this.save(album); } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index f6f58b926..a740cf583 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -97,6 +97,7 @@ export class AssetRepository implements IAssetRepository { month, }, ) + .leftJoinAndSelect('entity.exifInfo', 'exifInfo') .orderBy('entity.localDateTime', 'DESC') .getMany(); } @@ -111,6 +112,7 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, + stack: true, }, withDeleted: true, }); @@ -191,6 +193,7 @@ export class AssetRepository implements IAssetRepository { person: true, }, library: true, + stack: true, }, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, @@ -480,7 +483,6 @@ export class AssetRepository implements IAssetRepository { getTimeBuckets(options: TimeBucketOptions): Promise { const truncated = dateTrunc(options); - return this.getBuilder(options) .select(`COUNT(asset.id)::int`, 'count') .addSelect(truncated, 'timeBucket') @@ -508,6 +510,7 @@ export class AssetRepository implements IAssetRepository { let builder = this.repository .createQueryBuilder('asset') .where('asset.isVisible = true') + .andWhere('asset.fileCreatedAt < NOW()') .leftJoinAndSelect('asset.exifInfo', 'exifInfo'); if (albumId) { @@ -537,6 +540,12 @@ export class AssetRepository implements IAssetRepository { .andWhere('person.id = :personId', { personId }); } + // Hide stack children only in main timeline + // Uncomment after adding support for stacked assets in web client + // if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) { + // builder = builder.andWhere('asset.stackParent IS NULL'); + // } + return builder; } } diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts index 3cb53e823..63bc29dcb 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -45,6 +45,10 @@ export class MetadataRepository implements IMetadataRepository { }); } + async teardown() { + await exiftool.end(); + } + async deleteCache() { const dumpDirectory = REVERSE_GEOCODING_DUMP_DIRECTORY; if (dumpDirectory) { diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 1513c6297..365b07329 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -103,4 +103,8 @@ export class AppService { await this.metadataService.init(); await this.searchService.init(); } + + async teardown() { + await this.metadataService.teardown(); + } } diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index 633a825a7..e10f5414f 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -2,11 +2,10 @@ import { AlbumResponseDto, LoginResponseDto } from '@app/domain'; import { AlbumController } from '@app/immich'; import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; import { SharedLinkType } from '@app/infra/entities'; -import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, uuidStub } from '@test/fixtures'; -import { createTestApp } from '@test/test-utils'; +import { testApp } from '@test/test-utils'; import request from 'supertest'; const user1SharedUser = 'user1SharedUser'; @@ -17,7 +16,6 @@ const user2SharedLink = 'user2SharedLink'; const user2NotShared = 'user2NotShared'; describe(`${AlbumController.name} (e2e)`, () => { - let app: INestApplication; let server: any; let admin: LoginResponseDto; let user1: LoginResponseDto; @@ -27,9 +25,11 @@ describe(`${AlbumController.name} (e2e)`, () => { let user2Albums: AlbumResponseDto[]; beforeAll(async () => { - app = await createTestApp(); + [server] = await testApp.create(); + }); - server = app.getHttpServer(); + afterAll(async () => { + await testApp.teardown(); }); beforeEach(async () => { @@ -37,24 +37,30 @@ describe(`${AlbumController.name} (e2e)`, () => { await api.authApi.adminSignUp(server); admin = await api.authApi.adminLogin(server); - await api.userApi.create(server, admin.accessToken, { - email: 'user1@immich.app', - password: 'Password123', - firstName: 'User 1', - lastName: 'Test', - }); - user1 = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); + await Promise.all([ + api.userApi.create(server, admin.accessToken, { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', + }), + api.userApi.create(server, admin.accessToken, { + email: 'user2@immich.app', + password: 'Password123', + firstName: 'User 2', + lastName: 'Test', + }), + ]); - await api.userApi.create(server, admin.accessToken, { - email: 'user2@immich.app', - password: 'Password123', - firstName: 'User 2', - lastName: 'Test', - }); - user2 = await api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' }); + [user1, user2] = await Promise.all([ + api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }), + api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' }), + ]); user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example'); - user1Albums = await Promise.all([ + + const albums = await Promise.all([ + // user 1 api.albumApi.create(server, user1.accessToken, { albumName: user1SharedUser, sharedWithUserIds: [user2.userId], @@ -62,15 +68,8 @@ describe(`${AlbumController.name} (e2e)`, () => { }), api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }), api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }), - ]); - // add shared link to user1SharedLink album - await api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.ALBUM, - albumId: user1Albums[1].id, - }); - - user2Albums = await Promise.all([ + // user 2 api.albumApi.create(server, user2.accessToken, { albumName: user2SharedUser, sharedWithUserIds: [user1.userId], @@ -80,16 +79,22 @@ describe(`${AlbumController.name} (e2e)`, () => { api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }), ]); - // add shared link to user2SharedLink album - await api.sharedLinkApi.create(server, user2.accessToken, { - type: SharedLinkType.ALBUM, - albumId: user2Albums[1].id, - }); - }); + user1Albums = albums.slice(0, 3); + user2Albums = albums.slice(3); - afterAll(async () => { - await db.disconnect(); - await app.close(); + await Promise.all([ + // add shared link to user1SharedLink album + api.sharedLinkApi.create(server, user1.accessToken, { + type: SharedLinkType.ALBUM, + albumId: user1Albums[1].id, + }), + + // add shared link to user2SharedLink album + api.sharedLinkApi.create(server, user2.accessToken, { + type: SharedLinkType.ALBUM, + albumId: user2Albums[1].id, + }), + ]); }); describe('GET /album', () => { diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index 4f4021d59..b9b10e104 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -12,7 +12,7 @@ import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { errorStub, uuidStub } from '@test/fixtures'; -import { createTestApp, db } from '@test/test-utils'; +import { db, testApp } from '@test/test-utils'; import { randomBytes } from 'crypto'; import request from 'supertest'; @@ -86,12 +86,14 @@ describe(`${AssetController.name} (e2e)`, () => { let asset4: AssetEntity; beforeAll(async () => { - app = await createTestApp(); - - server = app.getHttpServer(); + [server, app] = await testApp.create(); assetRepository = app.get(IAssetRepository); }); + afterAll(async () => { + await testApp.teardown(); + }); + beforeEach(async () => { await db.reset(); await api.authApi.adminSignUp(server); @@ -123,11 +125,6 @@ describe(`${AssetController.name} (e2e)`, () => { }); }); - afterAll(async () => { - await db.disconnect(); - await app.close(); - }); - describe('POST /asset/upload', () => { it('should require authentication', async () => { const { status, body } = await request(server) @@ -589,9 +586,11 @@ describe(`${AssetController.name} (e2e)`, () => { describe('GET /asset/map-marker', () => { beforeEach(async () => { - await assetRepository.save({ id: asset1.id, isArchived: true }); - await assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 }); - await assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 }); + await Promise.all([ + assetRepository.save({ id: asset1.id, isArchived: true }), + assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 }), + assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 }), + ]); }); it('should require authentication', async () => { @@ -627,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => { expect(body).toEqual([expect.objectContaining({ id: asset2.id })]); }); }); + + describe('PUT /asset', () => { + beforeEach(async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + }); + + it('should require authentication', async () => { + const { status, body } = await request(server).put('/asset'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a valid parent id', async () => { + const { status, body } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: uuidStub.invalid, ids: [asset1.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID'])); + }); + + it('should require access to the parent', async () => { + const { status, body } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset4.id, ids: [asset1.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.noPermission); + }); + + it('should add stack children', async () => { + const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: parent.id, ids: [child.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, parent.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })])); + }); + + it('should remove stack children', async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ removeParent: true, ids: [asset2.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); + }); + + it('should remove all stack children', async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ removeParent: true, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); + expect(asset.stack).toHaveLength(0); + }); + + it('should merge stack children', async () => { + const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: newParent.id, ids: [asset1.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, newParent.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: asset1.id }), + expect.objectContaining({ id: asset2.id }), + expect.objectContaining({ id: asset3.id }), + ]), + ); + }); + }); + + describe('PUT /asset/stack/parent', () => { + beforeEach(async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + }); + + it('should require authentication', async () => { + const { status, body } = await request(server).put('/asset/stack/parent'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest()); + }); + + it('should require access', async () => { + const { status, body } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset4.id, newParentId: asset1.id }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.noPermission); + }); + + it('should make old parent child of new parent', async () => { + const { status } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset1.id, newParentId: asset2.id }); + + expect(status).toBe(200); + + const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })])); + }); + + it('should make all childrens of old parent, a child of new parent', async () => { + const { status } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset1.id, newParentId: asset2.id }); + + expect(status).toBe(200); + + const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); + }); + }); }); diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts index 4068634e7..a42e1e161 100644 --- a/server/test/e2e/auth.e2e-spec.ts +++ b/server/test/e2e/auth.e2e-spec.ts @@ -1,5 +1,4 @@ import { AuthController } from '@app/immich'; -import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { db } from '@test/db'; import { @@ -12,7 +11,7 @@ import { signupResponseStub, uuidStub, } from '@test/fixtures'; -import { createTestApp } from '@test/test-utils'; +import { testApp } from '@test/test-utils'; import request from 'supertest'; const firstName = 'Immich'; @@ -21,13 +20,16 @@ const password = 'Password123'; const email = 'admin@immich.app'; describe(`${AuthController.name} (e2e)`, () => { - let app: INestApplication; let server: any; let accessToken: string; beforeAll(async () => { - app = await createTestApp(); - server = app.getHttpServer(); + await testApp.reset(); + [server] = await testApp.create(); + }); + + afterAll(async () => { + await testApp.teardown(); }); beforeEach(async () => { @@ -37,11 +39,6 @@ describe(`${AuthController.name} (e2e)`, () => { accessToken = response.accessToken; }); - afterAll(async () => { - await db.disconnect(); - await app.close(); - }); - describe('POST /auth/admin-sign-up', () => { beforeEach(async () => { await db.reset(); diff --git a/server/test/e2e/formats.e2e-spec.ts b/server/test/e2e/formats.e2e-spec.ts index 98e24ec9a..f2fce83ac 100644 --- a/server/test/e2e/formats.e2e-spec.ts +++ b/server/test/e2e/formats.e2e-spec.ts @@ -1,11 +1,9 @@ import { LoginResponseDto } from '@app/domain'; import { AssetType, LibraryType } from '@app/infra/entities'; -import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; -import { IMMICH_TEST_ASSET_PATH, createTestApp, db, runAllTests } from '@test/test-utils'; +import { IMMICH_TEST_ASSET_PATH, db, runAllTests, testApp } from '@test/test-utils'; describe(`Supported file formats (e2e)`, () => { - let app: INestApplication; let server: any; let admin: LoginResponseDto; @@ -170,8 +168,11 @@ describe(`Supported file formats (e2e)`, () => { const testsToRun = formatTests.filter((formatTest) => formatTest.runTest); beforeAll(async () => { - app = await createTestApp(true); - server = app.getHttpServer(); + [server] = await testApp.create({ jobs: true }); + }); + + afterAll(async () => { + await testApp.teardown(); }); beforeEach(async () => { @@ -181,11 +182,6 @@ describe(`Supported file formats (e2e)`, () => { await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); }); - afterAll(async () => { - await db.disconnect(); - await app.close(); - }); - it.each(testsToRun)('should import file of format $format', async (testedFormat) => { const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL, diff --git a/server/test/e2e/library.e2e-spec.ts b/server/test/e2e/library.e2e-spec.ts index 742e6b7fe..9cfbe8961 100644 --- a/server/test/e2e/library.e2e-spec.ts +++ b/server/test/e2e/library.e2e-spec.ts @@ -1,22 +1,14 @@ import { LibraryResponseDto, LoginResponseDto } from '@app/domain'; import { LibraryController } from '@app/immich'; import { AssetType, LibraryType } from '@app/infra/entities'; -import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; -import { - IMMICH_TEST_ASSET_PATH, - IMMICH_TEST_ASSET_TEMP_PATH, - createTestApp, - db, - restoreTempFolder, -} from '@test/test-utils'; +import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, db, restoreTempFolder, testApp } from '@test/test-utils'; import * as fs from 'fs'; import request from 'supertest'; import { utimes } from 'utimes'; import { errorStub, uuidStub } from '../fixtures'; describe(`${LibraryController.name} (e2e)`, () => { - let app: INestApplication; let server: any; let admin: LoginResponseDto; @@ -35,8 +27,12 @@ describe(`${LibraryController.name} (e2e)`, () => { }; beforeAll(async () => { - app = await createTestApp(true); - server = app.getHttpServer(); + [server] = await testApp.create({ jobs: true }); + }); + + afterAll(async () => { + await testApp.teardown(); + await restoreTempFolder(); }); beforeEach(async () => { @@ -46,12 +42,6 @@ describe(`${LibraryController.name} (e2e)`, () => { admin = await api.authApi.adminLogin(server); }); - afterAll(async () => { - await db.disconnect(); - await app.close(); - await restoreTempFolder(); - }); - describe('GET /library', () => { it('should require authentication', async () => { const { status, body } = await request(server).get('/library'); diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts index d0d2137c6..879d53815 100644 --- a/server/test/e2e/oauth.e2e-spec.ts +++ b/server/test/e2e/oauth.e2e-spec.ts @@ -1,18 +1,19 @@ import { OAuthController } from '@app/immich'; -import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub } from '@test/fixtures'; -import { createTestApp } from '@test/test-utils'; +import { testApp } from '@test/test-utils'; import request from 'supertest'; describe(`${OAuthController.name} (e2e)`, () => { - let app: INestApplication; let server: any; beforeAll(async () => { - app = await createTestApp(); - server = app.getHttpServer(); + [server] = await testApp.create(); + }); + + afterAll(async () => { + await testApp.teardown(); }); beforeEach(async () => { @@ -20,11 +21,6 @@ describe(`${OAuthController.name} (e2e)`, () => { await api.authApi.adminSignUp(server); }); - afterAll(async () => { - await db.disconnect(); - await app.close(); - }); - describe('POST /oauth/authorize', () => { beforeEach(async () => { await db.reset(); diff --git a/server/test/e2e/partner.e2e-spec.ts b/server/test/e2e/partner.e2e-spec.ts index b0eb1d4ce..82a09dcc8 100644 --- a/server/test/e2e/partner.e2e-spec.ts +++ b/server/test/e2e/partner.e2e-spec.ts @@ -4,7 +4,7 @@ import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub } from '@test/fixtures'; -import { createTestApp } from '@test/test-utils'; +import { testApp } from '@test/test-utils'; import request from 'supertest'; const user1Dto = { @@ -31,27 +31,29 @@ describe(`${PartnerController.name} (e2e)`, () => { let user2: LoginResponseDto; beforeAll(async () => { - app = await createTestApp(); - server = app.getHttpServer(); + [server, app] = await testApp.create(); repository = app.get(IPartnerRepository); }); + afterAll(async () => { + await testApp.teardown(); + }); + beforeEach(async () => { await db.reset(); await api.authApi.adminSignUp(server); loginResponse = await api.authApi.adminLogin(server); accessToken = loginResponse.accessToken; - await api.userApi.create(server, accessToken, user1Dto); - user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); + await Promise.all([ + api.userApi.create(server, accessToken, user1Dto), + api.userApi.create(server, accessToken, user2Dto), + ]); - await api.userApi.create(server, accessToken, user2Dto); - user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }); - }); - - afterAll(async () => { - await db.disconnect(); - await app.close(); + [user1, user2] = await Promise.all([ + api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }), + api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }), + ]); }); describe('GET /partner', () => { diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts index f9da56fa8..bb0af4c96 100644 --- a/server/test/e2e/person.e2e-spec.ts +++ b/server/test/e2e/person.e2e-spec.ts @@ -5,7 +5,7 @@ import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, uuidStub } from '@test/fixtures'; -import { createTestApp } from '@test/test-utils'; +import { testApp } from '@test/test-utils'; import request from 'supertest'; describe(`${PersonController.name}`, () => { @@ -18,11 +18,14 @@ describe(`${PersonController.name}`, () => { let hiddenPerson: PersonEntity; beforeAll(async () => { - app = await createTestApp(); - server = app.getHttpServer(); + [server, app] = await testApp.create(); personRepository = app.get(IPersonRepository); }); + afterAll(async () => { + await testApp.teardown(); + }); + beforeEach(async () => { await db.reset(); await api.authApi.adminSignUp(server); @@ -46,11 +49,6 @@ describe(`${PersonController.name}`, () => { await personRepository.createFace({ assetId: faceAsset.id, personId: hiddenPerson.id }); }); - afterAll(async () => { - await db.disconnect(); - await app.close(); - }); - describe('GET /person', () => { beforeEach(async () => {}); diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts index 43cf471f4..cd6afbc07 100644 --- a/server/test/e2e/server-info.e2e-spec.ts +++ b/server/test/e2e/server-info.e2e-spec.ts @@ -1,21 +1,22 @@ import { LoginResponseDto } from '@app/domain'; import { ServerInfoController } from '@app/immich'; -import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub } from '@test/fixtures'; -import { createTestApp } from '@test/test-utils'; +import { testApp } from '@test/test-utils'; import request from 'supertest'; describe(`${ServerInfoController.name} (e2e)`, () => { - let app: INestApplication; let server: any; let accessToken: string; let loginResponse: LoginResponseDto; beforeAll(async () => { - app = await createTestApp(); - server = app.getHttpServer(); + [server] = await testApp.create(); + }); + + afterAll(async () => { + await testApp.teardown(); }); beforeEach(async () => { @@ -25,11 +26,6 @@ describe(`${ServerInfoController.name} (e2e)`, () => { accessToken = loginResponse.accessToken; }); - afterAll(async () => { - await db.disconnect(); - await app.close(); - }); - describe('GET /server-info', () => { it('should require authentication', async () => { const { status, body } = await request(server).get('/server-info'); diff --git a/server/test/e2e/setup.ts b/server/test/e2e/setup.ts index 26849f468..234deb754 100644 --- a/server/test/e2e/setup.ts +++ b/server/test/e2e/setup.ts @@ -1,5 +1,5 @@ import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import * as fs from 'fs'; +import { access } from 'fs/promises'; import path from 'path'; export default async () => { @@ -23,8 +23,7 @@ export default async () => { } const directoryExists = async (dirPath: string) => - await fs.promises - .access(dirPath) + await access(dirPath) .then(() => true) .catch(() => false); diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts index 3a52c15a0..80d43c7c7 100644 --- a/server/test/e2e/shared-link.e2e-spec.ts +++ b/server/test/e2e/shared-link.e2e-spec.ts @@ -1,16 +1,10 @@ import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain'; import { PartnerController } from '@app/immich'; import { LibraryType, SharedLinkType } from '@app/infra/entities'; -import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, uuidStub } from '@test/fixtures'; -import { - IMMICH_TEST_ASSET_PATH, - IMMICH_TEST_ASSET_TEMP_PATH, - createTestApp, - restoreTempFolder, -} from '@test/test-utils'; +import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder, testApp } from '@test/test-utils'; import { cp } from 'fs/promises'; import request from 'supertest'; @@ -22,7 +16,6 @@ const user1Dto = { }; describe(`${PartnerController.name} (e2e)`, () => { - let app: INestApplication; let server: any; let admin: LoginResponseDto; let user1: LoginResponseDto; @@ -30,8 +23,12 @@ describe(`${PartnerController.name} (e2e)`, () => { let sharedLink: SharedLinkResponseDto; beforeAll(async () => { - app = await createTestApp(true); - server = app.getHttpServer(); + [server] = await testApp.create({ jobs: true }); + }); + + afterAll(async () => { + await testApp.teardown(); + await restoreTempFolder(); }); beforeEach(async () => { @@ -49,12 +46,6 @@ describe(`${PartnerController.name} (e2e)`, () => { }); }); - afterAll(async () => { - await db.disconnect(); - await app.close(); - await restoreTempFolder(); - }); - describe('GET /shared-link', () => { it('should require authentication', async () => { const { status, body } = await request(server).get('/shared-link'); diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts index af0cbde74..d20ac729f 100644 --- a/server/test/e2e/user.e2e-spec.ts +++ b/server/test/e2e/user.e2e-spec.ts @@ -2,10 +2,11 @@ import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain'; import { AppModule, UserController } from '@app/immich'; import { UserEntity } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, userSignupStub, userStub } from '@test/fixtures'; -import { createTestApp } from '@test/test-utils'; +import { testApp } from '@test/test-utils'; import request from 'supertest'; import { Repository } from 'typeorm'; @@ -18,10 +19,12 @@ describe(`${UserController.name}`, () => { let userRepository: Repository; beforeAll(async () => { - app = await createTestApp(); - userRepository = app.select(AppModule).get('UserEntityRepository'); + [server, app] = await testApp.create(); + userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity)); + }); - server = app.getHttpServer(); + afterAll(async () => { + await testApp.teardown(); }); beforeEach(async () => { diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 5fef9f6d1..345481843 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -41,6 +41,7 @@ export const assetStub = { libraryId: 'library-id', library: libraryStub.uploadLibrary1, }), + noWebpPath: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -80,6 +81,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + noThumbhash: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -116,6 +118,7 @@ export const assetStub = { sidecarPath: null, deletedAt: null, }), + primaryImage: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -154,7 +157,9 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5_000, } as ExifEntity, + stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity], }), + image: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -194,6 +199,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + external: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -233,6 +239,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + offline: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -272,6 +279,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + image1: Object.freeze({ id: 'asset-id-1', deviceAssetId: 'device-asset-id', @@ -311,6 +319,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + imageFrom2015: Object.freeze({ id: 'asset-id-1', deviceAssetId: 'device-asset-id', @@ -350,6 +359,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + video: Object.freeze({ id: 'asset-id', originalFileName: 'asset-id.ext', @@ -389,6 +399,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', originalPath: fileStub.livePhotoMotion.originalPath, @@ -497,10 +508,41 @@ export const assetStub = { sidecarPath: '/original/path.ext.xmp', deletedAt: null, }), - readOnly: Object.freeze({ + + readOnly: Object.freeze({ id: 'read-only-asset', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.ext', + resizePath: '/uploads/user-id/thumbs/path.ext', + thumbhash: null, + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + webpPath: null, + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, isReadOnly: true, + isExternal: false, + isOffline: false, libraryId: 'library-id', library: libraryStub.uploadLibrary1, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.ext', + faces: [], + sidecarPath: '/original/path.ext.xmp', + deletedAt: null, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index acb14c6b2..dd5771cf9 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, + stackCount: 0, }; const assetResponseWithoutMetadata = { diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 25206f028..20c355269 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -14,7 +14,9 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { softDeleteAll: jest.fn(), deleteAll: jest.fn(), getAll: jest.fn(), + addAssets: jest.fn(), removeAsset: jest.fn(), + removeAssets: jest.fn(), hasAsset: jest.fn(), create: jest.fn(), update: jest.fn(), diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 13589f15b..76c6f777a 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -5,6 +5,7 @@ export const newMetadataRepositoryMock = (): jest.Mocked => deleteCache: jest.fn(), getExifTags: jest.fn(), init: jest.fn(), + teardown: jest.fn(), reverseGeocode: jest.fn(), }; }; diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts index 075e0b69f..6b45c6ee6 100644 --- a/server/test/test-utils.ts +++ b/server/test/test-utils.ts @@ -1,9 +1,8 @@ -import { dataSource } from '@app/infra'; - import { IJobRepository, JobItem, JobItemHandler, QueueName } from '@app/domain'; import { AppModule } from '@app/immich'; -import { INestApplication, Logger } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { dataSource } from '@app/infra'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; import * as fs from 'fs'; import path from 'path'; import { AppService } from '../src/microservices/app.service'; @@ -36,38 +35,48 @@ export const db = { let _handler: JobItemHandler = () => Promise.resolve(); -export async function createTestApp(runJobs = false, log = false): Promise { - const moduleBuilder = Test.createTestingModule({ - imports: [AppModule], - providers: [AppService], - }) - .overrideProvider(IJobRepository) - .useValue({ - addHandler: (_queueName: QueueName, _concurrency: number, handler: JobItemHandler) => (_handler = handler), - queue: (item: JobItem) => runJobs && _handler(item), - resume: jest.fn(), - empty: jest.fn(), - setConcurrency: jest.fn(), - getQueueStatus: jest.fn(), - getJobCounts: jest.fn(), - pause: jest.fn(), - } as IJobRepository); - - const moduleFixture: TestingModule = await moduleBuilder.compile(); - - const app = moduleFixture.createNestApplication(); - if (log) { - app.useLogger(new Logger()); - } else { - app.useLogger(false); - } - await app.init(); - const appService = app.get(AppService); - await appService.init(); - - return app; +interface TestAppOptions { + jobs: boolean; } +let app: INestApplication; + +export const testApp = { + create: async (options?: TestAppOptions): Promise<[any, INestApplication]> => { + const { jobs } = options || { jobs: false }; + + const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] }) + .overrideProvider(IJobRepository) + .useValue({ + addHandler: (_queueName: QueueName, _concurrency: number, handler: JobItemHandler) => (_handler = handler), + queue: (item: JobItem) => jobs && _handler(item), + resume: jest.fn(), + empty: jest.fn(), + setConcurrency: jest.fn(), + getQueueStatus: jest.fn(), + getJobCounts: jest.fn(), + pause: jest.fn(), + } as IJobRepository) + .compile(); + + app = await moduleFixture.createNestApplication().init(); + + if (jobs) { + await app.get(AppService).init(); + } + + return [app.getHttpServer(), app]; + }, + reset: async () => { + await db.reset(); + }, + teardown: async () => { + await app.get(AppService).teardown(); + await db.disconnect(); + await app.close(); + }, +}; + export const runAllTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true'; const directoryExists = async (dirPath: string) => diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 03fb10f50..8d278b44c 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.82.0 + * The version of the OpenAPI document: 1.82.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto { * @memberof AssetBulkUpdateDto */ 'isFavorite'?: boolean; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'removeParent'?: boolean; + /** + * + * @type {string} + * @memberof AssetBulkUpdateDto + */ + 'stackParentId'?: string; } /** * @@ -748,6 +760,24 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'smartInfo'?: SmartInfoResponseDto; + /** + * + * @type {Array} + * @memberof AssetResponseDto + */ + 'stack'?: Array; + /** + * + * @type {number} + * @memberof AssetResponseDto + */ + 'stackCount': number; + /** + * + * @type {string} + * @memberof AssetResponseDto + */ + 'stackParentId'?: string | null; /** * * @type {Array} @@ -3053,6 +3083,12 @@ export interface SharedLinkEditDto { * @memberof SharedLinkEditDto */ 'allowUpload'?: boolean; + /** + * Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. + * @type {boolean} + * @memberof SharedLinkEditDto + */ + 'changeExpiryTime'?: boolean; /** * * @type {string} @@ -3981,6 +4017,25 @@ export interface UpdateLibraryDto { */ 'name'?: string; } +/** + * + * @export + * @interface UpdateStackParentDto + */ +export interface UpdateStackParentDto { + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'newParentId': string; + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'oldParentId': string; +} /** * * @export @@ -7135,6 +7190,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'updateStackParentDto' is not null or undefined + assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto) + const localVarPath = `/asset/stack/parent`; + // 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: 'PUT', ...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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {File} assetData @@ -7601,6 +7700,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {File} assetData @@ -7892,6 +8001,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. @@ -8499,6 +8617,20 @@ export interface AssetApiUpdateAssetsRequest { readonly assetBulkUpdateDto: AssetBulkUpdateDto } +/** + * Request parameters for updateStackParent operation in AssetApi. + * @export + * @interface AssetApiUpdateStackParentRequest + */ +export interface AssetApiUpdateStackParentRequest { + /** + * + * @type {UpdateStackParentDto} + * @memberof AssetApiUpdateStackParent + */ + readonly updateStackParentDto: UpdateStackParentDto +} + /** * Request parameters for uploadFile operation in AssetApi. * @export @@ -8939,6 +9071,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 2abcb2b1d..84c4cbb35 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.82.0 + * The version of the OpenAPI document: 1.82.1 * * * 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 41329bb53..39307ae08 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.82.0 + * The version of the OpenAPI document: 1.82.1 * * * 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 d49752982..c64b2eb3f 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.82.0 + * The version of the OpenAPI document: 1.82.1 * * * 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 8dab99636..2afccb668 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.82.0 + * The version of the OpenAPI document: 1.82.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 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 c0018edcb..e744b182d 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 @@ -56,6 +56,7 @@ ext: 'jpg', filetype: 'IMG', filetypefull: 'IMAGE', + assetId: 'a8312960-e277-447d-b4ea-56717ccba856', }; const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString()); @@ -151,35 +152,36 @@

-
+

Template

-
+

PREVIEW

-

+

Approximately path length limit : {parsedTemplate().length + user.id.length + 'UPLOAD_LOCATION'.length}/260

-

- {user.storageLabel || user.id} is the user's Storage Label +

+ {user.storageLabel || user.id} is the user's + Storage Label

-

+

UPLOAD_LOCATION/{user.storageLabel || user.id}/{parsedTemplate()}.jpg

-
- +
+