Compare commits

...

26 Commits

Author SHA1 Message Date
Zack Pollard
eabf535246 wip: pmtiles server 2024-09-24 13:05:19 +01:00
Zack Pollard
b0947bf9b9 feat: serve map tile styles from tiles.immich.cloud (#12858)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2024-09-24 13:05:17 +01:00
Daniel Dietzler
5ea3b5a5c1 fix: open api (#12878) 2024-09-24 13:05:15 +01:00
Jason Rasmussen
ded18f3b6b refactor(mobile): open api dto upgrade (#12793) 2024-09-24 13:05:15 +01:00
Jason Rasmussen
3724dc465b fix: remove no longer needed LD_LIBRARY_PATH (#12872) 2024-09-24 13:05:15 +01:00
Daniel Dietzler
91b07fbac8 fix: show asset count for unassigned faces (#12871) 2024-09-24 13:05:15 +01:00
Zack Pollard
fe84f1cc90 wip: local tileserver 2024-09-23 23:10:45 +01:00
Jason Rasmussen
e748945b4f fix(server): gracefully handle unknown jobs (#12870) 2024-09-23 17:22:36 +00:00
jschwalbe
9f8a7e0bea feat(server): sort assets randomly from the API 'api/search/metadata' endpoint by including 'order': 'rand' in the API call. (#12741)
feat(server): search metadata random sort order

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2024-09-23 12:09:26 -04:00
Daniel Dietzler
a7719a94fc fix: normalize external domain (#12831)
chore: normalize external domain
2024-09-23 15:40:25 +00:00
Caesiumhydroxid
9a4a320cfb fix(web): Fix same key for delete and stack actions (#12865)
Fix same key for delete and stack actions
2024-09-23 15:38:50 +00:00
Jason Rasmussen
0cce7ebf25 fix: web e2e (#12869) 2024-09-23 15:16:25 +00:00
Nuno Antunes
b1cdf73a24 feat(server): validate rating (#12855)
* feat(server): validate exif rating tag

* fix(server): change allowed range for rating

* refactor: better readibility

* docs: comments

* remove log line
2024-09-23 07:50:18 +00:00
kurama
147747de32 docs: add section for Traefik Reverse Proxy (#12813)
* added a section for the Traefik Proxy

* minimized the configs

* replaced config with a comment.

* Update docs/docs/administration/reverse-proxy.md

changed timeout values

Co-authored-by: dvbthien <89862334+dvbthien@users.noreply.github.com>

* changed timeouts back to 10 minutes

* fixed typo and set default writeTimeout 600s

Leaving it at 0 may be also bad practice

* removed whitespace

* run `npm run format -- --check -w`

---------

Co-authored-by: dvbthien <89862334+dvbthien@users.noreply.github.com>
2024-09-23 14:40:23 +07:00
Fynn Petersen-Frey
9abfa6940c docs: mobile architecture diagram (#12841) 2024-09-23 06:11:23 +02:00
Fynn Petersen-Frey
39ea73d654 chore(mobile): restrict isar use via CI checks (#12840) 2024-09-22 09:24:08 -04:00
renovate[bot]
7c1ea2dc73 chore(deps): update dependency flutter to v3.24.3 (#11738)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-22 07:29:30 +07:00
Alex
14169d310a fix(mobile): fix uncaught error in getting file cause hashing procses to be aborted entirely (#12826)
* fix(mobile): fix uncaught error in getting file cause hashing procses to be aborted entirely

* log error
2024-09-21 00:29:07 +00:00
Zack Pollard
5a1a841365 fix: rework file handling so we always explicitly create, overwrite or both (#12812) 2024-09-20 23:16:53 +00:00
Shubham
af70111645 fix(mobile): Issue Selecting Many Albuns for Backup (#12784)
* Update backup.provider.dart

* Revert "Update backup.provider.dart"

This reverts commit ac2b7acef9.

* Reapply "Update backup.provider.dart"

This reverts commit c9fe934b3b.

* dart formatting
2024-09-21 06:01:26 +07:00
Daniel Dietzler
8cd3f6b884 fix(web): events as props (#12825) 2024-09-20 18:24:46 -04:00
Daniel Dietzler
124eb8251b chore: migrate away from event dispatcher (#12820) 2024-09-20 17:02:58 -04:00
renovate[bot]
529d49471f fix(deps): update machine-learning (#12747) 2024-09-20 10:09:15 -04:00
Fynn Petersen-Frey
3868736799 refactor(mobile): album api repository for album service (#12791)
* refactor(mobile): album api repository for album service
2024-09-20 13:32:37 +00:00
Jason Rasmussen
94fc1f213a refactor(web): migrate away from event dispatcher (#12802) 2024-09-19 18:20:09 -04:00
Jason Rasmussen
cfc575d89c chore(web): remove stray dateTimeOriginal reference (#12796) 2024-09-19 17:06:51 -04:00
386 changed files with 9914 additions and 2311 deletions

View File

@@ -25,6 +25,7 @@ server/upload/
server/src/queries
server/dist/
server/www/
server/resources/v1.pmtiles
web/node_modules/
web/coverage/

View File

@@ -24,6 +24,7 @@ services:
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
- ../server/resources/v1.pmtiles:/usr/src/app/resources/v1.pmtiles
env_file:
- .env
environment:

View File

@@ -51,5 +51,4 @@ services:
volumes:
- /usr/lib/wsl:/usr/lib/wsl
environment:
- LD_LIBRARY_PATH=/usr/lib/wsl/lib
- LIBVA_DRIVER_NAME=d3d12

View File

@@ -64,3 +64,43 @@ Below is an example config for Apache2 site configuration.
ProxyPreserveHost On
</VirtualHost>
```
### Traefik Proxy example config
The example below is for Traefik version 3.
The most important is to increase the `respondingTimeouts` of the entrypoint used by immich. In this example of entrypoint `websecure` for port `443`. Per default it's set to 60s which leeds to videos stop uploading after 1 minute (Error Code 499). With this config it will fail after 10 minutes which is in most cases enough. Increase it if needed.
`traefik.yaml`
```yaml
[...]
entryPoints:
websecure:
address: :443
# this section needs to be added
transport:
respondingTimeouts:
readTimeout: 600s
idleTimeout: 600s
writeTimeout: 600s
```
The second part is in the `docker-compose.yml` file where immich is in. Add the Traefik specific labels like in the example.
`docker-compose.yml`
```yaml
services:
immich-server:
[...]
labels:
traefik.enable: true
# increase readingTimeouts for the entrypoint used here
traefik.http.routers.immich.entrypoints: websecure
traefik.http.routers.immich.rule: Host(`immich.your-domain.com`)
traefik.http.services.immich.loadbalancer.server.port: 3001
```
Keep in mind, that Traefik needs to communicate with the network where immich is in, usually done
by adding the Traefik network to the `immich-server`.

View File

@@ -3,6 +3,7 @@ sidebar_position: 1
---
import AppArchitecture from './img/app-architecture.png';
import MobileArchitecture from './img/immich_mobile_architecture.svg';
# Architecture
@@ -28,7 +29,14 @@ All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for
### Mobile App
The mobile app is written in [Flutter](https://flutter.dev/). It uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management.
The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://flutter.dev/). Below is an architecture overview:
<MobileArchitecture className="p-4 dark:bg-immich-dark-primary my-4" />
The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture.
Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory.
The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes!
### Web Client

View File

@@ -0,0 +1,104 @@
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" version="24.7.16">
<diagram name="Page-1" id="Bp2gX--FtC4sSMWxsLrs">
<mxGraphModel dx="1728" dy="954" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" pageWidth="850" pageHeight="1100" background="none" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="zHhczcy2-Jv_nqmJUiNH-1" value="" style="verticalLabelPosition=bottom;verticalAlign=top;html=1;shape=mxgraph.basic.polygon;polyCoords=[[0.25,0],[0.75,0],[1,0.25],[1,0.75],[0.75,1],[0.25,1],[0,0.75],[0,0.25]];polyline=0;strokeWidth=4;rounded=1;fillColor=#4251B0;" vertex="1" parent="1">
<mxGeometry x="280" y="217.5" width="465" height="465" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-2" value="&lt;b&gt;&lt;font style=&quot;font-size: 22px;&quot;&gt;Mobile App&lt;/font&gt;&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;rounded=1;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="442.5" y="225" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-25" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-4" target="zHhczcy2-Jv_nqmJUiNH-5">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-4" value="Services" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFB400;" vertex="1" parent="1">
<mxGeometry x="530" y="420" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-26" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-5" target="zHhczcy2-Jv_nqmJUiNH-12">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-5" value="Repositories" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1E83F7;" vertex="1" parent="1">
<mxGeometry x="650" y="420" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-24" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-6" target="zHhczcy2-Jv_nqmJUiNH-4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-6" value="Providers" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ED79B5;" vertex="1" parent="1">
<mxGeometry x="410" y="420" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-29" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-7" target="zHhczcy2-Jv_nqmJUiNH-8">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-30" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.75;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-7" target="zHhczcy2-Jv_nqmJUiNH-6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-7" value="Pages" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FA2921;" vertex="1" parent="1">
<mxGeometry x="290" y="480" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-31" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-8" target="zHhczcy2-Jv_nqmJUiNH-6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-8" value="Widgets" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FA2921;" vertex="1" parent="1">
<mxGeometry x="290" y="360" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-11" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;rounded=1;fillColor=#4251B0;" vertex="1" parent="1">
<mxGeometry x="180" y="368.5" width="81.5" height="163" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-12" value="platform&lt;div&gt;system&lt;/div&gt;" style="rhombus;whiteSpace=wrap;html=1;rounded=1;fillColor=#ED79B5;" vertex="1" parent="1">
<mxGeometry x="800" y="410" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-13" value="on-device&lt;div&gt;database&lt;/div&gt;" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;rounded=1;fillColor=#FA2921;" vertex="1" parent="1">
<mxGeometry x="810" y="310" width="60" height="80" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-14" value="server" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;rounded=1;fillColor=#FFB400;" vertex="1" parent="1">
<mxGeometry x="780" y="500" width="120" height="80" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-16" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.75;exitDx=0;exitDy=0;entryX=0.07;entryY=0.4;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-5" target="zHhczcy2-Jv_nqmJUiNH-14">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-39" value="OpenAPI" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];rounded=1;labelBackgroundColor=#1E83F7;" vertex="1" connectable="0" parent="zHhczcy2-Jv_nqmJUiNH-16">
<mxGeometry x="0.0697" y="1" relative="1" as="geometry">
<mxPoint x="8" y="10" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-23" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.75;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-6" target="zHhczcy2-Jv_nqmJUiNH-6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-27" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.25;exitDx=0;exitDy=0;entryX=0;entryY=1;entryDx=0;entryDy=-15;entryPerimeter=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-5" target="zHhczcy2-Jv_nqmJUiNH-13">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-34" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;dashed=1;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-3">
<mxGeometry relative="1" as="geometry">
<mxPoint x="810" y="360" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-36" value="" style="endArrow=none;dashed=1;html=1;rounded=1;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-9">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="512.08" y="665" as="sourcePoint" />
<mxPoint x="512.08" y="265" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-37" value="UI part" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;fontSize=14;fontColor=#FFFFFF;" vertex="1" parent="1">
<mxGeometry x="387.5" y="640" width="70" height="30" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-38" value="non-UI part" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;fontSize=14;fontColor=#FFFFFF;" vertex="1" parent="1">
<mxGeometry x="550" y="640" width="90" height="30" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-41" value="" style="endArrow=none;dashed=1;html=1;rounded=1;" edge="1" parent="1" target="zHhczcy2-Jv_nqmJUiNH-9">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="512.08" y="665" as="sourcePoint" />
<mxPoint x="512.08" y="265" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-9" value="Models" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#18C249;" vertex="1" parent="1">
<mxGeometry x="470" y="510" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-3" value="Entities" style="rounded=1;whiteSpace=wrap;html=1;gradientColor=none;fillColor=#18C249;" vertex="1" parent="1">
<mxGeometry x="472.5" y="330" width="80" height="60" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -22,7 +22,6 @@ services:
- IMMICH_METRICS=true
- IMMICH_ENV=testing
volumes:
- upload:/usr/src/app/upload
- ./test-assets:/test-assets
extra_hosts:
- 'auth-server:host-gateway'
@@ -44,7 +43,3 @@ services:
POSTGRES_DB: immich
ports:
- 5435:5432
volumes:
model-cache:
upload:

6
e2e/package-lock.json generated
View File

@@ -5016,9 +5016,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz",
"integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"dev": true
},
"node_modules/pathe": {

View File

@@ -53,8 +53,10 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'docker compose up --build -V --remove-orphans',
command: 'docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans',
url: 'http://127.0.0.1:2285',
stdout: 'pipe',
stderr: 'pipe',
reuseExistingServer: true,
},
});

View File

@@ -1,8 +1,7 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
import { LoginResponseDto } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
@@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/map', () => {
let websocket: Socket;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let asset: AssetMediaResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
websocket = await utils.connectWebsocket(admin.accessToken);
asset = await utils.createAsset(admin.accessToken);
const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
utils.resetEvents();
const uploadFile = async (input: string) => {
@@ -103,63 +97,6 @@ describe('/map', () => {
});
});
describe('GET /map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should allow shared link access', async () => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark']));
}
});
it('should return the light style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' }));
});
it('should return the dark style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should not require admin authentication', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
});
describe('GET /map/reverse-geocode', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/reverse-geocode');

View File

@@ -128,6 +128,8 @@ describe('/server-info', () => {
isInitialized: true,
externalDomain: '',
isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});
});
});

View File

@@ -134,6 +134,8 @@ describe('/server', () => {
isInitialized: true,
externalDomain: '',
isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});
});
});

View File

@@ -12,7 +12,8 @@ const setup = async () => {
const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000);
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
const command = 'compose up --build --renew-anon-volumes --force-recreate --remove-orphans';
const child = spawn('docker', command.split(' '), { stdio: 'pipe' });
child.stdout.on('data', (data) => {
const input = data.toString();

View File

@@ -156,8 +156,7 @@ export const utils = {
for (const table of tables) {
if (table === 'system_metadata') {
// prevent reverse geocoder from being re-initialized
sql.push(`DELETE FROM "system_metadata" where "key" != 'reverse-geocoding-state';`);
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
sql.push(`DELETE FROM ${table} CASCADE;`);
}

View File

@@ -1237,13 +1237,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "huggingface-hub"
version = "0.24.6"
version = "0.25.0"
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
optional = false
python-versions = ">=3.8.0"
files = [
{file = "huggingface_hub-0.24.6-py3-none-any.whl", hash = "sha256:a990f3232aa985fe749bc9474060cbad75e8b2f115f6665a9fda5b9c97818970"},
{file = "huggingface_hub-0.24.6.tar.gz", hash = "sha256:cc2579e761d070713eaa9c323e3debe39d5b464ae3a7261c39a9195b27bb8000"},
{file = "huggingface_hub-0.25.0-py3-none-any.whl", hash = "sha256:e2f357b35d72d5012cfd127108c4e14abcd61ba4ebc90a5a374dc2456cb34e12"},
{file = "huggingface_hub-0.25.0.tar.gz", hash = "sha256:fb5fbe6c12fcd99d187ec7db95db9110fb1a20505f23040a5449a717c1a0db4d"},
]
[package.dependencies]
@@ -1531,13 +1531,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
[[package]]
name = "locust"
version = "2.31.5"
version = "2.31.6"
description = "Developer-friendly load testing framework"
optional = false
python-versions = ">=3.9"
files = [
{file = "locust-2.31.5-py3-none-any.whl", hash = "sha256:2904ff6307d54d3202c9ebd776f9170214f6dfbe4059504dad9e3ffaca03f600"},
{file = "locust-2.31.5.tar.gz", hash = "sha256:14b2fa6f95bf248668e6dc92d100a44f06c5dcb1c26f88a5442bcaaee18faceb"},
{file = "locust-2.31.6-py3-none-any.whl", hash = "sha256:004c963c7a588dc15d57d710cdc6a262d85b57936d7fad3c38ac0657aa98fc3b"},
{file = "locust-2.31.6.tar.gz", hash = "sha256:03b6da0491d6a0b905692d9ac128d9deec403f40dc605c481a90dbab5126318c"},
]
[package.dependencies]
@@ -2834,29 +2834,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruff"
version = "0.6.4"
version = "0.6.6"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.6.4-py3-none-linux_armv6l.whl", hash = "sha256:c4b153fc152af51855458e79e835fb6b933032921756cec9af7d0ba2aa01a258"},
{file = "ruff-0.6.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bedff9e4f004dad5f7f76a9d39c4ca98af526c9b1695068198b3bda8c085ef60"},
{file = "ruff-0.6.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d02a4127a86de23002e694d7ff19f905c51e338c72d8e09b56bfb60e1681724f"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7862f42fc1a4aca1ea3ffe8a11f67819d183a5693b228f0bb3a531f5e40336fc"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eebe4ff1967c838a1a9618a5a59a3b0a00406f8d7eefee97c70411fefc353617"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:932063a03bac394866683e15710c25b8690ccdca1cf192b9a98260332ca93408"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:50e30b437cebef547bd5c3edf9ce81343e5dd7c737cb36ccb4fe83573f3d392e"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c44536df7b93a587de690e124b89bd47306fddd59398a0fb12afd6133c7b3818"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ea086601b22dc5e7693a78f3fcfc460cceabfdf3bdc36dc898792aba48fbad6"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b52387d3289ccd227b62102c24714ed75fbba0b16ecc69a923a37e3b5e0aaaa"},
{file = "ruff-0.6.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0308610470fcc82969082fc83c76c0d362f562e2f0cdab0586516f03a4e06ec6"},
{file = "ruff-0.6.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:803b96dea21795a6c9d5bfa9e96127cc9c31a1987802ca68f35e5c95aed3fc0d"},
{file = "ruff-0.6.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:66dbfea86b663baab8fcae56c59f190caba9398df1488164e2df53e216248baa"},
{file = "ruff-0.6.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:34d5efad480193c046c86608dbba2bccdc1c5fd11950fb271f8086e0c763a5d1"},
{file = "ruff-0.6.4-py3-none-win32.whl", hash = "sha256:f0f8968feea5ce3777c0d8365653d5e91c40c31a81d95824ba61d871a11b8523"},
{file = "ruff-0.6.4-py3-none-win_amd64.whl", hash = "sha256:549daccee5227282289390b0222d0fbee0275d1db6d514550d65420053021a58"},
{file = "ruff-0.6.4-py3-none-win_arm64.whl", hash = "sha256:ac4b75e898ed189b3708c9ab3fc70b79a433219e1e87193b4f2b77251d058d14"},
{file = "ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212"},
{file = "ruff-0.6.6-py3-none-linux_armv6l.whl", hash = "sha256:f5bc5398457484fc0374425b43b030e4668ed4d2da8ee7fdda0e926c9f11ccfb"},
{file = "ruff-0.6.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:515a698254c9c47bb84335281a170213b3ee5eb47feebe903e1be10087a167ce"},
{file = "ruff-0.6.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6bb1b4995775f1837ab70f26698dd73852bbb82e8f70b175d2713c0354fe9182"},
{file = "ruff-0.6.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c546f412dfae8bb9cc4f27f0e45cdd554e42fecbb34f03312b93368e1cd0a6"},
{file = "ruff-0.6.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59627e97364329e4eae7d86fa7980c10e2b129e2293d25c478ebcb861b3e3fd6"},
{file = "ruff-0.6.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94c3f78c3d32190aafbb6bc5410c96cfed0a88aadb49c3f852bbc2aa9783a7d8"},
{file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:704da526c1e137f38c8a067a4a975fe6834b9f8ba7dbc5fd7503d58148851b8f"},
{file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efeede5815a24104579a0f6320660536c5ffc1c91ae94f8c65659af915fb9de9"},
{file = "ruff-0.6.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e368aef0cc02ca3593eae2fb8186b81c9c2b3f39acaaa1108eb6b4d04617e61f"},
{file = "ruff-0.6.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2653fc3b2a9315bd809725c88dd2446550099728d077a04191febb5ea79a4f79"},
{file = "ruff-0.6.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bb858cd9ce2d062503337c5b9784d7b583bcf9d1a43c4df6ccb5eab774fbafcb"},
{file = "ruff-0.6.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:488f8e15c01ea9afb8c0ba35d55bd951f484d0c1b7c5fd746ce3c47ccdedce68"},
{file = "ruff-0.6.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aefb0bd15f1cfa4c9c227b6120573bb3d6c4ee3b29fb54a5ad58f03859bc43c6"},
{file = "ruff-0.6.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a4c0698cc780bcb2c61496cbd56b6a3ac0ad858c966652f7dbf4ceb029252fbe"},
{file = "ruff-0.6.6-py3-none-win32.whl", hash = "sha256:aadf81ddc8ab5b62da7aae78a91ec933cbae9f8f1663ec0325dae2c364e4ad84"},
{file = "ruff-0.6.6-py3-none-win_amd64.whl", hash = "sha256:0adb801771bc1f1b8cf4e0a6fdc30776e7c1894810ff3b344e50da82ef50eeb1"},
{file = "ruff-0.6.6-py3-none-win_arm64.whl", hash = "sha256:4b4d32c137bc781c298964dd4e52f07d6f7d57c03eae97a72d97856844aa510a"},
{file = "ruff-0.6.6.tar.gz", hash = "sha256:0fc030b6fd14814d69ac0196396f6761921bd20831725c7361e1b8100b818034"},
]
[[package]]

View File

@@ -1,3 +1,3 @@
{
"flutter": "3.24.0"
"flutter": "3.24.3"
}

View File

@@ -1,5 +1,5 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.24.0",
"dart.flutterSdkPath": ".fvm/versions/3.24.3",
"search.exclude": {
"**/.fvm": true
},

View File

@@ -46,21 +46,66 @@ custom_lint:
- avoid_public_notifier_properties: false
- avoid_manual_providers_as_generated_provider_dependency: false
- unsupported_provider_value: false
- photo_manager:
exclude:
- import_rule_photo_manager:
message: photo_manager must only be used in MediaRepositories
restrict: package:photo_manager
allowed:
# required / wanted
- album_media.repository.dart
- asset_media.repository.dart
- file_media.repository.dart
# acceptable exceptions for the time being
- asset.entity.dart # to provide local AssetEntity for now
- immich_local_image_provider.dart # accesses thumbnails via PhotoManager
- immich_local_thumbnail_provider.dart # accesses thumbnails via PhotoManager
# refactor to make the providers and services testable
- backup.provider.dart # uses only PMProgressHandler
- manual_upload.provider.dart # uses only PMProgressHandler
- background.service.dart # uses only PMProgressHandler
- backup.service.dart # uses only PMProgressHandler
- 'lib/repositories/{album,asset,file}_media.repository.dart'
# acceptable exceptions for the time being
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
- lib/providers/image/immich_local_{image,thumbnail}_provider.dart # accesses thumbnails via PhotoManager
# refactor to make the providers and services testable
- lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler
- lib/services/{background,backup}.service.dart # uses only PMProgressHandler
- import_rule_isar:
message: isar must only be used in entities and repositories
restrict: package:isar
allowed:
# required / wanted
- lib/entities/*.entity.dart
- lib/repositories/{album,asset,backup,user}.repository.dart
# acceptable exceptions for the time being
- integration_test/test_utils/general_helper.dart
- lib/main.dart
- lib/routing/router.dart
- lib/utils/{db,image_url_builder,migration,renderlist_generator}.dart
- test/**.dart
# refactor to make the providers and services testable
- lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart
- lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart
- lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart
- lib/services/{asset,asset_description,background,backup,backup_verification,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart
- lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart
- import_rule_openapi:
message: openapi must only be used through ApiRepositories
restrict: package:openapi
allowed:
# requried / wanted
- lib/repositories/album_api.repository.dart
# acceptable exceptions for the time being
- lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
# refactor
- lib/models/activities/activity.model.dart
- lib/models/map/map_marker.model.dart
- lib/models/search/search_filter.model.dart
- lib/models/server_info/server_{config,disk_info,features,version}.model.dart
- lib/models/shared_link/shared_link.model.dart
- lib/pages/search/search_input.page.dart
- lib/providers/asset_viewer/asset_people.provider.dart
- lib/providers/authentication.provider.dart
- lib/providers/image/immich_remote_{image,thumbnail}_provider.dart
- lib/providers/map/map_state.provider.dart
- lib/providers/search/{people,search,search_filter}.provider.dart
- lib/providers/websocket.provider.dart
- lib/routing/auth_guard.dart
- lib/services/{activity,api,asset,asset_description,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart
- lib/widgets/album/album_thumbnail_listtile.dart
- lib/widgets/forms/login/login_form.dart
- lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart
dart_code_metrics:
metrics:

View File

@@ -1,36 +1,61 @@
import 'dart:collection';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/error/error.dart' show ErrorSeverity;
import 'package:custom_lint_builder/custom_lint_builder.dart';
// ignore: depend_on_referenced_packages
import 'package:glob/glob.dart';
PluginBase createPlugin() => ImmichLinter();
class ImmichLinter extends PluginBase {
@override
List<LintRule> getLintRules(CustomLintConfigs configs) => [
PhotoManagerRule(configs.rules[PhotoManagerRule._code.name]),
];
}
class PhotoManagerRule extends DartLintRule {
PhotoManagerRule(LintOptions? options) : super(code: _code) {
final excludeOption = options?.json["exclude"];
if (excludeOption is String) {
_excludePaths.add(excludeOption);
} else if (excludeOption is List) {
_excludePaths.addAll(excludeOption.map((option) => option));
List<LintRule> getLintRules(CustomLintConfigs configs) {
final List<LintRule> rules = [];
for (final entry in configs.rules.entries) {
if (entry.value.enabled && entry.key.startsWith("import_rule_")) {
final code = makeCode(entry.key, entry.value);
final allowedPaths = getStrings(entry.value, "allowed");
final forbiddenPaths = getStrings(entry.value, "forbidden");
final restrict = getStrings(entry.value, "restrict");
rules.add(ImportRule(code, buildGlob(allowedPaths),
buildGlob(forbiddenPaths), restrict));
}
}
return rules;
}
final Set<String> _excludePaths = HashSet();
static makeCode(String name, LintOptions options) => LintCode(
name: name,
problemMessage: options.json["message"] as String,
errorSeverity: ErrorSeverity.WARNING,
);
static const _code = LintCode(
name: 'photo_manager',
problemMessage:
'photo_manager library must only be used in MediaRepository',
errorSeverity: ErrorSeverity.WARNING,
);
static List<String> getStrings(LintOptions options, String field) {
final List<String> result = [];
final excludeOption = options.json[field];
if (excludeOption is String) {
result.add(excludeOption);
} else if (excludeOption is List) {
result.addAll(excludeOption.map((option) => option));
}
return result;
}
Glob? buildGlob(List<String> globs) {
if (globs.isEmpty) return null;
if (globs.length == 1) return Glob(globs[0], caseSensitive: true);
return Glob("{${globs.join(",")}}", caseSensitive: true);
}
}
// ignore: must_be_immutable
class ImportRule extends DartLintRule {
ImportRule(LintCode code, this._allowed, this._forbidden, this._restrict)
: super(code: code);
final Glob? _allowed;
final Glob? _forbidden;
final List<String> _restrict;
int _rootOffset = -1;
@override
void run(
@@ -38,11 +63,23 @@ class PhotoManagerRule extends DartLintRule {
ErrorReporter reporter,
CustomLintContext context,
) {
if (_excludePaths.contains(resolver.source.shortName)) return;
if (_rootOffset == -1) {
const project = "/immich/mobile/";
_rootOffset = resolver.path.indexOf(project) + project.length;
}
final path = resolver.path.substring(_rootOffset);
if ((_allowed != null && _allowed!.matches(path)) &&
(_forbidden == null || !_forbidden!.matches(path))) return;
context.registry.addImportDirective((node) {
if (node.uri.stringValue?.startsWith("package:photo_manager") == true) {
reporter.atNode(node, code);
final uri = node.uri.stringValue;
if (uri == null) return;
for (final restricted in _restrict) {
if (uri.startsWith(restricted) == true) {
reporter.atNode(node, code);
return;
}
}
});
}

View File

@@ -159,7 +159,7 @@ packages:
source: hosted
version: "2.4.4"
glob:
dependency: transitive
dependency: "direct main"
description:
name: glob
sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"

View File

@@ -8,6 +8,7 @@ dependencies:
analyzer: ^6.8.0
analyzer_plugin: ^0.11.3
custom_lint_builder: ^0.6.4
glob: ^2.1.2
dev_dependencies:
lints: ^4.0.0

View File

@@ -3,6 +3,8 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:isar/isar.dart';
// ignore: implementation_imports
import 'package:isar/src/common/isar_links_common.dart';
import 'package:openapi/api.dart';
part 'album.entity.g.dart';
@@ -23,6 +25,7 @@ class Album {
required this.activityEnabled,
});
// fields stored in DB
Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId;
@@ -41,9 +44,17 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
// transient fields
@ignore
bool isAll = false;
@ignore
String? remoteThumbnailAssetId;
@ignore
int remoteAssetCount = 0;
// getters
@ignore
bool get isRemote => remoteId != null;
@@ -74,6 +85,18 @@ class Album {
@ignore
String get eTagKeyAssetCount => "device-album-$localId-asset-count";
// the following getter are needed because Isar links do not make data
// accessible in an object freshly created (not loaded from DB)
@ignore
Iterable<User> get remoteUsers => sharedUsers.isEmpty
? (sharedUsers as IsarLinksCommon<User>).addedObjects
: sharedUsers;
@ignore
Iterable<Asset> get remoteAssets =>
assets.isEmpty ? (assets as IsarLinksCommon<Asset>).addedObjects : assets;
@override
bool operator ==(other) {
if (other is! Album) return false;
@@ -129,6 +152,7 @@ class Album {
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
a.remoteAssetCount = dto.assetCount;
a.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) {
a.thumbnail.value = await db.assets
@@ -164,7 +188,3 @@ extension AssetsHelper on IsarCollection<Album> {
return a;
}
}
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}

View File

@@ -0,0 +1,40 @@
import 'package:immich_mobile/entities/album.entity.dart';
abstract interface class IAlbumApiRepository {
Future<Album> get(String id);
Future<List<Album>> getAll({bool? shared});
Future<Album> create(
String name, {
required Iterable<String> assetIds,
Iterable<String> sharedUserIds = const [],
});
Future<Album> update(
String albumId, {
String? name,
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
});
Future<void> delete(String albumId);
Future<({List<String> added, List<String> duplicates})> addAssets(
String albumId,
Iterable<String> assetIds,
);
Future<({List<String> removed, List<String> failed})> removeAssets(
String albumId,
Iterable<String> assetIds,
);
Future<Album> addUsers(
String albumId,
Iterable<String> userIds,
);
Future<void> removeUser(String albumId, {required String userId});
}

View File

@@ -3,6 +3,8 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IAssetRepository {
Future<Asset?> getByRemoteId(String id);
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids);
Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy});
Future<void> deleteById(List<int> ids);
}

View File

@@ -2,4 +2,5 @@ import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IUserRepository {
Future<List<User>> getByIds(List<String> ids);
Future<User?> get(String id);
}

View File

@@ -4,11 +4,15 @@ class ServerConfig {
final int trashDays;
final String oauthButtonText;
final String externalDomain;
final String mapDarkStyleUrl;
final String mapLightStyleUrl;
const ServerConfig({
required this.trashDays,
required this.oauthButtonText,
required this.externalDomain,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
});
ServerConfig copyWith({
@@ -20,6 +24,8 @@ class ServerConfig {
trashDays: trashDays ?? this.trashDays,
oauthButtonText: oauthButtonText ?? this.oauthButtonText,
externalDomain: externalDomain ?? this.externalDomain,
mapDarkStyleUrl: mapDarkStyleUrl,
mapLightStyleUrl: mapLightStyleUrl,
);
}
@@ -30,7 +36,9 @@ class ServerConfig {
ServerConfig.fromDto(ServerConfigDto dto)
: trashDays = dto.trashDays,
oauthButtonText = dto.oauthButtonText,
externalDomain = dto.externalDomain;
externalDomain = dto.externalDomain,
mapDarkStyleUrl = dto.mapDarkStyleUrl,
mapLightStyleUrl = dto.mapLightStyleUrl;
@override
bool operator ==(covariant ServerConfig other) {

View File

@@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/favorite_provider.dart';
import 'package:immich_mobile/providers/favorite.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';

View File

@@ -1,4 +1,5 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/map/map_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
@@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget {
),
Positioned(
right: 0,
bottom: MediaQuery.of(context).padding.bottom + 16,
bottom: MediaQuery.paddingOf(context).bottom + 16,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(

View File

@@ -313,6 +313,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Those assets are unique and are used as the total assets
///
Future<void> _updateBackupAssetCount() async {
// Save to persistent storage
await _updatePersistentAlbumsSelection();
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<BackupCandidate> assetsFromSelectedAlbums = {};
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
@@ -408,9 +411,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
}
// Save to persistent storage
await _updatePersistentAlbumsSelection();
}
/// Get all necessary information for calculating the available albums,

View File

@@ -1,28 +1,23 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/models/map/map_state.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'map_state.provider.g.dart';
@Riverpod(keepAlive: true)
class MapStateNotifier extends _$MapStateNotifier {
final _log = Logger("MapStateNotifier");
@override
MapState build() {
final appSettingsProvider = ref.read(appSettingsServiceProvider);
// Fetch and save the Style JSONs
loadStyles();
final lightStyleUrl =
ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl;
final darkStyleUrl =
ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl;
return MapState(
themeMode: ThemeMode.values[
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)],
@@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier {
appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapwithPartners),
relativeTime:
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate),
lightStyleFetched: AsyncData(lightStyleUrl),
darkStyleFetched: AsyncData(darkStyleUrl),
);
}
void loadStyles() async {
final documents = (await getApplicationDocumentsDirectory()).path;
// Set to loading
state = state.copyWith(lightStyleFetched: const AsyncLoading());
// Fetch and save light theme
final lightResponse = await ref
.read(apiServiceProvider)
.mapApi
.getMapStyleWithHttpInfo(MapTheme.light);
if (lightResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
);
_log.severe(
"Cannot fetch map light style",
lightResponse.toLoggerString(),
);
return;
}
final lightJSON = lightResponse.body;
final lightFile = await File("$documents/map-style-light.json")
.writeAsString(lightJSON, flush: true);
// Update state with path
state =
state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path));
// Set to loading
state = state.copyWith(darkStyleFetched: const AsyncLoading());
// Fetch and save dark theme
final darkResponse = await ref
.read(apiServiceProvider)
.mapApi
.getMapStyleWithHttpInfo(MapTheme.dark);
if (darkResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
);
_log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
return;
}
final darkJSON = darkResponse.body;
final darkFile = await File("$documents/map-style-dark.json")
.writeAsString(darkJSON, flush: true);
// Update state with path
state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path));
}
void switchTheme(ThemeMode mode) {
ref.read(appSettingsServiceProvider).setSetting(
AppSettingsEnum.mapThemeMode,

View File

@@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
trashDays: 30,
oauthButtonText: '',
externalDomain: '',
mapLightStyleUrl:
'https://tiles.immich.cloud/v1/style/light.json',
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
),
serverDiskInfo: const ServerDiskInfo(
diskAvailable: "0",

View File

@@ -0,0 +1,172 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/errors.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:openapi/api.dart';
final albumApiRepositoryProvider = Provider(
(ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
);
class AlbumApiRepository implements IAlbumApiRepository {
final AlbumsApi _api;
AlbumApiRepository(this._api);
@override
Future<Album> get(String id) async {
final dto = await _checkNull(_api.getAlbumInfo(id));
return _toAlbum(dto);
}
@override
Future<List<Album>> getAll({bool? shared}) async {
final dtos = await _checkNull(_api.getAllAlbums(shared: shared));
return dtos.map(_toAlbum).toList().cast();
}
@override
Future<Album> create(
String name, {
required Iterable<String> assetIds,
Iterable<String> sharedUserIds = const [],
}) async {
final users = sharedUserIds.map(
(id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor),
);
final responseDto = await _checkNull(
_api.createAlbum(
CreateAlbumDto(
albumName: name,
assetIds: assetIds.toList(),
albumUsers: users.toList(),
),
),
);
return _toAlbum(responseDto);
}
@override
Future<Album> update(
String albumId, {
String? name,
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
}) async {
final response = await _checkNull(
_api.updateAlbumInfo(
albumId,
UpdateAlbumDto(
albumName: name,
albumThumbnailAssetId: thumbnailAssetId,
description: description,
isActivityEnabled: activityEnabled,
),
),
);
return _toAlbum(response);
}
@override
Future<void> delete(String albumId) {
return _api.deleteAlbum(albumId);
}
@override
Future<({List<String> added, List<String> duplicates})> addAssets(
String albumId,
Iterable<String> assetIds,
) async {
final response = await _checkNull(
_api.addAssetsToAlbum(
albumId,
BulkIdsDto(ids: assetIds.toList()),
),
);
final List<String> added = [];
final List<String> duplicates = [];
for (final result in response) {
if (result.success) {
added.add(result.id);
} else if (result.error == BulkIdResponseDtoErrorEnum.duplicate) {
duplicates.add(result.id);
}
}
return (added: added, duplicates: duplicates);
}
@override
Future<({List<String> removed, List<String> failed})> removeAssets(
String albumId,
Iterable<String> assetIds,
) async {
final response = await _checkNull(
_api.removeAssetFromAlbum(
albumId,
BulkIdsDto(ids: assetIds.toList()),
),
);
final List<String> removed = [], failed = [];
for (final dto in response) {
if (dto.success) {
removed.add(dto.id);
} else {
failed.add(dto.id);
}
}
return (removed: removed, failed: failed);
}
@override
Future<Album> addUsers(String albumId, Iterable<String> userIds) async {
final albumUsers =
userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList();
final response = await _checkNull(
_api.addUsersToAlbum(
albumId,
AddUsersDto(albumUsers: albumUsers),
),
);
return _toAlbum(response);
}
@override
Future<void> removeUser(String albumId, {required String userId}) {
return _api.removeUserFromAlbum(albumId, userId);
}
static Future<T> _checkNull<T>(Future<T?> future) async {
final response = await future;
if (response == null) throw NoResponseDtoError();
return response;
}
static Album _toAlbum(AlbumResponseDto dto) {
final Album album = Album(
remoteId: dto.id,
name: dto.albumName,
createdAt: dto.createdAt,
modifiedAt: dto.updatedAt,
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
shared: dto.shared,
startDate: dto.startDate,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
album.remoteAssetCount = dto.assetCount;
album.owner.value = User.fromSimpleUserDto(dto.owner);
album.remoteThumbnailAssetId = dto.albumThumbnailAssetId;
final users = dto.albumUsers
.map((albumUser) => User.fromSimpleUserDto(albumUser.user));
album.sharedUsers.addAll(users);
final assets = dto.assets.map(Asset.remote).toList();
album.assets.addAll(assets);
return album;
}
}

View File

@@ -28,4 +28,11 @@ class AssetRepository implements IAssetRepository {
@override
Future<void> deleteById(List<int> ids) =>
_db.writeTxn(() => _db.assets.deleteAll(ids));
@override
Future<Asset?> getByRemoteId(String id) => _db.assets.getByRemoteId(id);
@override
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
_db.assets.getAllByRemoteId(ids);
}

View File

@@ -17,4 +17,7 @@ class UserRepository implements IUserRepository {
@override
Future<List<User>> getByIds(List<String> ids) async =>
(await _db.users.getAllById(ids)).cast();
@override
Future<User?> get(String id) => _db.users.getById(id);
}

View File

@@ -6,63 +6,61 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final albumServiceProvider = Provider(
(ref) => AlbumService(
ref.watch(apiServiceProvider),
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(entityServiceProvider),
ref.watch(albumRepositoryProvider),
ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(albumApiRepositoryProvider),
),
);
class AlbumService {
final ApiService _apiService;
final UserService _userService;
final SyncService _syncService;
final EntityService _entityService;
final IAlbumRepository _albumRepository;
final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
final IBackupRepository _backupAlbumRepository;
final IAlbumMediaRepository _albumMediaRepository;
final IAlbumApiRepository _albumApiRepository;
final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
AlbumService(
this._apiService,
this._userService,
this._syncService,
this._entityService,
this._albumRepository,
this._assetRepository,
this._userRepository,
this._backupAlbumRepository,
this._albumMediaRepository,
this._albumApiRepository,
);
/// Checks all selected device albums for changes of albums and their assets
@@ -164,17 +162,11 @@ class AlbumService {
bool changes = false;
try {
await _userService.refreshUsers();
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumsApi
.getAllAlbums(shared: isShared ? true : null);
if (serverAlbums == null) {
return false;
}
final List<Album> serverAlbums =
await _albumApiRepository.getAll(shared: isShared ? true : null);
changes = await _syncService.syncRemoteAlbumsToDb(
serverAlbums,
isShared: isShared,
loadDetails: (dto) async => dto.assetCount == dto.assets.length
? dto
: (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto,
);
} finally {
_remoteCompleter.complete(changes);
@@ -188,30 +180,13 @@ class AlbumService {
Iterable<Asset> assets, [
Iterable<User> sharedUsers = const [],
]) async {
try {
AlbumResponseDto? remote = await _apiService.albumsApi.createAlbum(
CreateAlbumDto(
albumName: albumName,
assetIds: assets.map((asset) => asset.remoteId!).toList(),
albumUsers: sharedUsers
.map(
(e) => AlbumUserCreateDto(
userId: e.id,
role: AlbumUserRole.editor,
),
)
.toList(),
),
);
if (remote != null) {
final Album album = await Album.remote(remote);
await _albumRepository.create(album);
return album;
}
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
}
return null;
final Album album = await _albumApiRepository.create(
albumName,
assetIds: assets.map((asset) => asset.remoteId!),
sharedUserIds: sharedUsers.map((user) => user.id),
);
await _entityService.fillAlbumWithDatabaseEntities(album);
return _albumRepository.create(album);
}
/*
@@ -243,32 +218,21 @@ class AlbumService {
Album album,
) async {
try {
var response = await _apiService.albumsApi.addAssetsToAlbum(
final result = await _albumApiRepository.addAssets(
album.remoteId!,
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
assets.map((asset) => asset.remoteId!),
);
if (response != null) {
List<Asset> successAssets = [];
List<String> duplicatedAssets = [];
final List<Asset> addedAssets = result.added
.map((id) => assets.firstWhere((asset) => asset.remoteId == id))
.toList();
for (final result in response) {
if (result.success) {
successAssets
.add(assets.firstWhere((asset) => asset.remoteId == result.id));
} else if (!result.success &&
result.error == BulkIdResponseDtoErrorEnum.duplicate) {
duplicatedAssets.add(result.id);
}
}
await _updateAssets(album.id, add: addedAssets);
await _updateAssets(album.id, add: successAssets);
return AlbumAddAssetsResponse(
alreadyInAlbum: duplicatedAssets,
successfullyAdded: successAssets.length,
);
}
return AlbumAddAssetsResponse(
alreadyInAlbum: result.duplicates,
successfullyAdded: addedAssets.length,
);
} catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
}
@@ -293,20 +257,11 @@ class AlbumService {
Album album,
) async {
try {
final List<AlbumUserAddDto> albumUsers = sharedUserIds
.map((userId) => AlbumUserAddDto(userId: userId))
.toList();
final result = await _apiService.albumsApi.addUsersToAlbum(
album.remoteId!,
AddUsersDto(albumUsers: albumUsers),
);
if (result != null) {
album.sharedUsers.addAll(await _userRepository.getByIds(sharedUserIds));
album.shared = result.shared;
await _albumRepository.update(album);
return true;
}
final updatedAlbum =
await _albumApiRepository.addUsers(album.remoteId!, sharedUserIds);
await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum);
await _albumRepository.update(updatedAlbum);
return true;
} catch (e) {
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
}
@@ -315,15 +270,13 @@ class AlbumService {
Future<bool> setActivityEnabled(Album album, bool enabled) async {
try {
final result = await _apiService.albumsApi.updateAlbumInfo(
final updatedAlbum = await _albumApiRepository.update(
album.remoteId!,
UpdateAlbumDto(isActivityEnabled: enabled),
activityEnabled: enabled,
);
if (result != null) {
album.activityEnabled = enabled;
await _albumRepository.update(album);
return true;
}
await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum);
await _albumRepository.update(updatedAlbum);
return true;
} catch (e) {
debugPrint("Error setActivityEnabled ${e.toString()}");
}
@@ -334,7 +287,7 @@ class AlbumService {
try {
final user = Store.get(StoreKey.currentUser);
if (album.owner.value?.isarId == user.isarId) {
await _apiService.albumsApi.deleteAlbum(album.remoteId!);
await _albumApiRepository.delete(album.remoteId!);
}
if (album.shared) {
final foreignAssets =
@@ -365,7 +318,7 @@ class AlbumService {
Future<bool> leaveAlbum(Album album) async {
try {
await _apiService.albumsApi.removeUserFromAlbum(album.remoteId!, "me");
await _albumApiRepository.removeUser(album.remoteId!, userId: "me");
return true;
} catch (e) {
debugPrint("Error leaveAlbum ${e.toString()}");
@@ -378,21 +331,14 @@ class AlbumService {
Iterable<Asset> assets,
) async {
try {
final response = await _apiService.albumsApi.removeAssetFromAlbum(
final result = await _albumApiRepository.removeAssets(
album.remoteId!,
BulkIdsDto(
ids: assets.map((asset) => asset.remoteId!).toList(),
),
assets.map((asset) => asset.remoteId!),
);
if (response != null) {
final toRemove = response.every((e) => e.success)
? assets
: response
.where((e) => e.success)
.map((e) => assets.firstWhere((a) => a.remoteId == e.id));
await _updateAssets(album.id, remove: toRemove.toList());
return true;
}
final toRemove = result.removed
.map((id) => assets.firstWhere((asset) => asset.remoteId == id));
await _updateAssets(album.id, remove: toRemove.toList());
return true;
} catch (e) {
debugPrint("Error removeAssetFromAlbum ${e.toString()}");
}
@@ -404,9 +350,9 @@ class AlbumService {
User user,
) async {
try {
await _apiService.albumsApi.removeUserFromAlbum(
await _albumApiRepository.removeUser(
album.remoteId!,
user.id,
userId: user.id,
);
album.sharedUsers.remove(user);
@@ -427,15 +373,12 @@ class AlbumService {
String newAlbumTitle,
) async {
try {
await _apiService.albumsApi.updateAlbumInfo(
album = await _albumApiRepository.update(
album.remoteId!,
UpdateAlbumDto(
albumName: newAlbumTitle,
),
name: newAlbumTitle,
);
album.name = newAlbumTitle;
await _entityService.fillAlbumWithDatabaseEntities(album);
await _albumRepository.update(album);
return true;
} catch (e) {
debugPrint("Error changeTitleAlbum ${e.toString()}");
@@ -456,12 +399,8 @@ class AlbumService {
for (final albumName in albumNames) {
Album? album = await getAlbumByName(albumName, true);
album ??= await createAlbum(albumName, []);
if (album != null && album.remoteId != null) {
await _apiService.albumsApi.addAssetsToAlbum(
album.remoteId!,
BulkIdsDto(ids: assetIds),
);
await _albumApiRepository.addAssets(album.remoteId!, assetIds);
}
}
}

View File

@@ -13,12 +13,14 @@ import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
@@ -363,23 +365,33 @@ class BackgroundService {
PartnerService partnerService = PartnerService(apiService, db);
AlbumRepository albumRepository = AlbumRepository(db);
AssetRepository assetRepository = AssetRepository(db);
UserRepository userRepository = UserRepository(db);
BackupRepository backupAlbumRepository = BackupRepository(db);
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
FileMediaRepository fileMediaRepository = FileMediaRepository();
UserRepository userRepository = UserRepository(db);
AlbumApiRepository albumApiRepository =
AlbumApiRepository(apiService.albumsApi);
HashService hashService = HashService(db, this, albumMediaRepository);
SyncService syncSerive = SyncService(db, hashService, albumMediaRepository);
EntityService entityService =
EntityService(assetRepository, userRepository);
SyncService syncSerive = SyncService(
db,
hashService,
entityService,
albumMediaRepository,
albumApiRepository,
);
UserService userService =
UserService(apiService, db, syncSerive, partnerService);
AlbumService albumService = AlbumService(
apiService,
userService,
syncSerive,
entityService,
albumRepository,
assetRepository,
userRepository,
backupAlbumRepository,
albumMediaRepository,
albumApiRepository,
);
BackupService backupService = BackupService(
apiService,

View File

@@ -0,0 +1,52 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
class EntityService {
final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
EntityService(
this._assetRepository,
this._userRepository,
);
Future<Album> fillAlbumWithDatabaseEntities(Album album) async {
final ownerId = album.ownerId;
if (ownerId != null) {
// replace owner with user from database
album.owner.value = await _userRepository.get(ownerId);
}
final thumbnailAssetId =
album.remoteThumbnailAssetId ?? album.thumbnail.value?.remoteId;
if (thumbnailAssetId != null) {
// set thumbnail with asset from database
album.thumbnail.value =
await _assetRepository.getByRemoteId(thumbnailAssetId);
}
if (album.remoteUsers.isNotEmpty) {
// replace all users with users from database
final users = await _userRepository
.getByIds(album.remoteUsers.map((user) => user.id).toList());
album.sharedUsers.clear();
album.sharedUsers.addAll(users);
}
if (album.remoteAssets.isNotEmpty) {
// replace all assets with assets from database
final assets = await _assetRepository
.getAllByRemoteId(album.remoteAssets.map((asset) => asset.remoteId!));
album.assets.clear();
album.assets.addAll(assets);
}
return album;
}
}
final entityServiceProvider = Provider(
(ref) => EntityService(
ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
),
);

View File

@@ -65,7 +65,19 @@ class HashService {
if (hashes[i] != null) {
continue;
}
final file = await assets[i].local!.originFile;
File? file;
try {
file = await assets[i].local!.originFile;
} catch (error, stackTrace) {
_log.warning(
"Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping",
error,
stackTrace,
);
}
if (file == null) {
final fileName = assets[i].fileName;

View File

@@ -8,9 +8,12 @@ import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
@@ -18,24 +21,33 @@ import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final syncServiceProvider = Provider(
(ref) => SyncService(
ref.watch(dbProvider),
ref.watch(hashServiceProvider),
ref.watch(entityServiceProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(albumApiRepositoryProvider),
),
);
class SyncService {
final Isar _db;
final HashService _hashService;
final EntityService _entityService;
final IAlbumMediaRepository _albumMediaRepository;
final IAlbumApiRepository _albumApiRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
SyncService(this._db, this._hashService, this._albumMediaRepository);
SyncService(
this._db,
this._hashService,
this._entityService,
this._albumMediaRepository,
this._albumApiRepository,
);
// public methods:
@@ -65,11 +77,10 @@ class SyncService {
/// Syncs remote albums to the database
/// returns `true` if there were any changes
Future<bool> syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote, {
List<Album> remote, {
required bool isShared,
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
}) =>
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails));
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared));
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
@@ -289,11 +300,10 @@ class SyncService {
/// Syncs remote albums to the database
/// returns `true` if there were any changes
Future<bool> _syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote,
List<Album> remoteAlbums,
bool isShared,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
remote.sortBy((e) => e.id);
remoteAlbums.sortBy((e) => e.remoteId!);
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
final QueryBuilder<Album, Album, QAfterFilterCondition> query;
@@ -310,14 +320,14 @@ class SyncService {
final List<Asset> existing = [];
final bool changes = await diffSortedLists(
remote,
remoteAlbums,
dbAlbums,
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!),
both: (AlbumResponseDto a, Album b) =>
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails),
onlyFirst: (AlbumResponseDto a) =>
_addAlbumFromServer(a, existing, loadDetails),
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete),
compare: (remoteAlbum, dbAlbum) =>
remoteAlbum.remoteId!.compareTo(dbAlbum.remoteId!),
both: (remoteAlbum, dbAlbum) =>
_syncRemoteAlbum(remoteAlbum, dbAlbum, toDelete, existing),
onlyFirst: (remoteAlbum) => _addAlbumFromServer(remoteAlbum, existing),
onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete),
);
if (isShared && toDelete.isNotEmpty) {
@@ -338,26 +348,22 @@ class SyncService {
/// syncing changes from local back to server)
/// accumulates
Future<bool> _syncRemoteAlbum(
AlbumResponseDto dto,
Album dto,
Album album,
List<Asset> deleteCandidates,
List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
if (!_hasAlbumResponseDtoChanged(dto, album)) {
if (!_hasRemoteAlbumChanged(dto, album)) {
return false;
}
// loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp,
// i.e. it will always be null. Save it here.
final originalDto = dto;
dto = await loadDetails(dto);
if (dto.assetCount != dto.assets.length) {
return false;
}
dto = await _albumApiRepository.get(dto.remoteId!);
final assetsInDb =
await album.assets.filter().sortByOwnerId().thenByChecksum().findAll();
assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!");
final List<Asset> assetsOnRemote = dto.getAssets();
final List<Asset> assetsOnRemote = dto.remoteAssets.toList();
assetsOnRemote.sort(Asset.compareByOwnerChecksum);
final (toAdd, toUpdate, toUnlink) = _diffAssets(
assetsOnRemote,
@@ -368,15 +374,16 @@ class SyncService {
// update shared users
final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
sharedUsers.sort((a, b) => a.id.compareTo(b.id));
dto.albumUsers.sort((a, b) => a.user.id.compareTo(b.user.id));
final List<User> users = dto.remoteUsers.toList()
..sort((a, b) => a.id.compareTo(b.id));
final List<String> userIdsToAdd = [];
final List<User> usersToUnlink = [];
diffSortedListsSync(
dto.albumUsers,
users,
sharedUsers,
compare: (AlbumUserResponseDto a, User b) => a.user.id.compareTo(b.id),
compare: (User a, User b) => a.id.compareTo(b.id),
both: (a, b) => false,
onlyFirst: (AlbumUserResponseDto a) => userIdsToAdd.add(a.user.id),
onlyFirst: (User a) => userIdsToAdd.add(a.id),
onlySecond: (User a) => usersToUnlink.add(a),
);
@@ -386,19 +393,19 @@ class SyncService {
final assetsToLink = existingInDb + updated;
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
album.name = dto.albumName;
album.name = dto.name;
album.shared = dto.shared;
album.createdAt = dto.createdAt;
album.modifiedAt = dto.updatedAt;
album.modifiedAt = dto.modifiedAt;
album.startDate = dto.startDate;
album.endDate = dto.endDate;
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
album.shared = dto.shared;
album.activityEnabled = dto.isActivityEnabled;
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
album.activityEnabled = dto.activityEnabled;
if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) {
album.thumbnail.value = await _db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.remoteIdEqualTo(dto.remoteThumbnailAssetId)
.findFirst();
}
@@ -434,27 +441,26 @@ class SyncService {
/// (shared) assets to the database beforehand
/// accumulates assets already existing in the database
Future<void> _addAlbumFromServer(
AlbumResponseDto dto,
Album album,
List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
if (dto.assetCount != dto.assets.length) {
dto = await loadDetails(dto);
if (album.remoteAssetCount != album.remoteAssets.length) {
album = await _albumApiRepository.get(album.remoteId!);
}
if (dto.assetCount == dto.assets.length) {
if (album.remoteAssetCount == album.remoteAssets.length) {
// in case an album contains assets not yet present in local DB:
// put missing album assets into local DB
final (existingInDb, updated) =
await _linkWithExistingFromDb(dto.getAssets());
await _linkWithExistingFromDb(album.remoteAssets.toList());
existing.addAll(existingInDb);
await upsertAssetsWithExif(updated);
final Album a = await Album.remote(dto);
await _db.writeTxn(() => _db.albums.store(a));
await _entityService.fillAlbumWithDatabaseEntities(album);
await _db.writeTxn(() => _db.albums.store(album));
} else {
_log.warning(
"Failed to add album from server: assetCount ${dto.assetCount} != "
"asset array length ${dto.assets.length} for album ${dto.albumName}");
"Failed to add album from server: assetCount ${album.remoteAssetCount} != "
"asset array length ${album.remoteAssets.length} for album ${album.name}");
}
}
@@ -919,17 +925,17 @@ class SyncService {
}
/// returns `true` if the albums differ on the surface
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
return dto.assetCount != a.assetCount ||
dto.albumName != a.name ||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
dto.shared != a.shared ||
dto.albumUsers.length != a.sharedUsers.length ||
!dto.updatedAt.isAtSameMomentAs(a.modifiedAt) ||
!isAtSameMomentAs(dto.startDate, a.startDate) ||
!isAtSameMomentAs(dto.endDate, a.endDate) ||
bool _hasRemoteAlbumChanged(Album remoteAlbum, Album dbAlbum) {
return remoteAlbum.remoteAssetCount != dbAlbum.assetCount ||
remoteAlbum.name != dbAlbum.name ||
remoteAlbum.remoteThumbnailAssetId != dbAlbum.thumbnail.value?.remoteId ||
remoteAlbum.shared != dbAlbum.shared ||
remoteAlbum.remoteUsers.length != dbAlbum.sharedUsers.length ||
!remoteAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
!isAtSameMomentAs(remoteAlbum.startDate, dbAlbum.startDate) ||
!isAtSameMomentAs(remoteAlbum.endDate, dbAlbum.endDate) ||
!isAtSameMomentAs(
dto.lastModifiedAssetTimestamp,
a.lastModifiedAssetTimestamp,
remoteAlbum.lastModifiedAssetTimestamp,
dbAlbum.lastModifiedAssetTimestamp,
);
}

View File

@@ -12,6 +12,19 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'tags', TagsResponse().toJson());
}
break;
case 'ServerConfigDto':
if (value is Map) {
addDefault(
value,
'mapLightStyleUrl',
'https://tiles.immich.cloud/v1/style/light.json',
);
addDefault(
value,
'mapDarkStyleUrl',
'https://tiles.immich.cloud/v1/style/dark.json',
);
}
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());

View File

@@ -116,6 +116,7 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets |
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
@@ -137,7 +138,6 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} |
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate |
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers |
*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json |
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode |
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets |
*MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories |
@@ -172,6 +172,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random |
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
*ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license |
*ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about |
@@ -346,7 +347,6 @@ Class | Method | HTTP request | Description
- [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
- [MapTheme](doc//MapTheme.md)
- [MemoriesResponse](doc//MemoriesResponse.md)
- [MemoriesUpdate](doc//MemoriesUpdate.md)
- [MemoryCreateDto](doc//MemoryCreateDto.md)
@@ -379,6 +379,7 @@ Class | Method | HTTP request | Description
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [RandomSearchDto](doc//RandomSearchDto.md)
- [RatingsResponse](doc//RatingsResponse.md)
- [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md)

View File

@@ -159,7 +159,6 @@ part 'model/logout_response_dto.dart';
part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart';
part 'model/map_theme.dart';
part 'model/memories_response.dart';
part 'model/memories_update.dart';
part 'model/memory_create_dto.dart';
@@ -192,6 +191,7 @@ part 'model/places_response_dto.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';
part 'model/queue_status_dto.dart';
part 'model/random_search_dto.dart';
part 'model/ratings_response.dart';
part 'model/ratings_update.dart';
part 'model/reaction_level.dart';

View File

@@ -449,7 +449,10 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'GET /assets/random' operation and returns the [Response].
/// This property was deprecated in v1.116.0
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [num] count:
@@ -482,6 +485,8 @@ class AssetsApi {
);
}
/// This property was deprecated in v1.116.0
///
/// Parameters:
///
/// * [num] count:

View File

@@ -71,4 +71,63 @@ class DeprecatedApi {
}
return null;
}
/// This property was deprecated in v1.116.0
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [num] count:
Future<Response> getRandomWithHttpInfo({ num? count, }) async {
// ignore: prefer_const_declarations
final path = r'/assets/random';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (count != null) {
queryParams.addAll(_queryParams('', 'count', count));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This property was deprecated in v1.116.0
///
/// Parameters:
///
/// * [num] count:
Future<List<AssetResponseDto>?> getRandom({ num? count, }) async {
final response = await getRandomWithHttpInfo( count: count, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList(growable: false);
}
return null;
}
}

View File

@@ -105,62 +105,6 @@ class MapApi {
return null;
}
/// Performs an HTTP 'GET /map/style.json' operation and returns the [Response].
/// Parameters:
///
/// * [MapTheme] theme (required):
///
/// * [String] key:
Future<Response> getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/map/style.json';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
queryParams.addAll(_queryParams('', 'theme', theme));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [MapTheme] theme (required):
///
/// * [String] key:
Future<Object?> getMapStyle(MapTheme theme, { String? key, }) async {
final response = await getMapStyleWithHttpInfo(theme, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object;
}
return null;
}
/// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response].
/// Parameters:
///

View File

@@ -351,6 +351,53 @@ class SearchApi {
return null;
}
/// Performs an HTTP 'POST /search/random' operation and returns the [Response].
/// Parameters:
///
/// * [RandomSearchDto] randomSearchDto (required):
Future<Response> searchRandomWithHttpInfo(RandomSearchDto randomSearchDto,) async {
// ignore: prefer_const_declarations
final path = r'/search/random';
// ignore: prefer_final_locals
Object? postBody = randomSearchDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [RandomSearchDto] randomSearchDto (required):
Future<SearchResponseDto?> searchRandom(RandomSearchDto randomSearchDto,) async {
final response = await searchRandomWithHttpInfo(randomSearchDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /search/smart' operation and returns the [Response].
/// Parameters:
///

View File

@@ -166,7 +166,6 @@ class ApiClient {
/// Returns a native instance of an OpenAPI class matching the [specified type][targetType].
static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) {
upgradeDto(value, targetType);
try {
switch (targetType) {
case 'String':
@@ -373,8 +372,6 @@ class ApiClient {
return MapMarkerResponseDto.fromJson(value);
case 'MapReverseGeocodeResponseDto':
return MapReverseGeocodeResponseDto.fromJson(value);
case 'MapTheme':
return MapThemeTypeTransformer().decode(value);
case 'MemoriesResponse':
return MemoriesResponse.fromJson(value);
case 'MemoriesUpdate':
@@ -439,6 +436,8 @@ class ApiClient {
return PurchaseUpdate.fromJson(value);
case 'QueueStatusDto':
return QueueStatusDto.fromJson(value);
case 'RandomSearchDto':
return RandomSearchDto.fromJson(value);
case 'RatingsResponse':
return RatingsResponse.fromJson(value);
case 'RatingsUpdate':

View File

@@ -100,9 +100,6 @@ String parameterToString(dynamic value) {
if (value is ManualJobName) {
return ManualJobNameTypeTransformer().encode(value).toString();
}
if (value is MapTheme) {
return MapThemeTypeTransformer().encode(value).toString();
}
if (value is MemoryType) {
return MemoryTypeTypeTransformer().encode(value).toString();
}

View File

@@ -78,6 +78,7 @@ class ActivityCreateDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ActivityCreateDto? fromJson(dynamic value) {
upgradeDto(value, "ActivityCreateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -78,6 +78,7 @@ class ActivityResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ActivityResponseDto? fromJson(dynamic value) {
upgradeDto(value, "ActivityResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class ActivityStatisticsResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ActivityStatisticsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "ActivityStatisticsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class AddUsersDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AddUsersDto? fromJson(dynamic value) {
upgradeDto(value, "AddUsersDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class AdminOnboardingUpdateDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AdminOnboardingUpdateDto? fromJson(dynamic value) {
upgradeDto(value, "AdminOnboardingUpdateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -186,6 +186,7 @@ class AlbumResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -52,6 +52,7 @@ class AlbumStatisticsResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumStatisticsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumStatisticsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -56,6 +56,7 @@ class AlbumUserAddDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumUserAddDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumUserAddDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class AlbumUserCreateDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumUserCreateDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumUserCreateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class AlbumUserResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumUserResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumUserResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -118,6 +118,7 @@ class AllJobStatusResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AllJobStatusResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AllJobStatusResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -56,6 +56,7 @@ class APIKeyCreateDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static APIKeyCreateDto? fromJson(dynamic value) {
upgradeDto(value, "APIKeyCreateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class APIKeyCreateResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static APIKeyCreateResponseDto? fromJson(dynamic value) {
upgradeDto(value, "APIKeyCreateResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -64,6 +64,7 @@ class APIKeyResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static APIKeyResponseDto? fromJson(dynamic value) {
upgradeDto(value, "APIKeyResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class APIKeyUpdateDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static APIKeyUpdateDto? fromJson(dynamic value) {
upgradeDto(value, "APIKeyUpdateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -56,6 +56,7 @@ class AssetBulkDeleteDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetBulkDeleteDto? fromJson(dynamic value) {
upgradeDto(value, "AssetBulkDeleteDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -148,6 +148,7 @@ class AssetBulkUpdateDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetBulkUpdateDto? fromJson(dynamic value) {
upgradeDto(value, "AssetBulkUpdateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class AssetBulkUploadCheckDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetBulkUploadCheckDto? fromJson(dynamic value) {
upgradeDto(value, "AssetBulkUploadCheckDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -47,6 +47,7 @@ class AssetBulkUploadCheckItem {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetBulkUploadCheckItem? fromJson(dynamic value) {
upgradeDto(value, "AssetBulkUploadCheckItem");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class AssetBulkUploadCheckResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetBulkUploadCheckResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetBulkUploadCheckResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -88,6 +88,7 @@ class AssetBulkUploadCheckResult {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetBulkUploadCheckResult? fromJson(dynamic value) {
upgradeDto(value, "AssetBulkUploadCheckResult");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class AssetDeltaSyncDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetDeltaSyncDto? fromJson(dynamic value) {
upgradeDto(value, "AssetDeltaSyncDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -52,6 +52,7 @@ class AssetDeltaSyncResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetDeltaSyncResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetDeltaSyncResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -102,6 +102,7 @@ class AssetFaceResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetFaceResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class AssetFaceUpdateDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceUpdateDto? fromJson(dynamic value) {
upgradeDto(value, "AssetFaceUpdateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class AssetFaceUpdateItem {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceUpdateItem? fromJson(dynamic value) {
upgradeDto(value, "AssetFaceUpdateItem");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -92,6 +92,7 @@ class AssetFaceWithoutPersonResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetFaceWithoutPersonResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -79,6 +79,7 @@ class AssetFullSyncDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFullSyncDto? fromJson(dynamic value) {
upgradeDto(value, "AssetFullSyncDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class AssetIdsDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetIdsDto? fromJson(dynamic value) {
upgradeDto(value, "AssetIdsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -56,6 +56,7 @@ class AssetIdsResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetIdsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetIdsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class AssetJobsDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetJobsDto? fromJson(dynamic value) {
upgradeDto(value, "AssetJobsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class AssetMediaResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMediaResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMediaResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -293,6 +293,7 @@ class AssetResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -52,6 +52,7 @@ class AssetStackResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetStackResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetStackResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -52,6 +52,7 @@ class AssetStatsResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetStatsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetStatsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class AuditDeletesResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AuditDeletesResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AuditDeletesResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class AvatarResponse {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AvatarResponse? fromJson(dynamic value) {
upgradeDto(value, "AvatarResponse");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -50,6 +50,7 @@ class AvatarUpdate {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AvatarUpdate? fromJson(dynamic value) {
upgradeDto(value, "AvatarUpdate");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -56,6 +56,7 @@ class BulkIdResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static BulkIdResponseDto? fromJson(dynamic value) {
upgradeDto(value, "BulkIdResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class BulkIdsDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static BulkIdsDto? fromJson(dynamic value) {
upgradeDto(value, "BulkIdsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class ChangePasswordDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ChangePasswordDto? fromJson(dynamic value) {
upgradeDto(value, "ChangePasswordDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class CheckExistingAssetsDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CheckExistingAssetsDto? fromJson(dynamic value) {
upgradeDto(value, "CheckExistingAssetsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -40,6 +40,7 @@ class CheckExistingAssetsResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CheckExistingAssetsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "CheckExistingAssetsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class CLIPConfig {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CLIPConfig? fromJson(dynamic value) {
upgradeDto(value, "CLIPConfig");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -68,6 +68,7 @@ class CreateAlbumDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CreateAlbumDto? fromJson(dynamic value) {
upgradeDto(value, "CreateAlbumDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -68,6 +68,7 @@ class CreateLibraryDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CreateLibraryDto? fromJson(dynamic value) {
upgradeDto(value, "CreateLibraryDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -52,6 +52,7 @@ class CreateProfileImageResponseDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CreateProfileImageResponseDto? fromJson(dynamic value) {
upgradeDto(value, "CreateProfileImageResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class DownloadArchiveInfo {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DownloadArchiveInfo? fromJson(dynamic value) {
upgradeDto(value, "DownloadArchiveInfo");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -89,6 +89,7 @@ class DownloadInfoDto {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DownloadInfoDto? fromJson(dynamic value) {
upgradeDto(value, "DownloadInfoDto");
if (value is Map) {
final json = value.cast<String, dynamic>();

View File

@@ -46,6 +46,7 @@ class DownloadResponse {
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DownloadResponse? fromJson(dynamic value) {
upgradeDto(value, "DownloadResponse");
if (value is Map) {
final json = value.cast<String, dynamic>();

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