chore: pull main

This commit is contained in:
shalong-tanwen 2023-10-22 21:05:50 +05:30
commit 16646d9946
152 changed files with 4494 additions and 617 deletions

View file

@ -21,7 +21,7 @@ jobs:
submodules: "recursive" submodules: "recursive"
- name: Run e2e tests - 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: doc-tests:
name: Run documentation checks name: Run documentation checks

View file

@ -4,6 +4,9 @@ dev:
dev-new: dev-new:
docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans 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: dev-new-update:
docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans 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 docker-compose -f ./docker/docker-compose.staging.yml pull
test-e2e: 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: prod:
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
@ -32,4 +35,4 @@ api:
cd ./server && npm run api:generate cd ./server && npm run api:generate
attach-server: attach-server:
docker exec -it docker_immich-server_1 sh docker exec -it docker_immich-server_1 sh

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## Disclaimer ## Disclaimer

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## Avís legal ## Avís legal

View file

@ -24,6 +24,7 @@
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## Descargo de responsabilidad ## Descargo de responsabilidad

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## Clause de non-responsabilité ## Clause de non-responsabilité

113
README_it_IT.md Normal file
View file

@ -0,0 +1,113 @@
<p align="center">
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## 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

View file

@ -24,6 +24,7 @@
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## 免責事項 ## 免責事項

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## Disclaimer ## Disclaimer

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## Feragatname ## Feragatname

View file

@ -29,6 +29,7 @@
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto * @memberof AssetBulkUpdateDto
*/ */
'isFavorite'?: boolean; 'isFavorite'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'removeParent'?: boolean;
/**
*
* @type {string}
* @memberof AssetBulkUpdateDto
*/
'stackParentId'?: string;
} }
/** /**
* *
@ -748,6 +760,24 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
'smartInfo'?: SmartInfoResponseDto; 'smartInfo'?: SmartInfoResponseDto;
/**
*
* @type {Array<AssetResponseDto>}
* @memberof AssetResponseDto
*/
'stack'?: Array<AssetResponseDto>;
/**
*
* @type {number}
* @memberof AssetResponseDto
*/
'stackCount': number;
/**
*
* @type {string}
* @memberof AssetResponseDto
*/
'stackParentId'?: string | null;
/** /**
* *
* @type {Array<TagResponseDto>} * @type {Array<TagResponseDto>}
@ -3053,6 +3083,12 @@ export interface SharedLinkEditDto {
* @memberof SharedLinkEditDto * @memberof SharedLinkEditDto
*/ */
'allowUpload'?: boolean; '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} * @type {string}
@ -3981,6 +4017,25 @@ export interface UpdateLibraryDto {
*/ */
'name'?: string; 'name'?: string;
} }
/**
*
* @export
* @interface UpdateStackParentDto
*/
export interface UpdateStackParentDto {
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'newParentId': string;
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'oldParentId': string;
}
/** /**
* *
* @export * @export
@ -7135,6 +7190,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {UpdateStackParentDto} updateStackParentDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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 * @param {File} assetData
@ -7601,6 +7700,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {File} assetData * @param {File} assetData
@ -7892,6 +8001,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> { updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); 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<void> {
return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {AssetApiUploadFileRequest} requestParameters Request parameters. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -8499,6 +8617,20 @@ export interface AssetApiUpdateAssetsRequest {
readonly assetBulkUpdateDto: AssetBulkUpdateDto 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. * Request parameters for uploadFile operation in AssetApi.
* @export * @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)); 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. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -21,6 +21,10 @@ services:
- .env - .env
environment: environment:
- NODE_ENV=development - NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
depends_on: depends_on:
- redis - redis
- database - database
@ -48,6 +52,10 @@ services:
- 9231:9230 - 9231:9230
environment: environment:
- NODE_ENV=development - NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
depends_on: depends_on:
- database - database
- immich-server - immich-server
@ -73,6 +81,10 @@ services:
volumes: volumes:
- ../web:/usr/src/app - ../web:/usr/src/app
- /usr/src/app/node_modules - /usr/src/app/node_modules
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- immich-server - immich-server

View file

@ -1,10 +1,10 @@
version: "3.8" version: "3.8"
# Compose file for dockerized end-to-end testing of the backend name: "immich-test-e2e"
services: services:
immich-server-test: immich-server:
image: immich-server-test image: immich-server-dev:latest
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@ -14,27 +14,20 @@ services:
- ../server:/usr/src/app - ../server:/usr/src/app
- /usr/src/app/node_modules - /usr/src/app/node_modules
environment: environment:
- DB_HOSTNAME=immich-database-test - DB_HOSTNAME=database
- DB_USERNAME=postgres - DB_USERNAME=postgres
- DB_PASSWORD=postgres - DB_PASSWORD=postgres
- DB_DATABASE_NAME=e2e_test - DB_DATABASE_NAME=e2e_test
- IMMICH_RUN_ALL_TESTS=true - IMMICH_RUN_ALL_TESTS=true
depends_on: depends_on:
- immich-database-test - database
networks:
- immich-test-network
immich-database-test: database:
container_name: immich-database-test
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441 image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
command: -c fsync=off
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: e2e_test POSTGRES_DB: e2e_test
networks:
- immich-test-network
logging: logging:
driver: none driver: none
networks:
immich-test-network:

View file

@ -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. 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? ### 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. 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.

View file

@ -11,6 +11,6 @@ Running into an issue or have a question? Try the following:
3. Search through existing [GitHub Issues][github-issues]. 3. Search through existing [GitHub Issues][github-issues].
4. Open a help ticket on [Discord][discord-link]. 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 [github-releases]: https://github.com/immich-app/immich/releases
[discord-link]: https://discord.com/invite/D8JsnBEuKb [discord-link]: https://discord.com/invite/D8JsnBEuKb

View file

@ -4,7 +4,7 @@ sidebar_position: 1
# Introduction # Introduction
<img src={require('./img/feature-panel.png').default} alt="Immich" /> <img src={require('./img/feature-panel.png').default} alt="Immich - Self-hosted photos and videos backup tool" />
## Welcome! ## Welcome!

View file

@ -6,28 +6,31 @@ function HomepageHeader() {
return ( return (
<header> <header>
<section className="text-center m-6 p-12 border border-red-400 rounded-[50px] bg-gray-100 dark:bg-immich-dark-gray"> <section className="text-center m-6 p-12 border border-red-400 rounded-[50px] bg-gray-100 dark:bg-immich-dark-gray">
<h1 className="md:text-6xl font-bold mb-10 font-immich-title text-immich-primary dark:text-immich-dark-primary"> <img src="img/immich-logo.svg" className="md:h-24 h-12 mb-2" alt="Immich logo" />
IMMICH <h1 className="md:text-6xl font-immich-title mb-10 text-immich-primary dark:text-immich-dark-primary uppercase">
Immich
</h1> </h1>
<div className="font-thin sm:text-base md:text-2xl my-12 sm:leading-tight"> <div className="font-thin sm:text-base md:text-2xl my-12 sm:leading-tight">
<p>SELF-HOSTED BACKUP SOLUTION </p> <p className="mb-1 uppercase">
<p>FOR PHOTOS AND VIDEOS</p> Self-hosted backup solution <span className="block"></span>
<p>ON MOBILE DEVICE</p> for photos and videos <span className="block"></span>
on mobile device
</p>
</div> </div>
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 mb-16 gap-4 "> <div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 mb-16 gap-4 ">
<Link <Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold" className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold uppercase"
to="docs/overview/introduction" to="docs/overview/introduction"
> >
GET STARTED Get started
</Link> </Link>
<Link <Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-full hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold" className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-full hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
to="https://demo.immich.app/" to="https://demo.immich.app/"
> >
DEMO PORTAL Demo portal
</Link> </Link>
</div> </div>

98
docs/static/img/immich-logo.svg vendored Normal file
View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="svg2781" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 564.2 553.5"
style="enable-background:new 0 0 564.2 553.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4081EF;stroke:#512D8C;stroke-miterlimit:10;}
.st1{fill:#31A452;stroke:#512D8C;stroke-miterlimit:10;}
.st2{fill:#DE7FB3;stroke:#512D8C;stroke-miterlimit:10;}
.st3{fill:#FFB800;stroke:#512D8C;stroke-miterlimit:10;}
.st4{fill:#E64132;stroke:#512D8C;stroke-miterlimit:10;}
.st5{fill:#F2F5FB;stroke:#512D8C;stroke-miterlimit:10;}
</style>
<path class="st0" d="M210.5,549.6c-2.2-0.2-5.5-1-9.7-2.2c-52.4-15.7-99-46.5-133.8-88.5c-8.8-10.7-17.2-22.4-19.4-27.5
c-8.1-18.1-6.3-38.7,4.8-55.4c5-7.5,13.2-15,20.5-18.7c1.2-0.6,54.1-20,55.8-20.4c0.5-0.1,0.5,0.2-0.3,2.1c-0.7,1.7-1,3.1-1.1,5.5
l-0.1,3.2l2.8,5.8c8.7,17.9,19.2,32.7,33.2,46.4c6.3,6.2,7.8,7.6,13.8,12.3c22.7,18.1,52,30.7,79.9,34.3c2.5,0.3,5,0.8,5.7,1
c2.8,0.9,7.7-0.8,11-3.7l1.8-1.6l-0.2,4.8c-0.1,2.7-0.6,15.4-1,28.3c-0.6,20.3-0.8,24-1.5,27.5c-3.9,20.7-18.6,37.5-38.4,44.1
c-4.6,1.5-8,2.2-13.1,2.7C216.6,550.1,215.3,550,210.5,549.6z"/>
<path class="st1" d="M339.8,549.4c-4-0.4-9.4-1.6-13.2-2.9c-3.4-1.2-10-4.4-12.5-6.1c-10.9-7.4-19-17.9-23.1-30
c-2.2-6.7-2.3-7.5-3.3-36.9c-0.5-14.9-0.9-27.9-0.9-28.9l0-1.9l2.3,1.8c2.6,2,6.6,3.4,8.5,3.1c0.6-0.1,3-0.5,5.3-0.8
c37.7-5.3,71.2-22.2,97.4-49.1c12.2-12.5,21.4-25.5,29.9-42.4l3.5-7l0-3.6c0-3.1-0.1-3.8-1-5.7c-0.5-1.2-0.9-2.1-0.9-2.2
c0.2-0.2,55.3,20.1,56.9,20.9c2.6,1.3,6.6,4.1,9.9,7c9.2,7.7,16.1,19.4,18.8,31.8c0.7,3.1,0.8,4.8,0.8,11.3c0,8.6-0.5,11.7-2.9,18.7
c-1.7,5-2.9,7.2-7.1,13.1c-7.6,11-15.3,20.5-25.2,31.2c-32.8,35.4-76.5,62.5-123.4,76.3C351.6,549.6,347.2,550.1,339.8,549.4z"/>
<path class="st2" d="M255.6,438c-25.9-4.2-50.7-14.9-71.7-31c-5.2-4-8.7-7.1-14.1-12.4c-12.7-12.5-21.9-24.9-30.5-41.4
c-2.3-4.4-2.4-4.7-2.4-7.1c0-8.8,8.5-15.2,16.9-12.7c5.6,1.7,9.6,6.8,9.7,12.2c0,2.6-0.8,4.6-2.6,6.2c-1.2,1.1-3.2,1.9-4.6,1.9
c-1.2,0-3.3-0.8-4.3-1.6c-2.1-1.8-2-1,0.4,3.2c19.3,33.8,52.3,59.1,90,69.1c5.7,1.5,11.5,2.7,11.8,2.4c0.1-0.1-0.4-0.8-1.3-1.6
c-5.1-4.5-2.3-11.7,5-12.8c5.4-0.8,11.4,2.7,13.9,8c0.8,1.7,1,2.5,1,5.3s-0.1,3.5-1,5.3c-2,4.3-6.8,7.9-10.3,7.8
C260.6,438.7,257.9,438.3,255.6,438z"/>
<path class="st0" d="M297.6,438.2c-3.4-1.3-6.4-4.3-7.8-8.1c-1.1-2.9-0.9-7.3,0.5-10.2c2.6-5.3,8.7-8.5,14.4-7.5
c2.9,0.5,4.7,1.9,6,4.3c0.8,1.6,1,2.2,0.8,3.6c-0.3,2.2-0.9,3.3-2.7,4.8c-0.8,0.7-1.4,1.4-1.3,1.5c0.5,0.5,13.4-2.7,21.3-5.4
c33.6-11.3,62.5-35.1,80.4-66.1c2.5-4.4,2.6-5,0.5-3.2c-2.8,2.4-7,1.9-9.6-1c-4-4.6-0.7-13.8,6.1-16.9c2-0.9,2.7-1,5.5-1
c2.9,0,3.5,0.1,5.6,1.1c4.4,2.1,7.4,6.4,7.8,11c0.2,2.2,0.1,2.3-2.2,6.9c-23,45.9-67,78.1-117.2,85.9
C300.2,438.8,299.4,438.9,297.6,438.2z"/>
<path class="st1" d="M211.1,398.5c-4.7-0.9-8.7-2.7-12.9-5.9c-10.8-8.1-13.5-22.3-6.6-33.7c0.7-1.2,1.1-2.2,1-2.4
c-0.2-0.2-1.2-0.6-2.3-1.1c-7.6-3-13-10.6-13.5-19.1c-0.5-7.4,3.1-15,9-19.4c1-0.7,2.2-1.5,2.6-1.8c0.8-0.4,68.9-22.7,69.4-22.7
c0.2,0,0.7,0.7,1.2,1.5c0.5,0.8,1.6,2.3,2.4,3.3c1.2,1.4,1.5,1.9,1.2,2.3c-0.2,0.3-6.9,9.5-14.8,20.5
c-15.9,21.9-15.5,21.3-13.4,23.4c1.3,1.3,2.9,1.4,4.4,0.3c0.6-0.4,7.5-9.7,15.5-20.7c11.2-15.4,14.6-19.9,15-19.7
c0.9,0.4,5.5,1.9,6.6,2.1l1,0.2l0,35.3c0,39.7,0,38.8-2.5,44c-2.6,5.3-7.2,9.3-12.7,11.2c-3.7,1.3-6.8,1.6-10.2,1
c-5.5-0.9-9.8-3.2-13.7-7.4l-2.2-2.4l-0.6,0.9c-3,4.3-8.6,8.1-14,9.5C218.2,398.6,213.2,398.9,211.1,398.5z"/>
<path class="st3" d="M342.9,398.5c-5.5-0.9-9.9-3.2-14.3-7.6l-3.2-3.2l-0.7,1c-2.3,3.3-6.8,6.5-11.1,7.9c-3.7,1.2-9.2,1.4-12.6,0.3
c-7.1-2.1-12.7-7.4-15.2-14.3l-0.9-2.6v-37.1v-37.1l1.8-0.4c1-0.2,2.7-0.8,3.9-1.2c1.1-0.5,2.1-0.8,2.2-0.7c0.1,0.1,6.5,9,14.4,19.9
c7.8,10.9,14.7,20.1,15.2,20.5c2.2,1.9,5.4,0.4,5.4-2.6c0-1.4-1-2.9-13.8-20.5c-7.6-10.5-14.2-19.6-14.7-20.4l-0.9-1.3l1.4-1.7
c0.8-0.9,1.9-2.5,2.5-3.4l1-1.6l34.4,11.2c18.9,6.2,35.1,11.6,35.9,12.1c6.8,4,11.1,11.3,11.1,19.1c0,4.1-0.5,6.4-2.4,10.2
c-2,4.1-5.5,7.6-9.6,9.7c-1.6,0.8-3.2,1.5-3.4,1.5c-1,0-0.9,0.7,0.3,2.6c2.8,4.3,4,8.5,3.9,13.7c0,8.1-3.7,15.2-10.6,20.3
C356.4,397.6,349.5,399.5,342.9,398.5z"/>
<path class="st2" d="M53.9,341.9c-0.5-0.1-2.3-0.4-3.9-0.7c-15.6-2.6-30.4-12.6-38.8-26.2c-3.5-5.7-6.4-13.2-7.8-19.9
c-1.2-6.1-0.8-28.1,0.8-43.1c4.5-43,19-84.3,42.2-120.7c6.5-10.2,14.9-21.5,18.2-24.6c17.8-16.6,43.1-20.5,64.8-10
c4.3,2.1,8.8,5.1,12.7,8.6c2.8,2.4,5.8,6.1,20.9,25.5c9.7,12.5,17.8,22.8,17.9,23c0.2,0.2-0.9,0.4-3.2,0.4c-2.5,0-4.1,0.2-5.7,0.7
c-2.1,0.7-2.6,1.1-7.9,6.3c-8.2,8.1-14.4,15.3-20.3,23.9c-15.5,22.2-25.4,47.7-28.8,74.8c-2.2,16.9-1.6,37.5,1.6,52.3
c0.3,1.4,0.5,2.8,0.4,3c-0.1,0.2,0.2,1.3,0.8,2.4c1.1,2.4,4.3,5.7,6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2-13.1,3.8-27.6,8
c-16.4,4.7-27.7,7.8-29.8,8.1C64.1,342.1,56.1,342.3,53.9,341.9z"/>
<path class="st3" d="M494.7,341.7c-2.1-0.3-33.8-9.1-56.5-15.8l-2.5-0.7l1.6-0.8c3.4-1.7,7.2-6.6,7.3-9.6c0-0.7,0.4-3.3,0.8-5.8
c3.9-22.7,3.1-46.1-2.5-68.4c-6.4-25.5-18.6-49.2-35.8-69.1c-4.6-5.3-14.8-15.4-16.4-16.1c-2.4-1.1-5.1-1.6-8-1.4l-2.7,0.2l1.2-1.5
c0.7-0.8,8.5-10.8,17.5-22.3c8.9-11.5,17.2-21.8,18.5-23.1c2.6-2.7,7-6.2,10.3-8.2c19.3-11.6,43-11.1,61.6,1.2
c5.4,3.6,8.2,6.2,12.3,11.7c26.4,34.5,44,73.7,52.3,116.2c3.4,17.6,4.9,33.3,5,52.4c0,13-0.2,14.8-2.5,21.8
C547.8,328.6,521.7,345.2,494.7,341.7z"/>
<path class="st4" d="M133.9,318.5c-2-0.5-4.6-1.9-6-3.3c-2.5-2.4-3.1-3.5-3.7-7.3c-4.4-27.3-2.2-54,6.7-79.3
c5.3-15.1,13.5-30.5,23-43.1c5.8-7.8,16.6-19.5,19-20.7c4.7-2.4,11.3-1.2,15.2,2.7c5.4,5.4,5.2,13.9-0.3,19.1
c-4.3,4-9.4,4.4-12.6,0.9c-1.7-1.9-2.2-3.9-1.7-6.4c0.2-1.1,0.3-2,0.2-2.2c-0.3-0.3-3.6,3.3-8.3,9.1c-17.6,21.8-28.5,48-31.9,76.5
c-1.1,9.3-1,26.4,0.1,34.6c0.3,1.8,0.8,1.9,1.4,0.1c0.9-2.6,4-4.7,6.8-4.7c3,0,5.9,2.2,7.5,5.7c0.6,1.3,0.8,2.3,0.8,5.2
c0,3.3-0.1,3.8-1.1,5.7c-1.4,2.7-4.6,5.7-7.1,6.6C139.4,318.6,135.8,318.9,133.9,318.5z"/>
<path class="st1" d="M422.6,318.5c-3.7-0.6-7.7-3.6-9.4-7.1c-3.8-7.5,0.1-16.9,6.9-16.9c3.1,0,5.8,2,6.9,5.2
c0.4,1.2,0.5,1.3,0.7,0.7c1.3-3.7,1.7-26.4,0.6-35.7c-3.6-29.6-14.5-55.3-33-77.9c-5.5-6.7-8.4-9.4-7.1-6.6c0.7,1.4,0.5,4.3-0.3,5.9
c-0.9,1.7-3.2,3.5-5,3.8c-3.2,0.6-7.9-1.6-10.2-4.8c-6.5-8.8-0.5-21.2,10.4-21.4c4.6-0.1,5.2,0.3,11.2,6.4
c12.1,12.3,21.1,24.9,28.8,40.3c13.2,26.3,18.6,54.9,16.1,84.5c-0.5,5.6-2,15.7-2.6,17.1c-1.3,2.8-4.8,5.5-8.4,6.5
C425.9,318.9,425.1,318.9,422.6,318.5z"/>
<path class="st0" d="M178.2,307.2c-6-1.3-12.2-6.2-14.9-11.7c-3.4-7-3.1-15.1,0.9-21.6c0.7-1.2,1.2-2.3,1.1-2.4
c-0.1-0.1-1.1-0.6-2.1-1c-3.9-1.5-8.1-4.8-10.7-8.3c-4.6-6.2-6.1-14.6-3.9-22.1c2.9-10.3,9.4-16.8,19.1-19.3c2.8-0.7,9-0.8,11.7,0
c1.1,0.3,2.2,0.5,2.4,0.5c0.2,0,0.3-0.7,0.3-1.5c0-2.9,0.8-5.8,2.4-9.2c5.2-10.8,18.1-15.5,29-10.5c2.7,1.2,6.2,3.8,7.8,5.8
c0.7,0.8,10.3,14,21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1-1.9,2.6-2.5,3.5c-0.6,1-1.2,1.7-1.5,1.6c-4.5-1.7-46.7-15-47.7-15
c-1.9,0-3.1,1.3-3.1,3.2c0,1,0.2,1.7,0.8,2.3c0.6,0.6,7.8,3.1,24.5,8.5l23.7,7.7l-0.1,4.3l-0.1,4.3L223,295.9
c-18,5.9-33.9,10.9-35.2,11.2C184.7,307.8,181.2,307.8,178.2,307.2z"/>
<path class="st4" d="M372.5,306.8c-1.8-0.5-17.5-5.6-35-11.3l-31.8-10.4l1-4.3v-4.3l22.6-7.7c15-4.9,24-8,24.6-8.5
c0.7-0.6,0.9-1.1,0.9-2.2c0-2-1.2-3.3-3.1-3.3c-0.9,0-10.5,2.9-24.7,7.5c-12.8,4.1-23.4,7.5-23.6,7.5c-0.1,0-0.7-0.8-1.3-1.9
c-0.6-1-1.6-2.5-2.2-3.2c-0.7-0.7-1.2-1.5-1.2-1.6c0-0.2,9.6-13.5,21.4-29.6c18.9-26,21.6-29.6,23.6-31.1c5.7-4.4,13.1-5.8,19.7-3.9
c9,2.7,16.1,11.6,16.1,20.3c0,2.3-0.1,2.3,3.1,1.5c4.7-1.1,11.5-0.5,16,1.5c4.6,2,9,6,11.5,10.2c2.1,3.6,3.9,9.4,4.2,13.2
c0.3,5.2-1.1,10.7-4,15.3c-2.6,4.1-7.8,8.3-12.1,9.8c-0.9,0.3-1.7,0.8-1.7,1c0,0.2,0.4,1,0.9,1.7c2.4,3.6,3.6,7.7,3.5,12.7
c0,5.8-2.1,10.7-6.4,15.1c-4,4.1-8.9,6.3-14.9,6.5C376.3,307.7,375.3,307.6,372.5,306.8z"/>
<path class="st5" d="M276.2,298.9c-6.1-1.6-11.4-6.8-13.2-12.9c-0.7-2.4-0.7-7.5,0-9.9c1.7-5.8,6.6-10.8,12.3-12.5
c2.7-0.8,7.2-0.9,10-0.2c6.2,1.6,11.6,7.1,13.2,13.3c1.6,6-0.3,12.6-5,17.3C288.9,298.6,282.2,300.5,276.2,298.9z"/>
<path class="st2" d="M248.3,229.8c-13.3-18.3-21.2-29.6-22-31.1c-1.4-3-1.9-5.5-1.9-9.4c0-14.1,13.1-24.4,27.1-21.4
c1.4,0.3,2.6,0.5,2.7,0.5s0.3-1.3,0.4-2.8c0.8-10.7,8.4-19.6,18.9-22.4c3.9-1,10.6-1,14.5,0c8.9,2.3,15.9,9.3,18.2,18.2
c0.4,1.5,0.7,3.7,0.7,4.9c0,1.2,0.1,2.1,0.3,2.1s1.5-0.3,3-0.6c7.4-1.6,15.2,0.7,20.5,6c4.3,4.3,6.6,9.6,6.6,15.6
c0,4-0.6,6.5-2.4,10c-0.6,1.2-10.4,15-21.7,30.7c-17.8,24.5-20.8,28.5-21.4,28.3c-0.4-0.1-1.9-0.6-3.4-1.1c-1.5-0.5-2.9-0.9-3.3-0.9
c-0.7,0-0.7-0.8-0.3-25.5v-25.5l-1.4-0.9c-1-1.1-2.5-1.5-3.8-0.9c-2,0.8-2-0.5-1.8,27.2v25.8h-1.2c-0.5-0.2-2.4,0.3-4,0.9
s-3.1,1.1-3.2,1.1C269.2,258.5,259.8,245.6,248.3,229.8z"/>
<path class="st3" d="M210.9,164.8c-4.1-0.9-7.7-3.6-9.6-7.4c-1.4-2.8-1.7-7.3-0.5-10.3c1.7-4.5,3.9-6.1,15.6-11.2
c15.8-7,31.4-11.1,49.2-12.9c7.3-0.8,23.2-0.8,30.6,0c17.4,1.8,33.3,6,49.1,13c7.3,3.2,12.5,6.1,13.6,7.5c4.3,5.6,3.8,12.7-1.1,17.6
c-5.1,5.1-12.9,5.4-18.1,0.7c-2-1.8-3-3.5-3.4-5.6c-0.7-4,2.9-8.1,7.3-8.2c1.4,0,1.5-0.1,1.1-0.5c-0.3-0.3-2.2-1.2-4.3-2.1
c-33.2-14.5-70.5-16.4-105-5.4c-7.5,2.4-19,7.2-18.6,7.7c0.1,0.2,0.8,0.3,1.6,0.3c5.6,0,9.1,6.2,6.1,10.8
C221.6,163.3,215.9,165.9,210.9,164.8z"/>
<path class="st4" d="M174.7,123.4c-8.9-13.1-16.8-25.1-17.5-26.6c-1.6-3.3-3.6-9.2-4.4-13c-2.6-12.5-0.9-25.8,5-37.5
c4.2-8.3,11.2-16.3,18.6-21.3c5-3.4,6.1-3.9,12.8-6.3c23.1-8.2,47.2-13.1,73.4-15c7.5-0.6,28.5-0.6,36.3,0
c25.5,1.8,50.6,6.9,73,14.8c6.4,2.2,8.2,3.1,13.1,6.5c9.8,6.6,18.1,17.5,22,29.2c2.2,6.5,2.7,10,2.7,17.9c0,7.9-0.5,11.3-2.7,17.9
c-2.3,6.8-3.7,9.1-20.3,33.6l-16.1,23.8l-0.4-2.2c-0.2-1.2-0.9-3-1.4-4c-1-1.8-4.4-5.6-4.7-5.2c-0.1,0.1-1.2-0.4-2.4-1.1
c-9.1-5.2-21.9-10.5-33.2-13.9c-37-11-77.2-8.8-113,6.1c-4.9,2.1-17.7,8.4-19.2,9.5c-2.2,1.6-5.1,6.8-5.1,9c0,0.4-0.1,1-0.3,1.2
C191,147,184.7,138,174.7,123.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.82.0" version = "1.82.1"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"

View file

@ -36,7 +36,7 @@ platform :android do
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 106, "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') 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')

View file

@ -131,7 +131,10 @@
"control_bottom_app_bar_delete_from_local": "Delete from device", "control_bottom_app_bar_delete_from_local": "Delete from device",
"control_bottom_app_bar_favorite": "Favorite", "control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_share": "Share", "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_unarchive": "Unarchive",
"control_bottom_app_bar_upload": "Upload",
"create_album_page_untitled": "Untitled", "create_album_page_untitled": "Untitled",
"create_shared_album_page_create": "Create", "create_shared_album_page_create": "Create",
"create_shared_album_page_share": "Share", "create_shared_album_page_share": "Share",
@ -167,6 +170,7 @@
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "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).", "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_download_error": "Download Error",
"image_viewer_page_state_provider_share_error": "Share Error",
"image_viewer_page_state_provider_download_success": "Download Success", "image_viewer_page_state_provider_download_success": "Download Success",
"library_page_albums": "Albums", "library_page_albums": "Albums",
"library_page_archive": "Archive", "library_page_archive": "Archive",
@ -277,6 +281,7 @@
"setting_pages_app_bar_settings": "Settings", "setting_pages_app_bar_settings": "Settings",
"settings_require_restart": "Please restart Immich to apply this setting", "settings_require_restart": "Please restart Immich to apply this setting",
"share_add": "Add", "share_add": "Add",
"share_done": "Done",
"share_add_photos": "Add photos", "share_add_photos": "Add photos",
"share_add_title": "Add a title", "share_add_title": "Add a title",
"share_create_album": "Create album", "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_description": "Create shared albums to share photos and videos with people in your network.",
"sharing_page_empty_list": "EMPTY LIST", "sharing_page_empty_list": "EMPTY LIST",
"sharing_silver_appbar_create_shared_album": "Create shared album", "sharing_silver_appbar_create_shared_album": "Create shared album",
"sharing_silver_appbar_shared_links": "Shared links",
"sharing_silver_appbar_share_partner": "Share with partner", "sharing_silver_appbar_share_partner": "Share with partner",
"tab_controller_nav_library": "Library", "tab_controller_nav_library": "Library",
"tab_controller_nav_photos": "Photos", "tab_controller_nav_photos": "Photos",
@ -339,5 +345,24 @@
"trash_page_select_assets_btn": "Select assets", "trash_page_select_assets_btn": "Select assets",
"trash_page_empty_trash_btn": "Empty trash", "trash_page_empty_trash_btn": "Empty trash",
"trash_page_empty_trash_dialog_ok": "Ok", "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"
} }

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.82.0" version_number: "1.82.1"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View file

@ -210,6 +210,18 @@ class AlbumViewerAppbar extends HookConsumerWidget
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
).tr(), ).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( ListTile(
leading: const Icon(Icons.settings_rounded), leading: const Icon(Icons.settings_rounded),
onTap: () => onTap: () =>

View file

@ -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/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.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/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@ -69,7 +70,8 @@ class AlbumViewerPage extends HookConsumerWidget {
await AutoRouter.of(context).push<AssetSelectionPageResult?>( await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute( AssetSelectionRoute(
existingAssets: albumInfo.assets, existingAssets: albumInfo.assets,
isNewAlbum: false, canDeselect: false,
query: getRemoteAssetQuery(ref),
), ),
); );

View file

@ -4,26 +4,27 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.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/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:isar/isar.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
class AssetSelectionPage extends HookConsumerWidget { class AssetSelectionPage extends HookConsumerWidget {
const AssetSelectionPage({ const AssetSelectionPage({
Key? key, Key? key,
required this.existingAssets, required this.existingAssets,
this.isNewAlbum = false, this.canDeselect = false,
required this.query,
}) : super(key: key); }) : super(key: key);
final Set<Asset> existingAssets; final Set<Asset> existingAssets;
final bool isNewAlbum; final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
final bool canDeselect;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(currentUserProvider); final renderList = ref.watch(renderListQueryProvider(query));
final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId));
final selected = useState<Set<Asset>>(existingAssets); final selected = useState<Set<Asset>>(existingAssets);
final selectionEnabledHook = useState(true); final selectionEnabledHook = useState(true);
@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget {
selected.value = assets; selected.value = assets;
}, },
selectionActive: true, selectionActive: true,
preselectedAssets: isNewAlbum ? selected.value : existingAssets, preselectedAssets: existingAssets,
canDeselect: isNewAlbum, canDeselect: canDeselect,
showMultiSelectIndicator: false, showMultiSelectIndicator: false,
); );
} }
@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget {
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
if (selected.value.isNotEmpty) if (selected.value.isNotEmpty || canDeselect)
TextButton( TextButton(
onPressed: () { onPressed: () {
var payload = var payload =
@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget {
.popForced<AssetSelectionPageResult>(payload); .popForced<AssetSelectionPageResult>(payload);
}, },
child: Text( child: Text(
"share_add", canDeselect ? "share_done" : "share_add",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,

View file

@ -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/modules/album/ui/shared_album_thumbnail_image.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget { class CreateAlbumPage extends HookConsumerWidget {
@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget {
final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true); final isAlbumTitleEmpty = useState(true);
final selectedAssets = useState<Set<Asset>>( final selectedAssets = useState<Set<Asset>>(
initialAssets != null ? Set.from(initialAssets!) : const {},); initialAssets != null ? Set.from(initialAssets!) : const {},
);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark; final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
showSelectUserPage() async { showSelectUserPage() async {
@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget {
await AutoRouter.of(context).push<AssetSelectionPageResult?>( await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute( AssetSelectionRoute(
existingAssets: selectedAssets.value, existingAssets: selectedAssets.value,
isNewAlbum: true, canDeselect: true,
query: getRemoteAssetQuery(ref),
), ),
); );
if (selectedAsset == null) { if (selectedAsset == null) {

View file

@ -147,13 +147,13 @@ class SharingPage extends HookConsumerWidget {
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => onPressed: () =>
AutoRouter.of(context).push(const PartnerRoute()), AutoRouter.of(context).push(const SharedLinkRoute()),
icon: const Icon( icon: const Icon(
Icons.swap_horizontal_circle_outlined, Icons.link,
size: 20, size: 20,
), ),
label: const Text( label: const Text(
"sharing_silver_appbar_share_partner", "sharing_silver_appbar_shared_links",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 11, fontSize: 11,
@ -179,6 +179,17 @@ class SharingPage extends HookConsumerWidget {
fontSize: 22, 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()),
),
],
); );
} }

View file

@ -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<List<Asset>> {
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<AssetStackNotifier, List<Asset>, Asset>(
(ref, asset) => AssetStackNotifier(asset, ref),
);
final assetStackProvider =
FutureProvider.autoDispose.family<List<Asset>, 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();
});

View file

@ -57,9 +57,19 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext buildContext) { builder: (BuildContext buildContext) {
_shareService _shareService.shareAsset(asset).then(
.shareAsset(asset) (bool status) {
.then((_) => Navigator.of(buildContext).pop()); 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(); return const ShareDialog();
}, },
barrierDismissible: false, barrierDismissible: false,

View file

@ -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/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
final renderListProvider = final renderListProvider =
FutureProvider.family<RenderList, List<Asset>>((ref, assets) { FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
@ -13,3 +14,19 @@ final renderListProvider =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
); );
}); });
final renderListQueryProvider = StreamProvider.family<RenderList,
QueryBuilder<Asset, Asset, QAfterSortBy>?>(
(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);
}
},
);

View file

@ -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<Asset>? childrenToAdd,
List<Asset>? 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),
),
);

View file

@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -14,6 +15,7 @@ final imageViewerServiceProvider =
class ImageViewerService { class ImageViewerService {
final ApiService _apiService; final ApiService _apiService;
final Logger _log = Logger("ImageViewerService");
ImageViewerService(this._apiService); ImageViewerService(this._apiService);
@ -29,6 +31,16 @@ class ImageViewerService {
asset.livePhotoVideoId!, 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 AssetEntity? entity;
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
@ -48,6 +60,13 @@ class ImageViewerService {
var res = await _apiService.assetApi var res = await _apiService.assetApi
.downloadFileWithHttpInfo(asset.remoteId!); .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; final AssetEntity? entity;
if (asset.isImage) { if (asset.isImage) {

View file

@ -8,11 +8,13 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_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/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/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/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/advanced_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_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'; 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 totalAssets;
final int initialIndex; final int initialIndex;
final int heroOffset; final int heroOffset;
final bool showStack;
GalleryViewerPage({ GalleryViewerPage({
super.key, super.key,
@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget {
required this.loadAsset, required this.loadAsset,
required this.totalAssets, required this.totalAssets,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false,
}) : controller = PageController(initialPage: initialIndex); }) : controller = PageController(initialPage: initialIndex);
final PageController controller; final PageController controller;
@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget {
final isFromTrash = isTrashEnabled && final isFromTrash = isTrashEnabled &&
navStack.length > 2 && navStack.length > 2 &&
navStack.elementAt(navStack.length - 2).name == TrashRoute.name; navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
final stackIndex = useState(-1);
final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset))
: <Asset>[];
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
Asset asset() => currentAsset; Asset asset() => stackIndex.value == -1
? currentAsset
: stackElements.elementAt(stackIndex.value);
bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
useEffect( useEffect(
() { () {
@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget {
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom, 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 { void handleDelete(Asset deleteAsset) async {
Future<bool> onDelete(bool force) async { Future<bool> onDelete(bool force) async {
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
{deleteAsset}, {deleteAsset},
force: force, force: force,
); );
if (isDeleted) { if (isDeleted && isParent) {
if (totalAssets == 1) { if (totalAssets == 1) {
// Handle only one asset // Handle only one asset
AutoRouter.of(context).pop(); AutoRouter.of(context).pop();
@ -195,14 +217,17 @@ class GalleryViewerPage extends HookConsumerWidget {
// Asset is trashed // Asset is trashed
if (isTrashEnabled && !isFromTrash) { if (isTrashEnabled && !isFromTrash) {
final isDeleted = await onDelete(false); final isDeleted = await onDelete(false);
// Can only trash assets stored in server. Local assets are always permanently removed for now if (isDeleted) {
if (context.mounted && isDeleted && deleteAsset.isRemote) { // Can only trash assets stored in server. Local assets are always permanently removed for now
ImmichToast.show( if (context.mounted && deleteAsset.isRemote && isParent) {
durationInSecond: 1, ImmichToast.show(
context: context, durationInSecond: 1,
msg: 'Asset trashed', context: context,
gravity: ToastGravity.BOTTOM, msg: 'Asset trashed',
); gravity: ToastGravity.BOTTOM,
);
}
removeAssetFromStack();
} }
return; return;
} }
@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget {
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext _) { 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 ref
.watch(assetProvider.notifier) .watch(assetProvider.notifier)
.toggleArchive([asset], !asset.isArchived); .toggleArchive([asset], !asset.isArchived);
AutoRouter.of(context).pop(); if (isParent) {
AutoRouter.of(context).pop();
return;
}
removeAssetFromStack();
} }
handleUpload(Asset asset) { 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<void>(
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<Function(int)> actionslist = [
(_) => shareAsset(),
(_) => handleArchive(asset()),
if (stack.isNotEmpty) (_) => showStackActionItems(),
(_) => handleDelete(asset()),
];
return IgnorePointer( return IgnorePointer(
ignoring: !ref.watch(showControlsProvider), ignoring: !ref.watch(showControlsProvider),
child: AnimatedOpacity( child: AnimatedOpacity(
@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget {
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
child: Column( child: Column(
children: [ children: [
if (stack.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 10,
bottom: 30,
),
child: SizedBox(
height: 40,
child: buildStackedChildren(),
),
),
Visibility( Visibility(
visible: !asset().isImage && !isPlayingMotionVideo.value, visible: !asset().isImage && !isPlayingMotionVideo.value,
child: Container( child: Container(
@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget {
selectedLabelStyle: const TextStyle(color: Colors.black), selectedLabelStyle: const TextStyle(color: Colors.black),
showSelectedLabels: false, showSelectedLabels: false,
showUnselectedLabels: false, showUnselectedLabels: false,
items: [ items: 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(),
),
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'control_bottom_app_bar_delete'.tr(),
tooltip: 'control_bottom_app_bar_delete'.tr(),
),
],
onTap: (index) { onTap: (index) {
switch (index) { if (index < actionslist.length) {
case 0: actionslist[index].call(index);
shareAsset();
break;
case 1:
handleArchive(asset());
break;
case 2:
handleDelete(asset());
break;
} }
}, },
), ),
@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final next = currentIndex.value < value ? value + 1 : value - 1; final next = currentIndex.value < value ? value + 1 : value - 1;
precacheNextImage(next); precacheNextImage(next);
currentIndex.value = value; currentIndex.value = value;
stackIndex.value = -1;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
loadingBuilder: (context, event, index) { loadingBuilder: (context, event, index) {
@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget {
: webPThumbnail; : webPThumbnail;
}, },
builder: (context, index) { builder: (context, index) {
final asset = loadAsset(index); final a =
final ImageProvider provider = finalImageProvider(asset); index == currentIndex.value ? asset() : loadAsset(index);
final ImageProvider provider = finalImageProvider(a);
if (asset.isImage && !isPlayingMotionVideo.value) { if (a.isImage && !isPlayingMotionVideo.value) {
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) => onDragStart: (_, details, __) =>
localPosition = details.localPosition, localPosition = details.localPosition,
@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget {
}, },
imageProvider: provider, imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes( heroAttributes: PhotoViewHeroAttributes(
tag: asset.id + heroOffset, tag: a.id + heroOffset,
), ),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage( errorBuilder: (context, error, stackTrace) => ImmichImage(
asset, a,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
); );
@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget {
onDragUpdate: (_, details, __) => onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details), handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes( heroAttributes: PhotoViewHeroAttributes(
tag: asset.id + heroOffset, tag: a.id + heroOffset,
), ),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
maxScale: 1.0, maxScale: 1.0,
@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget {
child: VideoViewerPage( child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true, onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false, onPaused: () => isPlayingVideo.value = false,
asset: asset, asset: a,
isMotionVideo: isPlayingMotionVideo.value, isMotionVideo: isPlayingMotionVideo.value,
placeholder: Image( placeholder: Image(
image: provider, image: provider,

View file

@ -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<Asset> 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;
}

View file

@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
final Widget? topWidget; final Widget? topWidget;
final bool shrinkWrap; final bool shrinkWrap;
final bool showDragScroll; final bool showDragScroll;
final bool showStack;
const ImmichAssetGrid({ const ImmichAssetGrid({
super.key, super.key,
@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.topWidget, this.topWidget,
this.shrinkWrap = false, this.shrinkWrap = false,
this.showDragScroll = true, this.showDragScroll = true,
this.showStack = false,
}); });
@override @override
@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
heroOffset: heroOffset(), heroOffset: heroOffset(),
shrinkWrap: shrinkWrap, shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll, showDragScroll: showDragScroll,
showStack: showStack,
), ),
); );
} }

View file

@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget {
final int heroOffset; final int heroOffset;
final bool shrinkWrap; final bool shrinkWrap;
final bool showDragScroll; final bool showDragScroll;
final bool showStack;
const ImmichAssetGridView({ const ImmichAssetGridView({
super.key, super.key,
@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget {
this.heroOffset = 0, this.heroOffset = 0,
this.shrinkWrap = false, this.shrinkWrap = false,
this.showDragScroll = true, this.showDragScroll = true,
this.showStack = false,
}); });
@override @override
@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
bool _scrolling = false; bool _scrolling = false;
final Set<Asset> _selectedAssets = final Set<Asset> _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<Asset> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets); return Set.from(_selectedAssets);
@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _deselectAssets(List<Asset> assets) { void _deselectAssets(List<Asset> assets) {
setState(() { setState(() {
_selectedAssets.removeAll(assets); _selectedAssets.removeAll(
assets.where(
(a) =>
widget.canDeselect ||
!(widget.preselectedAssets?.contains(a) ?? false),
),
);
_callSelectionListener(_selectedAssets.isNotEmpty); _callSelectionListener(_selectedAssets.isNotEmpty);
}); });
} }
@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
useGrayBoxPlaceholder: true, useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator, showStorageIndicator: widget.showStorageIndicator,
heroOffset: widget.heroOffset, heroOffset: widget.heroOffset,
showStack: widget.showStack,
); );
} }
@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
setState(() { setState(() {
_selectedAssets.clear(); _selectedAssets.clear();
}); });
} else if (widget.preselectedAssets != null) {
setState(() {
_selectedAssets.addAll(widget.preselectedAssets!);
});
} }
} }

View file

@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget {
final Asset Function(int index) loadAsset; final Asset Function(int index) loadAsset;
final int totalAssets; final int totalAssets;
final bool showStorageIndicator; final bool showStorageIndicator;
final bool showStack;
final bool useGrayBoxPlaceholder; final bool useGrayBoxPlaceholder;
final bool isSelected; final bool isSelected;
final bool multiselectEnabled; final bool multiselectEnabled;
@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget {
required this.loadAsset, required this.loadAsset,
required this.totalAssets, required this.totalAssets,
this.showStorageIndicator = true, this.showStorageIndicator = true,
this.showStack = false,
this.useGrayBoxPlaceholder = false, this.useGrayBoxPlaceholder = false,
this.isSelected = false, this.isSelected = false,
this.multiselectEnabled = 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() { Widget buildImage() {
final image = SizedBox( final image = SizedBox(
width: 300, width: 300,
@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
width: 0, width: 0,
color: assetContainerColor, color: onDeselect == null ? Colors.grey : assetContainerColor,
), ),
color: assetContainerColor, color: onDeselect == null ? Colors.grey : assetContainerColor,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget {
loadAsset: loadAsset, loadAsset: loadAsset,
totalAssets: totalAssets, totalAssets: totalAssets,
heroOffset: heroOffset, heroOffset: heroOffset,
showStack: showStack,
), ),
); );
} }
@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget {
), ),
), ),
if (!asset.isImage) buildVideoIcon(), if (!asset.isImage) buildVideoIcon(),
if (asset.isImage && asset.stackCount > 0) buildStackIcon(),
], ],
), ),
); );

View file

@ -1,18 +1,16 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/delete_dialog.dart';
import 'package:immich_mobile/modules/home/ui/upload_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/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
class ControlBottomAppBar extends ConsumerWidget { class ControlBottomAppBar extends ConsumerWidget {
final void Function() onShare; final void Function(bool shareLocal) onShare;
final void Function() onFavorite; final void Function() onFavorite;
final void Function() onArchive; final void Function() onArchive;
final void Function() onDelete; final void Function() onDelete;
@ -20,11 +18,12 @@ class ControlBottomAppBar extends ConsumerWidget {
final Function(Album album) onAddToAlbum; final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum; final void Function() onCreateNewAlbum;
final void Function() onUpload; final void Function() onUpload;
final void Function() onStack;
final List<Album> albums; final List<Album> albums;
final List<Album> sharedAlbums; final List<Album> sharedAlbums;
final bool enabled; final bool enabled;
final AssetState selectionAssetState; final SelectionAssetState selectionAssetState;
const ControlBottomAppBar({ const ControlBottomAppBar({
Key? key, Key? key,
@ -38,32 +37,37 @@ class ControlBottomAppBar extends ConsumerWidget {
required this.onAddToAlbum, required this.onAddToAlbum,
required this.onCreateNewAlbum, required this.onCreateNewAlbum,
required this.onUpload, required this.onUpload,
this.selectionAssetState = AssetState.remote, required this.onStack,
this.selectionAssetState = const SelectionAssetState(),
this.enabled = true, this.enabled = true,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var isDarkMode = Theme.of(context).brightness == Brightness.dark; var isDarkMode = Theme.of(context).brightness == Brightness.dark;
var hasRemote = selectionAssetState == AssetState.remote || var hasRemote =
selectionAssetState == AssetState.merged; selectionAssetState.hasRemote || selectionAssetState.hasMerged;
var hasLocal = selectionAssetState == AssetState.merged || var hasLocal =
selectionAssetState == AssetState.local; selectionAssetState.hasLocal || selectionAssetState.hasMerged;
final trashEnabled = final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
List<Widget> renderActionButtons() { List<Widget> renderActionButtons() {
return [ return [
if (hasRemote)
ControlBoxButton(
iconData: Icons.share_rounded,
label: "control_bottom_app_bar_share".tr(),
onPressed: enabled ? () => onShare(false) : null,
),
ControlBoxButton( ControlBoxButton(
iconData: Platform.isAndroid iconData: Icons.ios_share_rounded,
? Icons.share_rounded label: "control_bottom_app_bar_share_to".tr(),
: Icons.ios_share_rounded, onPressed: enabled ? () => onShare(true) : null,
label: "control_bottom_app_bar_share".tr(),
onPressed: enabled ? onShare : null,
), ),
if (hasRemote) if (hasRemote)
ControlBoxButton( ControlBoxButton(
iconData: Icons.archive_outlined, iconData: Icons.archive,
label: "control_bottom_app_bar_archive".tr(), label: "control_bottom_app_bar_archive".tr(),
onPressed: enabled ? onArchive : null, onPressed: enabled ? onArchive : null,
), ),
@ -114,6 +118,12 @@ class ControlBottomAppBar extends ConsumerWidget {
: null, : null,
), ),
), ),
if (!hasLocal)
ControlBoxButton(
iconData: Icons.filter_none_rounded,
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
if (!hasRemote) if (!hasRemote)
ControlBoxButton( ControlBoxButton(
iconData: Icons.backup_outlined, iconData: Icons.backup_outlined,
@ -135,7 +145,7 @@ class ControlBottomAppBar extends ConsumerWidget {
return DraggableScrollableSheet( return DraggableScrollableSheet(
initialChildSize: hasRemote ? 0.30 : 0.18, initialChildSize: hasRemote ? 0.30 : 0.18,
minChildSize: 0.18, minChildSize: 0.18,
maxChildSize: hasRemote ? 0.57 : 0.18, maxChildSize: hasRemote ? 0.60 : 0.18,
snap: true, snap: true,
builder: ( builder: (
BuildContext context, BuildContext context,
@ -191,10 +201,6 @@ class ControlBottomAppBar extends ConsumerWidget {
enabled: enabled, enabled: enabled,
), ),
), ),
if (hasRemote)
const SliverToBoxAdapter(
child: SizedBox(height: 200),
),
], ],
), ),
); );
@ -227,7 +233,10 @@ class AddToAlbumTitleRow extends StatelessWidget {
).tr(), ).tr(),
TextButton.icon( TextButton.icon(
onPressed: onCreateNewAlbum, onPressed: onCreateNewAlbum,
icon: const Icon(Icons.add), icon: Icon(
Icons.add,
color: Theme.of(context).primaryColor,
),
label: Text( label: Text(
"common_create_new_album", "common_create_new_album",
style: TextStyle( style: TextStyle(

View file

@ -7,11 +7,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.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/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.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/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/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.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'; 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) { Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider.notifier); final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false); final selectionEnabledHook = useState(false);
final selectionAssetState = useState(AssetState.remote); final selectionAssetState = useState(const SelectionAssetState());
final selection = useState(<Asset>{}); final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
@ -83,17 +87,8 @@ class HomePage extends HookConsumerWidget {
) { ) {
selectionEnabledHook.value = multiselect; selectionEnabledHook.value = multiselect;
selection.value = selectedAssets; selection.value = selectedAssets;
selectionAssetState.value = selectedAssets.any((e) => e.isRemote) selectionAssetState.value =
? selectedAssets.any((e) => e.isLocal) SelectionAssetState.fromSelection(selectedAssets);
? AssetState.merged
: AssetState.remote
: AssetState.local;
}
void onShareAssets() {
handleShareAssets(ref, context, selection.value.toList());
selectionEnabledHook.value = false;
} }
List<Asset> remoteOnlySelection({String? localErrorMessage}) { List<Asset> remoteOnlySelection({String? localErrorMessage}) {
@ -112,6 +107,19 @@ class HomePage extends HookConsumerWidget {
return assets.toList(); 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 { void onFavoriteAssets() async {
processing.value = true; processing.value = true;
try { 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<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: stackChildren,
canDeselect: true,
query: getAssetStackSelectionQuery(ref, selectedAsset),
),
);
if (returnPayload != null) {
Set<Asset> 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<void> refreshAssets() async { Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0; final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
@ -346,6 +403,7 @@ class HomePage extends HookConsumerWidget {
currentUser.memoryEnabled!) currentUser.memoryEnabled!)
? const MemoryLane() ? const MemoryLane()
: const SizedBox(), : const SizedBox(),
showStack: true,
), ),
error: (error, _) => Center(child: Text(error.toString())), error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator, loading: buildLoadingIndicator,
@ -364,6 +422,7 @@ class HomePage extends HookConsumerWidget {
onUpload: onUpload, onUpload: onUpload,
enabled: !processing.value, enabled: !processing.value,
selectionAssetState: selectionAssetState.value, selectionAssetState: selectionAssetState.value,
onStack: onStack,
), ),
if (processing.value) const Center(child: ImmichLoadingIndicator()), if (processing.value) const Center(child: ImmichLoadingIndicator()),
], ],

View file

@ -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;
}

View file

@ -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<AsyncValue<List<SharedLink>>> {
final SharedLinkService _sharedLinkService;
SharedLinksNotifier(this._sharedLinkService) : super(const AsyncLoading()) {
fetchLinks();
}
Future<void> fetchLinks() async {
state = await _sharedLinkService.getAllSharedLinks();
}
Future<void> deleteLink(String id) async {
await _sharedLinkService.deleteSharedLink(id);
state = const AsyncLoading();
fetchLinks();
}
}
final sharedLinksStateProvider =
StateNotifierProvider<SharedLinksNotifier, AsyncValue<List<SharedLink>>>(
(ref) {
return SharedLinksNotifier(
ref.watch(sharedLinkServiceProvider),
);
});

View file

@ -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<AsyncValue<List<SharedLink>>> 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<void> 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<SharedLink?> createSharedLink({
required bool showMeta,
required bool allowDownload,
required bool allowUpload,
String? description,
String? albumId,
List<String>? 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<SharedLink?> 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;
}
}

View file

@ -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<void> 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,
),
),
],
);
}
}

View file

@ -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<String>? 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<void> 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<void> 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(),
),
],
),
),
);
}
}

View file

@ -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<SharedLink> 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()),
),
),
);
}
}

View file

@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget {
listener: selectionListener, listener: selectionListener,
selectionActive: selectionEnabledHook.value, selectionActive: selectionEnabledHook.value,
showMultiSelectIndicator: false, showMultiSelectIndicator: false,
showStack: true,
topWidget: Padding( topWidget: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 24, top: 24,

View file

@ -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/login/views/login_page.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.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/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/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_motion_videos_page.dart';
import 'package:immich_mobile/modules/search/views/all_people_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/app_log_page.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
part 'router.gr.dart'; part 'router.gr.dart';
@ -157,6 +161,8 @@ part 'router.gr.dart';
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: TrashPage, 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 { class AppRouter extends _$AppRouter {

View file

@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter {
loadAsset: args.loadAsset, loadAsset: args.loadAsset,
totalAssets: args.totalAssets, totalAssets: args.totalAssets,
heroOffset: args.heroOffset, heroOffset: args.heroOffset,
showStack: args.showStack,
), ),
); );
}, },
@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter {
child: AssetSelectionPage( child: AssetSelectionPage(
key: args.key, key: args.key,
existingAssets: args.existingAssets, existingAssets: args.existingAssets,
isNewAlbum: args.isNewAlbum, canDeselect: args.canDeselect,
query: args.query,
), ),
transitionsBuilder: TransitionsBuilders.slideBottom, transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true, opaque: true,
@ -318,6 +320,25 @@ class _$AppRouter extends RootStackRouter {
child: const TrashPage(), child: const TrashPage(),
); );
}, },
SharedLinkRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SharedLinkPage(),
);
},
SharedLinkEditRoute.name: (routeData) {
final args = routeData.argsAs<SharedLinkEditRouteArgs>(
orElse: () => const SharedLinkEditRouteArgs());
return MaterialPageX<dynamic>(
routeData: routeData,
child: SharedLinkEditPage(
key: args.key,
existingLink: args.existingLink,
assetsList: args.assetsList,
albumId: args.albumId,
),
);
},
HomeRoute.name: (routeData) { HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, routeData: routeData,
@ -638,6 +659,22 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard, 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<GalleryViewerRouteArgs> {
required Asset Function(int) loadAsset, required Asset Function(int) loadAsset,
required int totalAssets, required int totalAssets,
int heroOffset = 0, int heroOffset = 0,
bool showStack = false,
}) : super( }) : super(
GalleryViewerRoute.name, GalleryViewerRoute.name,
path: '/gallery-viewer-page', path: '/gallery-viewer-page',
@ -720,6 +758,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
loadAsset: loadAsset, loadAsset: loadAsset,
totalAssets: totalAssets, totalAssets: totalAssets,
heroOffset: heroOffset, heroOffset: heroOffset,
showStack: showStack,
), ),
); );
@ -733,6 +772,7 @@ class GalleryViewerRouteArgs {
required this.loadAsset, required this.loadAsset,
required this.totalAssets, required this.totalAssets,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false,
}); });
final Key? key; final Key? key;
@ -745,9 +785,11 @@ class GalleryViewerRouteArgs {
final int heroOffset; final int heroOffset;
final bool showStack;
@override @override
String toString() { 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<AssetSelectionRouteArgs> {
AssetSelectionRoute({ AssetSelectionRoute({
Key? key, Key? key,
required Set<Asset> existingAssets, required Set<Asset> existingAssets,
bool isNewAlbum = false, bool canDeselect = false,
required QueryBuilder<Asset, Asset, QAfterSortBy>? query,
}) : super( }) : super(
AssetSelectionRoute.name, AssetSelectionRoute.name,
path: '/asset-selection-page', path: '/asset-selection-page',
args: AssetSelectionRouteArgs( args: AssetSelectionRouteArgs(
key: key, key: key,
existingAssets: existingAssets, existingAssets: existingAssets,
isNewAlbum: isNewAlbum, canDeselect: canDeselect,
query: query,
), ),
); );
@ -979,18 +1023,21 @@ class AssetSelectionRouteArgs {
const AssetSelectionRouteArgs({ const AssetSelectionRouteArgs({
this.key, this.key,
required this.existingAssets, required this.existingAssets,
this.isNewAlbum = false, this.canDeselect = false,
required this.query,
}); });
final Key? key; final Key? key;
final Set<Asset> existingAssets; final Set<Asset> existingAssets;
final bool isNewAlbum; final bool canDeselect;
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
@override @override
String toString() { 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<void> {
static const String name = 'TrashRoute'; static const String name = 'TrashRoute';
} }
/// generated route for
/// [SharedLinkPage]
class SharedLinkRoute extends PageRouteInfo<void> {
const SharedLinkRoute()
: super(
SharedLinkRoute.name,
path: '/shared-link-page',
);
static const String name = 'SharedLinkRoute';
}
/// generated route for
/// [SharedLinkEditPage]
class SharedLinkEditRoute extends PageRouteInfo<SharedLinkEditRouteArgs> {
SharedLinkEditRoute({
Key? key,
SharedLink? existingLink,
List<String>? 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<String>? assetsList;
final String? albumId;
@override
String toString() {
return 'SharedLinkEditRouteArgs{key: $key, existingLink: $existingLink, assetsList: $assetsList, albumId: $albumId}';
}
}
/// generated route for /// generated route for
/// [HomePage] /// [HomePage]
class HomeRoute extends PageRouteInfo<void> { class HomeRoute extends PageRouteInfo<void> {

View file

@ -31,7 +31,9 @@ class Asset {
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null, remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite, isFavorite = remote.isFavorite,
isArchived = remote.isArchived, isArchived = remote.isArchived,
isTrashed = remote.isTrashed; isTrashed = remote.isTrashed,
stackParentId = remote.stackParentId,
stackCount = remote.stackCount;
Asset.local(AssetEntity local, List<int> hash) Asset.local(AssetEntity local, List<int> hash)
: localId = local.id, : localId = local.id,
@ -47,6 +49,7 @@ class Asset {
isFavorite = local.isFavorite, isFavorite = local.isFavorite,
isArchived = false, isArchived = false,
isTrashed = false, isTrashed = false,
stackCount = 0,
fileCreatedAt = local.createDateTime { fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) { if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt; fileCreatedAt = fileModifiedAt;
@ -77,6 +80,8 @@ class Asset {
required this.isFavorite, required this.isFavorite,
required this.isArchived, required this.isArchived,
required this.isTrashed, required this.isTrashed,
this.stackParentId,
required this.stackCount,
}); });
@ignore @ignore
@ -146,6 +151,10 @@ class Asset {
@ignore @ignore
ExifInfo? exifInfo; ExifInfo? exifInfo;
String? stackParentId;
int stackCount;
/// `true` if this [Asset] is present on the device /// `true` if this [Asset] is present on the device
@ignore @ignore
bool get isLocal => localId != null; bool get isLocal => localId != null;
@ -200,7 +209,9 @@ class Asset {
isFavorite == other.isFavorite && isFavorite == other.isFavorite &&
isLocal == other.isLocal && isLocal == other.isLocal &&
isArchived == other.isArchived && isArchived == other.isArchived &&
isTrashed == other.isTrashed; isTrashed == other.isTrashed &&
stackCount == other.stackCount &&
stackParentId == other.stackParentId;
} }
@override @override
@ -223,7 +234,9 @@ class Asset {
isFavorite.hashCode ^ isFavorite.hashCode ^
isLocal.hashCode ^ isLocal.hashCode ^
isArchived.hashCode ^ isArchived.hashCode ^
isTrashed.hashCode; isTrashed.hashCode ^
stackCount.hashCode ^
stackParentId.hashCode;
/// Returns `true` if this [Asset] can updated with values from parameter [a] /// Returns `true` if this [Asset] can updated with values from parameter [a]
bool canUpdate(Asset a) { bool canUpdate(Asset a) {
@ -236,9 +249,11 @@ class Asset {
width == null && a.width != null || width == null && a.width != null ||
height == null && a.height != null || height == null && a.height != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null || livePhotoVideoId == null && a.livePhotoVideoId != null ||
stackParentId == null && a.stackParentId != null ||
isFavorite != a.isFavorite || isFavorite != a.isFavorite ||
isArchived != a.isArchived || 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] /// Returns a new [Asset] with values from this and merged & updated with [a]
@ -267,6 +282,8 @@ class Asset {
id: id, id: id,
remoteId: remoteId, remoteId: remoteId,
livePhotoVideoId: livePhotoVideoId, livePhotoVideoId: livePhotoVideoId,
stackParentId: stackParentId,
stackCount: stackCount,
isFavorite: isFavorite, isFavorite: isFavorite,
isArchived: isArchived, isArchived: isArchived,
isTrashed: isTrashed, isTrashed: isTrashed,
@ -281,6 +298,8 @@ class Asset {
width: a.width, width: a.width,
height: a.height, height: a.height,
livePhotoVideoId: a.livePhotoVideoId, livePhotoVideoId: a.livePhotoVideoId,
stackParentId: a.stackParentId,
stackCount: a.stackCount,
// isFavorite + isArchived are not set by device-only assets // isFavorite + isArchived are not set by device-only assets
isFavorite: a.isFavorite, isFavorite: a.isFavorite,
isArchived: a.isArchived, isArchived: a.isArchived,
@ -318,6 +337,8 @@ class Asset {
bool? isArchived, bool? isArchived,
bool? isTrashed, bool? isTrashed,
ExifInfo? exifInfo, ExifInfo? exifInfo,
String? stackParentId,
int? stackCount,
}) => }) =>
Asset( Asset(
id: id ?? this.id, id: id ?? this.id,
@ -338,6 +359,8 @@ class Asset {
isArchived: isArchived ?? this.isArchived, isArchived: isArchived ?? this.isArchived,
isTrashed: isTrashed ?? this.isTrashed, isTrashed: isTrashed ?? this.isTrashed,
exifInfo: exifInfo ?? this.exifInfo, exifInfo: exifInfo ?? this.exifInfo,
stackParentId: stackParentId ?? this.stackParentId,
stackCount: stackCount ?? this.stackCount,
); );
Future<void> put(Isar db) async { Future<void> put(Isar db) async {
@ -379,6 +402,8 @@ class Asset {
"checksum": "$checksum", "checksum": "$checksum",
"ownerId": $ownerId, "ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
"stackCount": "$stackCount",
"stackParentId": "${stackParentId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt", "fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt", "fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt", "updatedAt": "$updatedAt",

View file

@ -82,19 +82,29 @@ const AssetSchema = CollectionSchema(
name: r'remoteId', name: r'remoteId',
type: IsarType.string, type: IsarType.string,
), ),
r'type': PropertySchema( r'stackCount': PropertySchema(
id: 13, 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', name: r'type',
type: IsarType.byte, type: IsarType.byte,
enumMap: _AssettypeEnumValueMap, enumMap: _AssettypeEnumValueMap,
), ),
r'updatedAt': PropertySchema( r'updatedAt': PropertySchema(
id: 14, id: 16,
name: r'updatedAt', name: r'updatedAt',
type: IsarType.dateTime, type: IsarType.dateTime,
), ),
r'width': PropertySchema( r'width': PropertySchema(
id: 15, id: 17,
name: r'width', name: r'width',
type: IsarType.int, type: IsarType.int,
) )
@ -184,6 +194,12 @@ int _assetEstimateSize(
bytesCount += 3 + value.length * 3; bytesCount += 3 + value.length * 3;
} }
} }
{
final value = object.stackParentId;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
return bytesCount; return bytesCount;
} }
@ -206,9 +222,11 @@ void _assetSerialize(
writer.writeString(offsets[10], object.localId); writer.writeString(offsets[10], object.localId);
writer.writeLong(offsets[11], object.ownerId); writer.writeLong(offsets[11], object.ownerId);
writer.writeString(offsets[12], object.remoteId); writer.writeString(offsets[12], object.remoteId);
writer.writeByte(offsets[13], object.type.index); writer.writeLong(offsets[13], object.stackCount);
writer.writeDateTime(offsets[14], object.updatedAt); writer.writeString(offsets[14], object.stackParentId);
writer.writeInt(offsets[15], object.width); writer.writeByte(offsets[15], object.type.index);
writer.writeDateTime(offsets[16], object.updatedAt);
writer.writeInt(offsets[17], object.width);
} }
Asset _assetDeserialize( Asset _assetDeserialize(
@ -232,10 +250,12 @@ Asset _assetDeserialize(
localId: reader.readStringOrNull(offsets[10]), localId: reader.readStringOrNull(offsets[10]),
ownerId: reader.readLong(offsets[11]), ownerId: reader.readLong(offsets[11]),
remoteId: reader.readStringOrNull(offsets[12]), 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, AssetType.other,
updatedAt: reader.readDateTime(offsets[14]), updatedAt: reader.readDateTime(offsets[16]),
width: reader.readIntOrNull(offsets[15]), width: reader.readIntOrNull(offsets[17]),
); );
return object; return object;
} }
@ -274,11 +294,15 @@ P _assetDeserializeProp<P>(
case 12: case 12:
return (reader.readStringOrNull(offset)) as P; return (reader.readStringOrNull(offset)) as P;
case 13: case 13:
return (reader.readLong(offset)) as P;
case 14:
return (reader.readStringOrNull(offset)) as P;
case 15:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P; AssetType.other) as P;
case 14: case 16:
return (reader.readDateTime(offset)) as P; return (reader.readDateTime(offset)) as P;
case 15: case 17:
return (reader.readIntOrNull(offset)) as P; return (reader.readIntOrNull(offset)) as P;
default: default:
throw IsarError('Unknown property with id $propertyId'); throw IsarError('Unknown property with id $propertyId');
@ -1801,6 +1825,205 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
}); });
} }
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountEqualTo(
int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackCount',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountGreaterThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'stackCount',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountLessThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'stackCount',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> 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<Asset, Asset, QAfterFilterCondition> stackParentIdIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'stackParentId',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'stackParentId',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> 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<Asset, Asset, QAfterFilterCondition> 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<Asset, Asset, QAfterFilterCondition> 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<Asset, Asset, QAfterFilterCondition> stackParentIdStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'stackParentId',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackParentId',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'stackParentId',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo( QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
AssetType value) { AssetType value) {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
@ -2137,6 +2360,30 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
}); });
} }
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackCount() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackCount', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackCountDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackCount', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() { QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc); return query.addSortBy(r'type', Sort.asc);
@ -2343,6 +2590,30 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
}); });
} }
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackCount() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackCount', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackCountDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackCount', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() { QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc); return query.addSortBy(r'type', Sort.asc);
@ -2465,6 +2736,20 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
}); });
} }
QueryBuilder<Asset, Asset, QDistinct> distinctByStackCount() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'stackCount');
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByStackParentId(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'stackParentId',
caseSensitive: caseSensitive);
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByType() { QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'type'); return query.addDistinctBy(r'type');
@ -2569,6 +2854,18 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
}); });
} }
QueryBuilder<Asset, int, QQueryOperations> stackCountProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'stackCount');
});
}
QueryBuilder<Asset, String?, QQueryOperations> stackParentIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'stackParentId');
});
}
QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() { QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'type'); return query.addPropertyName(r'type');

View file

@ -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/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.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/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@ -254,6 +255,7 @@ final assetsProvider =
.filter() .filter()
.isArchivedEqualTo(false) .isArchivedEqualTo(false)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.stackParentIdIsNull()
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider); final settings = ref.watch(appSettingsServiceProvider);
final groupBy = final groupBy =
@ -264,10 +266,12 @@ final assetsProvider =
} }
}); });
final remoteAssetsProvider = QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
StreamProvider.family<RenderList, int?>((ref, userId) async* { final userId = ref.watch(currentUserProvider)?.isarId;
if (userId == null) return; if (userId == null) {
final query = ref return null;
}
return ref
.watch(dbProvider) .watch(dbProvider)
.assets .assets
.where() .where()
@ -275,12 +279,34 @@ final remoteAssetsProvider =
.filter() .filter()
.ownerIdEqualTo(userId) .ownerIdEqualTo(userId)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.stackParentIdIsNull()
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider); }
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; QueryBuilder<Asset, Asset, QAfterSortBy>? getAssetStackSelectionQuery(
yield await RenderList.fromQuery(query, groupBy); WidgetRef ref,
await for (final _ in query.watchLazy()) { Asset parentAsset,
yield await RenderList.fromQuery(query, groupBy); ) {
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();
}

View file

@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_delete', _handleOnAssetDelete); socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates); socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_restore', _handleServerUpdates); socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
} catch (e) { } catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
} }

View file

@ -21,6 +21,7 @@ class ApiService {
late PartnerApi partnerApi; late PartnerApi partnerApi;
late PersonApi personApi; late PersonApi personApi;
late AuditApi auditApi; late AuditApi auditApi;
late SharedLinkApi sharedLinkApi;
ApiService() { ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint); final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@ -45,6 +46,7 @@ class ApiService {
partnerApi = PartnerApi(_apiClient); partnerApi = PartnerApi(_apiClient);
personApi = PersonApi(_apiClient); personApi = PersonApi(_apiClient);
auditApi = AuditApi(_apiClient); auditApi = AuditApi(_apiClient);
sharedLinkApi = SharedLinkApi(_apiClient);
} }
Future<String> resolveAndSetEndpoint(String serverUrl) async { Future<String> resolveAndSetEndpoint(String serverUrl) async {

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'api.service.dart'; import 'api.service.dart';
@ -13,32 +14,60 @@ final shareServiceProvider =
class ShareService { class ShareService {
final ApiService _apiService; final ApiService _apiService;
final Logger _log = Logger("ShareService");
ShareService(this._apiService); ShareService(this._apiService);
Future<void> shareAsset(Asset asset) async { Future<bool> shareAsset(Asset asset) async {
await shareAssets([asset]); return await shareAssets([asset]);
} }
Future<void> shareAssets(List<Asset> assets) async { Future<bool> shareAssets(List<Asset> assets) async {
final downloadedXFiles = assets.map<Future<XFile>>((asset) async { try {
if (asset.isRemote) { final downloadedXFiles = <XFile>[];
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);
}
});
Share.shareXFiles( for (var asset in assets) {
await Future.wait(downloadedXFiles), if (asset.isRemote) {
sharePositionOrigin: Rect.zero, 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;
} }
} }

View file

@ -41,7 +41,12 @@ ThemeData immichLightTheme = ThemeData(
fontFamily: 'WorkSans', fontFamily: 'WorkSans',
scaffoldBackgroundColor: immichBackgroundColor, scaffoldBackgroundColor: immichBackgroundColor,
snackBarTheme: const SnackBarThemeData( snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'), contentTextStyle: TextStyle(
fontFamily: 'WorkSans',
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
backgroundColor: Colors.white,
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
titleTextStyle: const TextStyle( titleTextStyle: const TextStyle(
@ -156,8 +161,13 @@ ThemeData immichDarkTheme = ThemeData(
scaffoldBackgroundColor: immichDarkBackgroundColor, scaffoldBackgroundColor: immichDarkBackgroundColor,
hintColor: Colors.grey[600], hintColor: Colors.grey[600],
fontFamily: 'WorkSans', fontFamily: 'WorkSans',
snackBarTheme: const SnackBarThemeData( snackBarTheme: SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'), contentTextStyle: TextStyle(
fontFamily: 'WorkSans',
color: immichDarkThemePrimaryColor,
fontWeight: FontWeight.bold,
),
backgroundColor: Colors.grey[900],
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(

View file

@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -15,10 +16,19 @@ void handleShareAssets(
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext buildContext) { builder: (BuildContext buildContext) {
ref ref.watch(shareServiceProvider).shareAssets(selection.toList()).then(
.watch(shareServiceProvider) (bool status) {
.shareAssets(selection.toList()) if (!status) {
.then((_) => Navigator.of(buildContext).pop()); 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(); return const ShareDialog();
}, },
barrierDismissible: false, barrierDismissible: false,

View file

@ -1,3 +1,5 @@
import 'package:immich_mobile/shared/models/store.dart';
String sanitizeUrl(String url) { String sanitizeUrl(String url) {
// Add schema if none is set // Add schema if none is set
final urlWithSchema = final urlWithSchema =
@ -6,3 +8,15 @@ String sanitizeUrl(String url) {
// Remove trailing slash(es) // Remove trailing slash(es)
return urlWithSchema.replaceFirst(RegExp(r"/+$"), ""); 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}";
}

View file

@ -149,6 +149,7 @@ doc/TranscodePolicy.md
doc/UpdateAlbumDto.md doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md doc/UpdateAssetDto.md
doc/UpdateLibraryDto.md doc/UpdateLibraryDto.md
doc/UpdateStackParentDto.md
doc/UpdateTagDto.md doc/UpdateTagDto.md
doc/UpdateUserDto.md doc/UpdateUserDto.md
doc/UsageByUserDto.md doc/UsageByUserDto.md
@ -314,6 +315,7 @@ lib/model/transcode_policy.dart
lib/model/update_album_dto.dart lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart lib/model/update_asset_dto.dart
lib/model/update_library_dto.dart lib/model/update_library_dto.dart
lib/model/update_stack_parent_dto.dart
lib/model/update_tag_dto.dart lib/model/update_tag_dto.dart
lib/model/update_user_dto.dart lib/model/update_user_dto.dart
lib/model/usage_by_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_album_dto_test.dart
test/update_asset_dto_test.dart test/update_asset_dto_test.dart
test/update_library_dto_test.dart test/update_library_dto_test.dart
test/update_stack_parent_dto_test.dart
test/update_tag_dto_test.dart test/update_tag_dto_test.dart
test/update_user_dto_test.dart test/update_user_dto_test.dart
test/usage_by_user_dto_test.dart test/usage_by_user_dto_test.dart

View file

@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: 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 - Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements ## Requirements
@ -116,6 +116,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} |
*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} |
*AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset | *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 | *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
*AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | *AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix |
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
@ -330,6 +331,7 @@ Class | Method | HTTP request | Description
- [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UpdateStackParentDto](doc//UpdateStackParentDto.md)
- [UpdateTagDto](doc//UpdateTagDto.md) - [UpdateTagDto](doc//UpdateTagDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md) - [UpdateUserDto](doc//UpdateUserDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md) - [UsageByUserDto](doc//UsageByUserDto.md)

View file

@ -38,6 +38,7 @@ Method | HTTP request | Description
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} |
[**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} |
[**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset | [**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset |
[**updateStackParent**](AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent |
[**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | [**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) [[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<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('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** # **uploadFile**
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData) > AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)

View file

@ -11,6 +11,8 @@ Name | Type | Description | Notes
**ids** | **List<String>** | | [default to const []] **ids** | **List<String>** | | [default to const []]
**isArchived** | **bool** | | [optional] **isArchived** | **bool** | | [optional]
**isFavorite** | **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) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -33,6 +33,9 @@ Name | Type | Description | Notes
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []] **people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
**resized** | **bool** | | **resized** | **bool** | |
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional] **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
**stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [optional] [default to const []]
**stackCount** | **int** | |
**stackParentId** | **String** | | [optional]
**tags** | [**List<TagResponseDto>**](TagResponseDto.md) | | [optional] [default to const []] **tags** | [**List<TagResponseDto>**](TagResponseDto.md) | | [optional] [default to const []]
**thumbhash** | **String** | | **thumbhash** | **String** | |
**type** | [**AssetTypeEnum**](AssetTypeEnum.md) | | **type** | [**AssetTypeEnum**](AssetTypeEnum.md) | |

View file

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**allowDownload** | **bool** | | [optional] **allowDownload** | **bool** | | [optional]
**allowUpload** | **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] **description** | **String** | | [optional]
**expiresAt** | [**DateTime**](DateTime.md) | | [optional] **expiresAt** | [**DateTime**](DateTime.md) | | [optional]
**showMetadata** | **bool** | | [optional] **showMetadata** | **bool** | | [optional]

View file

@ -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)

View file

@ -176,6 +176,7 @@ part 'model/transcode_policy.dart';
part 'model/update_album_dto.dart'; part 'model/update_album_dto.dart';
part 'model/update_asset_dto.dart'; part 'model/update_asset_dto.dart';
part 'model/update_library_dto.dart'; part 'model/update_library_dto.dart';
part 'model/update_stack_parent_dto.dart';
part 'model/update_tag_dto.dart'; part 'model/update_tag_dto.dart';
part 'model/update_user_dto.dart'; part 'model/update_user_dto.dart';
part 'model/usage_by_user_dto.dart'; part 'model/usage_by_user_dto.dart';

View file

@ -1654,6 +1654,45 @@ class AssetApi {
} }
} }
/// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response].
/// Parameters:
///
/// * [UpdateStackParentDto] updateStackParentDto (required):
Future<Response> updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/stack/parent';
// ignore: prefer_final_locals
Object? postBody = updateStackParentDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [UpdateStackParentDto] updateStackParentDto (required):
Future<void> 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]. /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View file

@ -443,6 +443,8 @@ class ApiClient {
return UpdateAssetDto.fromJson(value); return UpdateAssetDto.fromJson(value);
case 'UpdateLibraryDto': case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value); return UpdateLibraryDto.fromJson(value);
case 'UpdateStackParentDto':
return UpdateStackParentDto.fromJson(value);
case 'UpdateTagDto': case 'UpdateTagDto':
return UpdateTagDto.fromJson(value); return UpdateTagDto.fromJson(value);
case 'UpdateUserDto': case 'UpdateUserDto':

View file

@ -16,6 +16,8 @@ class AssetBulkUpdateDto {
this.ids = const [], this.ids = const [],
this.isArchived, this.isArchived,
this.isFavorite, this.isFavorite,
this.removeParent,
this.stackParentId,
}); });
List<String> ids; List<String> ids;
@ -36,21 +38,41 @@ class AssetBulkUpdateDto {
/// ///
bool? isFavorite; 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 @override
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
other.ids == ids && other.ids == ids &&
other.isArchived == isArchived && other.isArchived == isArchived &&
other.isFavorite == isFavorite; other.isFavorite == isFavorite &&
other.removeParent == removeParent &&
other.stackParentId == stackParentId;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(ids.hashCode) + (ids.hashCode) +
(isArchived == null ? 0 : isArchived!.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 @override
String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]'; String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, removeParent=$removeParent, stackParentId=$stackParentId]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -65,6 +87,16 @@ class AssetBulkUpdateDto {
} else { } else {
// json[r'isFavorite'] = null; // 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; return json;
} }
@ -81,6 +113,8 @@ class AssetBulkUpdateDto {
: const [], : const [],
isArchived: mapValueOfType<bool>(json, r'isArchived'), isArchived: mapValueOfType<bool>(json, r'isArchived'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
removeParent: mapValueOfType<bool>(json, r'removeParent'),
stackParentId: mapValueOfType<String>(json, r'stackParentId'),
); );
} }
return null; return null;

View file

@ -38,6 +38,9 @@ class AssetResponseDto {
this.people = const [], this.people = const [],
required this.resized, required this.resized,
this.smartInfo, this.smartInfo,
this.stack = const [],
required this.stackCount,
this.stackParentId,
this.tags = const [], this.tags = const [],
required this.thumbhash, required this.thumbhash,
required this.type, required this.type,
@ -113,6 +116,12 @@ class AssetResponseDto {
/// ///
SmartInfoResponseDto? smartInfo; SmartInfoResponseDto? smartInfo;
List<AssetResponseDto> stack;
int stackCount;
String? stackParentId;
List<TagResponseDto> tags; List<TagResponseDto> tags;
String? thumbhash; String? thumbhash;
@ -148,6 +157,9 @@ class AssetResponseDto {
other.people == people && other.people == people &&
other.resized == resized && other.resized == resized &&
other.smartInfo == smartInfo && other.smartInfo == smartInfo &&
other.stack == stack &&
other.stackCount == stackCount &&
other.stackParentId == stackParentId &&
other.tags == tags && other.tags == tags &&
other.thumbhash == thumbhash && other.thumbhash == thumbhash &&
other.type == type && other.type == type &&
@ -181,13 +193,16 @@ class AssetResponseDto {
(people.hashCode) + (people.hashCode) +
(resized.hashCode) + (resized.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) +
(stack.hashCode) +
(stackCount.hashCode) +
(stackParentId == null ? 0 : stackParentId!.hashCode) +
(tags.hashCode) + (tags.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) + (type.hashCode) +
(updatedAt.hashCode); (updatedAt.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -231,6 +246,13 @@ class AssetResponseDto {
json[r'smartInfo'] = this.smartInfo; json[r'smartInfo'] = this.smartInfo;
} else { } else {
// json[r'smartInfo'] = null; // 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; json[r'tags'] = this.tags;
if (this.thumbhash != null) { if (this.thumbhash != null) {
@ -276,6 +298,9 @@ class AssetResponseDto {
people: PersonResponseDto.listFromJson(json[r'people']), people: PersonResponseDto.listFromJson(json[r'people']),
resized: mapValueOfType<bool>(json, r'resized')!, resized: mapValueOfType<bool>(json, r'resized')!,
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
stack: AssetResponseDto.listFromJson(json[r'stack']),
stackCount: mapValueOfType<int>(json, r'stackCount')!,
stackParentId: mapValueOfType<String>(json, r'stackParentId'),
tags: TagResponseDto.listFromJson(json[r'tags']), tags: TagResponseDto.listFromJson(json[r'tags']),
thumbhash: mapValueOfType<String>(json, r'thumbhash'), thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!, type: AssetTypeEnum.fromJson(json[r'type'])!,
@ -347,6 +372,7 @@ class AssetResponseDto {
'originalPath', 'originalPath',
'ownerId', 'ownerId',
'resized', 'resized',
'stackCount',
'thumbhash', 'thumbhash',
'type', 'type',
'updatedAt', 'updatedAt',

View file

@ -15,6 +15,7 @@ class SharedLinkEditDto {
SharedLinkEditDto({ SharedLinkEditDto({
this.allowDownload, this.allowDownload,
this.allowUpload, this.allowUpload,
this.changeExpiryTime,
this.description, this.description,
this.expiresAt, this.expiresAt,
this.showMetadata, this.showMetadata,
@ -36,6 +37,15 @@ class SharedLinkEditDto {
/// ///
bool? allowUpload; 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 /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
@ -58,6 +68,7 @@ class SharedLinkEditDto {
bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto && bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
other.allowDownload == allowDownload && other.allowDownload == allowDownload &&
other.allowUpload == allowUpload && other.allowUpload == allowUpload &&
other.changeExpiryTime == changeExpiryTime &&
other.description == description && other.description == description &&
other.expiresAt == expiresAt && other.expiresAt == expiresAt &&
other.showMetadata == showMetadata; other.showMetadata == showMetadata;
@ -67,12 +78,13 @@ class SharedLinkEditDto {
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(allowDownload == null ? 0 : allowDownload!.hashCode) + (allowDownload == null ? 0 : allowDownload!.hashCode) +
(allowUpload == null ? 0 : allowUpload!.hashCode) + (allowUpload == null ? 0 : allowUpload!.hashCode) +
(changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) +
(description == null ? 0 : description!.hashCode) + (description == null ? 0 : description!.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) +
(showMetadata == null ? 0 : showMetadata!.hashCode); (showMetadata == null ? 0 : showMetadata!.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -86,6 +98,11 @@ class SharedLinkEditDto {
} else { } else {
// json[r'allowUpload'] = null; // json[r'allowUpload'] = null;
} }
if (this.changeExpiryTime != null) {
json[r'changeExpiryTime'] = this.changeExpiryTime;
} else {
// json[r'changeExpiryTime'] = null;
}
if (this.description != null) { if (this.description != null) {
json[r'description'] = this.description; json[r'description'] = this.description;
} else { } else {
@ -114,6 +131,7 @@ class SharedLinkEditDto {
return SharedLinkEditDto( return SharedLinkEditDto(
allowDownload: mapValueOfType<bool>(json, r'allowDownload'), allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
allowUpload: mapValueOfType<bool>(json, r'allowUpload'), allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
changeExpiryTime: mapValueOfType<bool>(json, r'changeExpiryTime'),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
expiresAt: mapDateTime(json, r'expiresAt', ''), expiresAt: mapDateTime(json, r'expiresAt', ''),
showMetadata: mapValueOfType<bool>(json, r'showMetadata'), showMetadata: mapValueOfType<bool>(json, r'showMetadata'),

View file

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return UpdateStackParentDto(
newParentId: mapValueOfType<String>(json, r'newParentId')!,
oldParentId: mapValueOfType<String>(json, r'oldParentId')!,
);
}
return null;
}
static List<UpdateStackParentDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateStackParentDto>[];
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<String, UpdateStackParentDto> mapFromJson(dynamic json) {
final map = <String, UpdateStackParentDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<UpdateStackParentDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateStackParentDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'newParentId',
'oldParentId',
};
}

View file

@ -174,6 +174,11 @@ void main() {
// TODO // TODO
}); });
//Future updateStackParent(UpdateStackParentDto updateStackParentDto) async
test('test updateStackParent', () async {
// TODO
});
//Future<AssetFileUploadResponseDto> 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 //Future<AssetFileUploadResponseDto> 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 { test('test uploadFile', () async {
// TODO // TODO

View file

@ -31,6 +31,16 @@ void main() {
// TODO // TODO
}); });
// bool removeParent
test('to test the property `removeParent`', () async {
// TODO
});
// String stackParentId
test('to test the property `stackParentId`', () async {
// TODO
});
}); });

View file

@ -142,6 +142,21 @@ void main() {
// TODO // TODO
}); });
// List<AssetResponseDto> 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<TagResponseDto> tags (default value: const []) // List<TagResponseDto> tags (default value: const [])
test('to test the property `tags`', () async { test('to test the property `tags`', () async {
// TODO // TODO

View file

@ -26,6 +26,12 @@ void main() {
// TODO // 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 // String description
test('to test the property `description`', () async { test('to test the property `description`', () async {
// TODO // TODO

View file

@ -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
});
});
}

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.82.0+106 version: 1.82.1+106
isar_version: &isar_version 3.1.0+1 isar_version: &isar_version 3.1.0+1
environment: environment:

View file

@ -25,6 +25,7 @@ void main() {
isFavorite: false, isFavorite: false,
isArchived: false, isArchived: false,
isTrashed: false, isTrashed: false,
stackCount: 0,
), ),
); );
} }

View file

@ -35,6 +35,7 @@ void main() {
isFavorite: false, isFavorite: false,
isArchived: false, isArchived: false,
isTrashed: false, isTrashed: false,
stackCount: 0,
); );
} }

View file

@ -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": { "/asset/statistics": {
"get": { "get": {
"operationId": "getAssetStats", "operationId": "getAssetStats",
@ -5379,7 +5414,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.82.0", "version": "1.82.1",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@ -5696,6 +5731,13 @@
}, },
"isFavorite": { "isFavorite": {
"type": "boolean" "type": "boolean"
},
"removeParent": {
"type": "boolean"
},
"stackParentId": {
"format": "uuid",
"type": "string"
} }
}, },
"required": [ "required": [
@ -5941,6 +5983,19 @@
"smartInfo": { "smartInfo": {
"$ref": "#/components/schemas/SmartInfoResponseDto" "$ref": "#/components/schemas/SmartInfoResponseDto"
}, },
"stack": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
},
"stackCount": {
"type": "integer"
},
"stackParentId": {
"nullable": true,
"type": "string"
},
"tags": { "tags": {
"items": { "items": {
"$ref": "#/components/schemas/TagResponseDto" "$ref": "#/components/schemas/TagResponseDto"
@ -5961,6 +6016,7 @@
}, },
"required": [ "required": [
"type", "type",
"stackCount",
"deviceAssetId", "deviceAssetId",
"deviceId", "deviceId",
"ownerId", "ownerId",
@ -7846,6 +7902,10 @@
"allowUpload": { "allowUpload": {
"type": "boolean" "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": { "description": {
"type": "string" "type": "string"
}, },
@ -8521,6 +8581,23 @@
}, },
"type": "object" "type": "object"
}, },
"UpdateStackParentDto": {
"properties": {
"newParentId": {
"format": "uuid",
"type": "string"
},
"oldParentId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"oldParentId",
"newParentId"
],
"type": "object"
},
"UpdateTagDto": { "UpdateTagDto": {
"properties": { "properties": {
"name": { "name": {

View file

@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.82.0", "version": "1.82.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.82.0", "version": "1.82.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.22.11", "@babel/runtime": "^7.22.11",

View file

@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.82.0", "version": "1.82.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -26,7 +26,7 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "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": "typeorm",
"typeorm:migrations:create": "typeorm migration:create", "typeorm:migrations:create": "typeorm migration:create",
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js", "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",

View file

@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { import {
albumStub, albumStub,
assetStub,
authStub, authStub,
IAccessRepositoryMock, IAccessRepositoryMock,
newAccessRepositoryMock, newAccessRepositoryMock,
@ -225,7 +224,7 @@ describe(AlbumService.name, () => {
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).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(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
@ -461,6 +460,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect( await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -473,9 +473,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1', 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 () => { it('should not set the thumbnail if the album has one already', async () => {
@ -490,9 +493,9 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
assets: [{ id: 'asset-1' }],
albumThumbnailAssetId: 'asset-id', albumThumbnailAssetId: 'asset-id',
}); });
expect(albumMock.addAssets).toHaveBeenCalled();
}); });
it('should allow a shared user to add assets', async () => { it('should allow a shared user to add assets', async () => {
@ -512,9 +515,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1', 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 () => { it('should allow a shared link user to add assets', async () => {
@ -523,6 +529,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect( await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -535,9 +542,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId, authStub.adminSharedLink.sharedLinkId,
@ -550,6 +560,7 @@ describe(AlbumService.name, () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true); accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' }, { success: true, id: 'asset-1' },
@ -558,10 +569,8 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }],
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, '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.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
@ -620,17 +630,14 @@ describe(AlbumService.name, () => {
it('should allow the owner to remove assets', async () => { it('should allow the owner to remove assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' }, { success: true, id: 'asset-id' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) });
id: 'album-123', expect(albumMock.removeAssets).toHaveBeenCalledWith({ assetIds: ['asset-id'], albumId: 'album-123' });
updatedAt: expect.any(Date),
assets: [],
albumThumbnailAssetId: null,
});
}); });
it('should skip assets not in the album', async () => { 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 () => { it('should skip assets without user permission to remove', async () => {
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ 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(); expect(albumMock.update).not.toHaveBeenCalled();
@ -658,6 +670,7 @@ describe(AlbumService.name, () => {
it('should reset the thumbnail if it is removed', async () => { it('should reset the thumbnail if it is removed', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' }, { success: true, id: 'asset-id' },
@ -666,9 +679,8 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
assets: [assetStub.withLocation],
albumThumbnailAssetId: assetStub.withLocation.id,
}); });
expect(albumMock.updateThumbnails).toHaveBeenCalled();
}); });
}); });

View file

@ -120,7 +120,7 @@ export class AlbumService {
const album = await this.findOrFail(id, { withAssets: true }); const album = await this.findOrFail(id, { withAssets: true });
if (dto.albumThumbnailAssetId) { 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) { if (!valid) {
throw new BadRequestException('Invalid album thumbnail'); throw new BadRequestException('Invalid album thumbnail');
} }
@ -148,35 +148,34 @@ export class AlbumService {
} }
async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
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); await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = []; const results: BulkIdResponseDto[] = [];
for (const id of dto.ids) { for (const assetId of dto.ids) {
const hasAsset = album.assets.find((asset) => asset.id === id); const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
if (hasAsset) { if (hasAsset) {
results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE }); results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE });
continue; 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) { if (!hasAccess) {
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION }); results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue; continue;
} }
results.push({ id, success: true }); results.push({ id: assetId, success: true });
album.assets.push({ id } as AssetEntity);
} }
const newAsset = results.find(({ success }) => success); const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
if (newAsset) { if (newAssetIds.length > 0) {
await this.albumRepository.addAssets({ albumId: id, assetIds: newAssetIds });
await this.albumRepository.update({ await this.albumRepository.update({
id, id,
assets: album.assets,
updatedAt: new Date(), 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<BulkIdResponseDto[]> { async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
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); await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = []; const results: BulkIdResponseDto[] = [];
for (const id of dto.ids) { for (const assetId of dto.ids) {
const hasAsset = album.assets.find((asset) => asset.id === id); const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
if (!hasAsset) { if (!hasAsset) {
results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND }); results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue; continue;
} }
const hasAccess = await this.access.hasAny(authUser, [ const hasAccess = await this.access.hasAny(authUser, [
{ permission: Permission.ALBUM_REMOVE_ASSET, id }, { permission: Permission.ALBUM_REMOVE_ASSET, id: assetId },
{ permission: Permission.ASSET_SHARE, id }, { permission: Permission.ASSET_SHARE, id: assetId },
]); ]);
if (!hasAccess) { if (!hasAccess) {
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION }); results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue; continue;
} }
results.push({ id, success: true }); results.push({ id: assetId, success: true });
album.assets = album.assets.filter((asset) => asset.id !== id);
if (album.albumThumbnailAssetId === id) {
album.albumThumbnailAssetId = null;
}
} }
const hasSuccess = results.find(({ success }) => success); const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (hasSuccess) { if (removedIds.length > 0) {
await this.albumRepository.update({ await this.albumRepository.removeAssets({ albumId: id, assetIds: removedIds });
id, await this.albumRepository.update({ id, updatedAt: new Date() });
assets: album.assets, if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
updatedAt: new Date(), await this.albumRepository.updateThumbnails();
albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null, }
});
} }
return results; return results;

View file

@ -20,6 +20,7 @@ import { Readable } from 'stream';
import { JobName } from '../job'; import { JobName } from '../job';
import { import {
AssetStats, AssetStats,
CommunicationEvent,
IAssetRepository, IAssetRepository,
ICommunicationRepository, ICommunicationRepository,
ICryptoRepository, ICryptoRepository,
@ -636,10 +637,89 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['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', () => { 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); accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.deleteAll(authStub.user1, { sut.deleteAll(authStub.user1, {
@ -677,7 +757,7 @@ describe(AssetService.name, () => {
}); });
describe('restoreAll', () => { 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); accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.deleteAll(authStub.user1, { sut.deleteAll(authStub.user1, {
@ -757,6 +837,21 @@ describe(AssetService.name, () => {
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); 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 () => { it('should not schedule delete-files job for readonly assets', async () => {
when(assetMock.getById) when(assetMock.getById)
.calledWith(assetStub.readOnly.id) .calledWith(assetStub.readOnly.id)
@ -854,4 +949,70 @@ describe(AssetService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }); 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' },
);
});
});
}); });

View file

@ -40,6 +40,7 @@ import {
TimeBucketDto, TimeBucketDto,
TrashAction, TrashAction,
UpdateAssetDto, UpdateAssetDto,
UpdateStackParentDto,
mapStats, mapStats,
} from './dto'; } from './dto';
import { import {
@ -208,7 +209,7 @@ export class AssetService {
if (authUser.isShowMetadata) { if (authUser.isShowMetadata) {
return assets.map((asset) => mapAsset(asset)); return assets.map((asset) => mapAsset(asset));
} else { } 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<void> { async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, ...options } = dto; const { ids, removeParent, ...options } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
if (removeParent) {
(options as Partial<AssetEntity>).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.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
await this.assetRepository.updateAll(ids, options); await this.assetRepository.updateAll(ids, options);
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
} }
async handleAssetDeletionCheck() { 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.assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, 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); this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
} }
async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise<void> {
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) { async run(authUser: AuthUserDto, dto: AssetJobsDto) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds); await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);

View file

@ -0,0 +1,9 @@
import { ValidateUUID } from '../../domain.util';
export class UpdateStackParentDto {
@ValidateUUID()
oldParentId!: string;
@ValidateUUID()
newParentId!: string;
}

View file

@ -1,6 +1,6 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator'; import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
import { Optional } from '../../domain.util'; import { Optional, ValidateUUID } from '../../domain.util';
import { BulkIdsDto } from '../response-dto'; import { BulkIdsDto } from '../response-dto';
export class AssetBulkUpdateDto extends BulkIdsDto { export class AssetBulkUpdateDto extends BulkIdsDto {
@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@Optional() @Optional()
@IsBoolean() @IsBoolean()
isArchived?: boolean; isArchived?: boolean;
@Optional()
@ValidateUUID()
stackParentId?: string;
@Optional()
@IsBoolean()
removeParent?: boolean;
} }
export class UpdateAssetDto { export class UpdateAssetDto {

View file

@ -1,4 +1,5 @@
export * from './asset-ids.dto'; export * from './asset-ids.dto';
export * from './asset-stack.dto';
export * from './asset-statistics.dto'; export * from './asset-statistics.dto';
export * from './asset.dto'; export * from './asset.dto';
export * from './download.dto'; export * from './download.dto';

View file

@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
people?: PersonResponseDto[]; people?: PersonResponseDto[];
/**base64 encoded sha1 hash */ /**base64 encoded sha1 hash */
checksum!: string; 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 = { const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id, id: entity.id,
type: entity.type, type: entity.type,
@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
tags: entity.tags?.map(mapTag), tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden), people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'), 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, isExternal: entity.isExternal,
isOffline: entity.isOffline, isOffline: entity.isOffline,
isReadOnly: entity.isReadOnly, isReadOnly: entity.isReadOnly,

View file

@ -409,6 +409,54 @@ describe(MetadataService.name, () => {
localDateTime: new Date('1970-01-01'), 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', () => { describe('handleQueueSidecar', () => {

View file

@ -7,6 +7,7 @@ import { Duration } from 'luxon';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
import { import {
ExifDuration,
IAlbumRepository, IAlbumRepository,
IAssetRepository, IAssetRepository,
ICryptoRepository, ICryptoRepository,
@ -109,6 +110,10 @@ export class MetadataService {
} }
} }
async teardown() {
await this.repository.teardown();
}
async handleLivePhotoLinking(job: IEntityJob) { async handleLivePhotoLinking(job: IEntityJob) {
const { id } = job; const { id } = job;
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
@ -394,7 +399,11 @@ export class MetadataService {
return bitsPerSample; return bitsPerSample;
} }
private getDuration(seconds?: number): string { private getDuration(seconds?: number | ExifDuration): string {
return Duration.fromObject({ seconds }).toFormat('hh:mm:ss.SSS'); 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');
} }
} }

View file

@ -11,13 +11,24 @@ export interface AlbumInfoOptions {
withAssets: boolean; withAssets: boolean;
} }
export interface AlbumAsset {
albumId: string;
assetId: string;
}
export interface AlbumAssets {
albumId: string;
assetIds: string[];
}
export interface IAlbumRepository { export interface IAlbumRepository {
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>; getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>;
getByIds(ids: string[]): Promise<AlbumEntity[]>; getByIds(ids: string[]): Promise<AlbumEntity[]>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>; getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
hasAsset(id: string, assetId: string): Promise<boolean>; addAssets(assets: AlbumAssets): Promise<void>;
/** Remove an asset from _all_ albums */ hasAsset(asset: AlbumAsset): Promise<boolean>;
removeAsset(id: string): Promise<void>; removeAsset(assetId: string): Promise<void>;
removeAssets(assets: AlbumAssets): Promise<void>;
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>; getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
getInvalidThumbnail(): Promise<string[]>; getInvalidThumbnail(): Promise<string[]>;
getOwned(ownerId: string): Promise<AlbumEntity[]>; getOwned(ownerId: string): Promise<AlbumEntity[]>;

View file

@ -4,6 +4,7 @@ export enum CommunicationEvent {
UPLOAD_SUCCESS = 'on_upload_success', UPLOAD_SUCCESS = 'on_upload_success',
ASSET_DELETE = 'on_asset_delete', ASSET_DELETE = 'on_asset_delete',
ASSET_TRASH = 'on_asset_trash', ASSET_TRASH = 'on_asset_trash',
ASSET_UPDATE = 'on_asset_update',
ASSET_RESTORE = 'on_asset_restore', ASSET_RESTORE = 'on_asset_restore',
PERSON_THUMBNAIL = 'on_person_thumbnail', PERSON_THUMBNAIL = 'on_person_thumbnail',
SERVER_VERSION = 'on_server_version', SERVER_VERSION = 'on_server_version',

View file

@ -14,7 +14,12 @@ export interface ReverseGeocodeResult {
city: string | null; city: string | null;
} }
export interface ImmichTags extends Omit<Tags, 'FocalLength'> { export interface ExifDuration {
Value: number;
Scale?: number;
}
export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration'> {
ContentIdentifier?: string; ContentIdentifier?: string;
MotionPhoto?: number; MotionPhoto?: number;
MotionPhotoVersion?: number; MotionPhotoVersion?: number;
@ -22,10 +27,12 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength'> {
MediaGroupUUID?: string; MediaGroupUUID?: string;
ImagePixelDepth?: string; ImagePixelDepth?: string;
FocalLength?: number; FocalLength?: number;
Duration?: number | ExifDuration;
} }
export interface IMetadataRepository { export interface IMetadataRepository {
init(options: Partial<InitOptions>): Promise<void>; init(options: Partial<InitOptions>): Promise<void>;
teardown(): Promise<void>;
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>; reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
deleteCache(): Promise<void>; deleteCache(): Promise<void>;
getExifTags(path: string): Promise<ImmichTags | null>; getExifTags(path: string): Promise<ImmichTags | null>;

Some files were not shown because too many files have changed in this diff Show more