Compare commits

..

35 Commits

Author SHA1 Message Date
github-actions
dc143046e3 chore: version v1.128.0 2025-02-28 18:54:08 +00:00
Jason Rasmussen
e684062569 fix: memories off by one (#16434) 2025-02-28 12:51:28 -06:00
Desmond Cox
5c0538e52c fix(server): stringify error log parameter to ensure correct overload (#16422)
* fix(server): stringify error log parameter to ensure correct overload

The intended error(message, stack, context) overload is only selected if context is a string.

* formatter
2025-02-28 11:50:00 -06:00
Jason Rasmussen
84cf0d1670 fix: duplicate memories (#16432) 2025-02-28 17:49:29 +00:00
Jonathan Jogenfors
bfcde05b1c chore(server): trash e2e cleanup (#16423) 2025-02-28 12:45:30 -05:00
Mert
b3b15e9b61 fix(server): include deleted assets if searching offline assets (#16417)
include deleted assets if searching for offline assets
2025-02-28 09:23:18 -06:00
Zack Pollard
819e56d9ca fix: user delete sync query sort by id (#16420) 2025-02-28 09:22:36 -06:00
shenlong
9a98712db7 fix(mobile): background backup failing due to store (#16418)
fix: background backup failing due to store

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-02-28 07:38:51 -06:00
Alex
a185e06399 fix(server): follow logs level setting (#16415) 2025-02-28 00:35:48 -05:00
Calum Dingwall
f2be9f7ad1 fix(web): person favorite icon bad placement (#16412)
move favorite person icon to top left

fixes #16003

Co-authored-by: Calum Dingwall <caburum@users.noreply.github.com>
2025-02-27 22:15:37 -06:00
Alex
5c879acd5b fix(server): don't show assets that no longer associate with a face (#16404) 2025-02-27 17:02:00 -06:00
shenlong
28c664c769 refactor(mobile): log service (#16383)
refactor: log service

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-02-27 15:18:49 -05:00
Jason Rasmussen
fbd85a89e0 refactor: logger (#16393) 2025-02-27 14:59:50 -05:00
Alex
1c86293035 chore(mobile): update analysis option (#16396)
chore-update-analysis-option
2025-02-27 18:35:28 +00:00
shenlong
4a9d80298b fix(mobile): bootstrap store inside isolates (#16392)
fix: bootstrap store inside isolates

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-02-27 18:01:36 +00:00
Alex
362feb1e62 feat(web): face tagging dialog enhancement (#16395) 2025-02-27 11:49:07 -06:00
Etienne
5503bf7a60 fix: improve contrast on disabled input field in light mode (#16368) (#16382) 2025-02-27 17:20:03 +00:00
Jonathan Jogenfors
d20e2e268a fix(server): don't reimport files more than once (#16375)
* fix(server) don't reimport files more than once

* fix: test

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-02-27 16:45:16 +00:00
Mert
a708649504 fix(server): skip stacked assets in duplicate detection (#16380)
* skip stacked assets in duplicate detection

* update sql

* handle stacking after duplicate detection runs
2025-02-27 10:16:13 -06:00
Tom Graham
a808b8610e fix(server): Fix delay with multiple ml servers (#16284)
* Prospective fix for ensuring that known active ML servers are used to reduce search delay.

* Added some logging and renamed backoff const.

* Fix lint issues.

* Update to use env vars for timeouts and updated documentation and strings.

* Fix docs.

* Make counter logic clearer.

* Minor readability improvements.

* Extract  skipUrl logic per feedback, and change log to verbose.

* Make code harder to read.
2025-02-27 10:14:09 -06:00
Alex
c70c9067b0 refactor(mobile): backup provider (#16360)
* refactor(mobile): backup provider

* refactor(mobile): backup provider
2025-02-27 09:56:23 -06:00
Alex
082471dfd9 chore(mobile): post release task (#16349) 2025-02-27 09:46:34 -06:00
Alex
9a098b4658 fix(web): storage template incorrect example (#16367) 2025-02-27 09:46:20 -06:00
immich-tofu[bot]
9d705097e8 chore: modify .github/FUNDING.yml 2025-02-27 14:28:08 +00:00
Mert
6050485ad8 feat(server): set exiftool process count (#16388)
exiftool concurrency control
2025-02-27 09:24:40 -05:00
Zack Pollard
fb907d707d refactor: use new updateId column for user CUD sync (#16384) 2025-02-27 09:22:02 -05:00
Mert
7d6cfd09e6 fix(server): don't expose source types in face creation api (#16381)
* don't expose source types in face creation api

* update open-api

* remove source type reference from web
2025-02-27 17:17:07 +03:00
Zack Pollard
967c69317b feat: updateId uuidv7 column for all entities with updatedAt (#16353) 2025-02-27 12:55:22 +00:00
Curtis Lowder
128d653fc6 fix(web): update search modal to not jump around (#16308)
* fix(web): update search modal to not jump around

Search People selection will change size while loading. This causes the
search modal to jump around as the people load in.

* loading spinner size

* remove unsued code

---------

Co-authored-by: cwlowder <me@curtislowder.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-02-27 03:06:41 +00:00
David Bourgault
8b69114924 feat(web): remember last chosen map location when editing (#16366)
Uses a global store to remember the last location chosen by a user when
editing asset locations. This fixes an annoyance when adding location
data to multiple assets in a row and having to zoom in the same area
everytime.
2025-02-26 21:01:29 -06:00
David Bourgault
4b55888d16 fix: ensure manually tagged faces have proper source type (#16364)
immich-app/immich#16062 added manual face tagging and deletion, but did
not add a new 'SourceType'. The create faces would default to
'machine-learning' which is incorrect, and has the annoying downside
that they will be wiped when the 'Refresh Faces' job is run.

Handling of non-machine-learning faces was previously added in
immich-app/immich#6455. This PR simply extends it to the new manually
tagged faces.
2025-02-26 20:53:21 -06:00
Alex
8fbd650483 refactor(mobile): refactor user provider (#16358) 2025-02-26 17:04:43 -06:00
Alex
c778516ce2 fix(web): tag people in video (#16351) 2025-02-26 12:55:32 -06:00
Adam O'neill
2969e25ff7 fix: websockets calling on_new_release across all sessions upon new websocket connection. (#16339)
* Implemented possible fix for the new_release window re-appearing across all active sessions when a new websocket connection is established.

* Reverted websocket.ts

Changes not needed to websocket.ts - was bouncing between ideas, current implementation doesn't need this to change.

* Prettier test format.

* Spelling (Aknowledged --> Acknowledged)
2025-02-26 17:48:18 +00:00
luzpaz
c055e1aefe docs: fix typos (#16352)
Found via `codespell -q 3 -S "./i18n,./docs/package-lock.json,./readme_i18n,./mobile/assets/i18n" -L afterall,nd,renderd`
2025-02-26 17:21:27 +00:00
138 changed files with 1800 additions and 660 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
custom: ['https://buy.immich.app'] custom: ['https://buy.immich.app', 'https://immich.store']

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.51", "version": "2.2.52",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.51", "version": "2.2.52",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.127.0", "version": "1.128.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.51", "version": "2.2.52",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",

View File

@@ -97,7 +97,7 @@ Make sure to [set your reverse proxy](/docs/administration/reverse-proxy/) to al
Also, check the disk space of your reverse proxy. Also, check the disk space of your reverse proxy.
In some cases, proxies cache requests to disk before passing them on, and if disk space runs out, the request fails. In some cases, proxies cache requests to disk before passing them on, and if disk space runs out, the request fails.
If you are using Cloudflare Tunnel, please know that they set a maxiumum filesize of 100 MB that cannot be changed. If you are using Cloudflare Tunnel, please know that they set a maximum filesize of 100 MB that cannot be changed.
At times, files larger than this may work, potentially up to 1 GB. However, the official limit is 100 MB. At times, files larger than this may work, potentially up to 1 GB. However, the official limit is 100 MB.
If you are having issues, we recommend switching to a different network deployment. If you are having issues, we recommend switching to a different network deployment.
@@ -170,7 +170,7 @@ If you aren't able to or prefer not to mount Samba on the host (such as Windows
Below is an example in the `docker-compose.yml`. Below is an example in the `docker-compose.yml`.
Change your username, password, local IP, and share name, and see below where the line `- originals:/usr/src/app/originals`, Change your username, password, local IP, and share name, and see below where the line `- originals:/usr/src/app/originals`,
corrolates to the section where the volume `originals` was created. You can call this whatever you like, and map it to the docker container as you like. correlates to the section where the volume `originals` was created. You can call this whatever you like, and map it to the docker container as you like.
For example you could change `originals:` to `Photos:`, and change `- originals:/usr/src/app/originals` to `Photos:/usr/src/app/photos`. For example you could change `originals:` to `Photos:`, and change `- originals:/usr/src/app/originals` to `Photos:/usr/src/app/photos`.
```diff ```diff

View File

@@ -98,6 +98,14 @@ The default Immich log level is `Log` (commonly known as `Info`). The Immich adm
Through this setting, you can manage all the settings related to machine learning in Immich, from the setting of remote machine learning to the model and its parameters Through this setting, you can manage all the settings related to machine learning in Immich, from the setting of remote machine learning to the model and its parameters
You can choose to disable a certain type of machine learning, for example smart search or facial recognition. You can choose to disable a certain type of machine learning, for example smart search or facial recognition.
### URL
The built in (`http://immich-machine-learning:3003`) machine learning server will be configured by default, but you can change this or add additional servers.
Hosting the `immich-machine-learning` container on a machine with a more powerful GPU can be helpful to for processing a large number of photos (such as during batch import) or for faster search.
If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.
### Smart Search ### Smart Search
The [smart search](/docs/features/searching) settings allow you to change the [CLIP model](https://openai.com/research/clip). Larger models will typically provide [more accurate search results](https://github.com/immich-app/immich/discussions/11862) but consume more processing power and RAM. When [changing the CLIP model](/docs/FAQ#can-i-use-a-custom-clip-model) it is mandatory to re-run the Smart Search job on all images to fully apply the change. The [smart search](/docs/features/searching) settings allow you to change the [CLIP model](https://openai.com/research/clip). Larger models will typically provide [more accurate search results](https://github.com/immich-app/immich/discussions/11862) but consume more processing power and RAM. When [changing the CLIP model](/docs/FAQ#can-i-use-a-custom-clip-model) it is mandatory to re-run the Smart Search job on all images to fully apply the change.

View File

@@ -37,7 +37,7 @@ You can alternatively download these two files from your browser and move them t
</CodeBlock> </CodeBlock>
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. It should be a new directory on the server with enough free space. - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. It should be a new directory on the server with enough free space.
- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication. - Consider changing `DB_PASSWORD` to a custom value. Postgres is not publicly exposed, so this password is only used for local authentication.
To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. `pwgen` is a handy utility for this. To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. `pwgen` is a handy utility for this.
- Set your timezone by uncommenting the `TZ=` line. - Set your timezone by uncommenting the `TZ=` line.
- Populate custom database information if necessary. - Populate custom database information if necessary.

View File

@@ -168,6 +168,8 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning | | `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | | `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server |
| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.

View File

@@ -198,7 +198,7 @@ The **CPU** value was specified in a different format with a default of `4000m`
The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000` The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000`
::: :::
Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passtrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough) Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passthrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough)
### Install ### Install

View File

@@ -1,4 +1,8 @@
[ [
{
"label": "v1.128.0",
"url": "https://v1.128.0.archive.immich.app"
},
{ {
"label": "v1.127.0", "label": "v1.127.0",
"url": "https://v1.127.0.archive.immich.app" "url": "https://v1.127.0.archive.immich.app"

View File

@@ -5,7 +5,7 @@ module.exports = {
preflight: false, // disable Tailwind's reset preflight: false, // disable Tailwind's reset
}, },
content: ['./src/**/*.{js,jsx,ts,tsx}', './{docs,blog}/**/*.{md,mdx}'], // my markdown stuff is in ../docs, not /src content: ['./src/**/*.{js,jsx,ts,tsx}', './{docs,blog}/**/*.{md,mdx}'], // my markdown stuff is in ../docs, not /src
darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settings
theme: { theme: {
extend: { extend: {
colors: { colors: {

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.127.0", "version": "1.128.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.127.0", "version": "1.128.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.51", "version": "2.2.52",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -92,7 +92,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.127.0", "version": "1.128.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.127.0", "version": "1.128.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@@ -526,6 +526,47 @@ describe('/libraries', () => {
utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
}); });
it('should not reimport a modified file more than once', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/reimport`],
});
utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id);
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id);
cpSync(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, {
libraryId: library.id,
});
expect(assets.count).toEqual(1);
const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(asset).toEqual(
expect.objectContaining({
originalFileName: 'asset.jpg',
exifInfo: expect.objectContaining({
model: 'NIKON D750',
}),
}),
);
utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
});
it('should set an asset offline if its file is missing', async () => { it('should set an asset offline if its file is missing', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,

View File

@@ -1,4 +1,4 @@
import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@@ -6,8 +6,6 @@ import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'sr
import request from 'supertest'; import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
describe('/trash', () => { describe('/trash', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let ws: Socket; let ws: Socket;
@@ -81,8 +79,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.items.length).toBe(1); expect(assets.items.length).toBe(1);
@@ -90,8 +87,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
@@ -116,8 +112,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.items.length).toBe(1); expect(assets.items.length).toBe(1);
@@ -125,8 +120,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
@@ -180,8 +174,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1); expect(assets.count).toBe(1);
@@ -189,9 +182,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
@@ -201,6 +192,8 @@ describe('/trash', () => {
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
}); });
}); });
@@ -238,7 +231,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -247,7 +240,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
const before = await utils.getAssetInfo(admin.accessToken, assetId); const before = await utils.getAssetInfo(admin.accessToken, assetId);
@@ -261,6 +254,8 @@ describe('/trash', () => {
const after = await utils.getAssetInfo(admin.accessToken, assetId); const after = await utils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(true); expect(after.isTrashed).toBe(true);
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
}); });
}); });
}); });

View File

@@ -131,7 +131,7 @@
"machine_learning_smart_search_description": "Search for images semantically using CLIP embeddings", "machine_learning_smart_search_description": "Search for images semantically using CLIP embeddings",
"machine_learning_smart_search_enabled": "Enable smart search", "machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.", "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last.", "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"manage_concurrency": "Manage Concurrency", "manage_concurrency": "Manage Concurrency",
"manage_log_settings": "Manage log settings", "manage_log_settings": "Manage log settings",
"map_dark_style": "Dark style", "map_dark_style": "Dark style",

View File

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

View File

@@ -67,7 +67,7 @@ custom_lint:
- lib/entities/*.entity.dart - lib/entities/*.entity.dart
- lib/repositories/{album,asset,backup,database,etag,exif_info,user,timeline,partner}.repository.dart - lib/repositories/{album,asset,backup,database,etag,exif_info,user,timeline,partner}.repository.dart
- lib/infrastructure/entities/*.entity.dart - lib/infrastructure/entities/*.entity.dart
- lib/infrastructure/repositories/{store,db}.repository.dart - lib/infrastructure/repositories/{store,db,log}.repository.dart
- lib/providers/infrastructure/db.provider.dart - lib/providers/infrastructure/db.provider.dart
# acceptable exceptions for the time being (until Isar is fully replaced) # acceptable exceptions for the time being (until Isar is fully replaced)
- integration_test/test_utils/general_helper.dart - integration_test/test_utils/general_helper.dart
@@ -76,17 +76,17 @@ custom_lint:
- lib/routing/router.dart - lib/routing/router.dart
- lib/services/immich_logger.service.dart # not really a service... more a util - lib/services/immich_logger.service.dart # not really a service... more a util
- lib/utils/{db,migration}.dart - lib/utils/{db,migration}.dart
- lib/utils/bootstrap.dart
- lib/widgets/asset_grid/asset_grid_data_structure.dart - lib/widgets/asset_grid/asset_grid_data_structure.dart
- test/**.dart - test/**.dart
# refactor the remaining providers # refactor the remaining providers
- lib/providers/{db,user}.provider.dart - lib/providers/db.provider.dart
- lib/providers/backup/backup.provider.dart
- import_rule_openapi: - import_rule_openapi:
message: openapi must only be used through ApiRepositories message: openapi must only be used through ApiRepositories
restrict: package:openapi restrict: package:openapi
allowed: allowed:
# requried / wanted # required / wanted
- lib/repositories/*_api.repository.dart - lib/repositories/*_api.repository.dart
# acceptable exceptions for the time being # acceptable exceptions for the time being
- lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 185, "android.injected.version.code" => 186,
"android.injected.version.name" => "1.127.0", "android.injected.version.name" => "1.128.0",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -7,8 +7,8 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:isar/isar.dart';
// ignore: depend_on_referenced_packages // ignore: depend_on_referenced_packages
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
@@ -39,7 +39,8 @@ class ImmichTestHelper {
static Future<void> loadApp(WidgetTester tester) async { static Future<void> loadApp(WidgetTester tester) async {
await EasyLocalization.ensureInitialized(); await EasyLocalization.ensureInitialized();
// Clear all data from Isar (reuse existing instance if available) // Clear all data from Isar (reuse existing instance if available)
final db = Isar.getInstance() ?? await app.loadDb(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db);
await Store.clear(); await Store.clear();
await db.writeTxn(() => db.clear()); await db.writeTxn(() => db.clear());
// Load main Widget // Load main Widget

View File

@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 195;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 195;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 195;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 195;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 195;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 195;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.126.1</string> <string>1.127.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>194</string> <string>195</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.127.0" version_number: "1.128.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -1,3 +1,6 @@
const int noDbId = -9223372036854775808; // from Isar const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1; const double downloadCompleted = -1;
const double downloadFailed = -2; const double downloadFailed = -2;
// Number of log entries to retain on app start
const int kLogTruncateLimit = 250;

View File

@@ -0,0 +1,16 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/log.model.dart';
abstract interface class ILogRepository {
Future<bool> insert(LogMessage log);
Future<bool> insertAll(Iterable<LogMessage> logs);
Future<List<LogMessage>> getAll();
Future<bool> deleteAll();
/// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs
Future<void> truncate({int limit = 250});
}

View File

@@ -0,0 +1,69 @@
// ignore_for_file: constant_identifier_names
import 'package:logging/logging.dart';
/// Log levels according to dart logging [Level]
enum LogLevel {
ALL,
FINEST,
FINER,
FINE,
CONFIG,
INFO,
WARNING,
SEVERE,
SHOUT,
OFF,
}
class LogMessage {
final String message;
final LogLevel level;
final DateTime createdAt;
final String? logger;
final String? error;
final String? stack;
const LogMessage({
required this.message,
required this.level,
required this.createdAt,
this.logger,
this.error,
this.stack,
});
@override
bool operator ==(covariant LogMessage other) {
if (identical(this, other)) return true;
return other.message == message &&
other.level == level &&
other.createdAt == createdAt &&
other.logger == logger &&
other.error == error &&
other.stack == stack;
}
@override
int get hashCode {
return message.hashCode ^
level.hashCode ^
createdAt.hashCode ^
logger.hashCode ^
error.hashCode ^
stack.hashCode;
}
@override
String toString() {
return '''LogMessage: {
message: $message,
level: $level,
createdAt: $createdAt,
logger: ${logger ?? '<NA>'},
error: ${error ?? '<NA>'},
stack: ${stack ?? '<NA>'},
}''';
}
}

View File

@@ -0,0 +1,153 @@
import 'dart:async';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:logging/logging.dart';
class LogService {
final ILogRepository _logRepository;
final IStoreRepository _storeRepository;
final List<LogMessage> _msgBuffer = [];
/// Whether to buffer logs in memory before writing to the database.
/// This is useful when logging in quick succession, as it increases performance
/// and reduces NAND wear. However, it may cause the logs to be lost in case of a crash / in isolates.
final bool _shouldBuffer;
Timer? _flushTimer;
late final StreamSubscription<LogRecord> _logSubscription;
LogService._(
this._logRepository,
this._storeRepository,
this._shouldBuffer,
) {
// Listen to log messages and write them to the database
_logSubscription = Logger.root.onRecord.listen(_writeLogToDatabase);
}
static LogService? _instance;
static LogService get I {
if (_instance == null) {
throw const LoggerUnInitializedException();
}
return _instance!;
}
static Future<LogService> init({
required ILogRepository logRepo,
required IStoreRepository storeRepo,
bool shouldBuffer = true,
}) async {
if (_instance != null) {
return _instance!;
}
_instance = await create(
logRepo: logRepo,
storeRepo: storeRepo,
shouldBuffer: shouldBuffer,
);
return _instance!;
}
static Future<LogService> create({
required ILogRepository logRepo,
required IStoreRepository storeRepo,
bool shouldBuffer = true,
}) async {
final instance = LogService._(logRepo, storeRepo, shouldBuffer);
// Truncate logs to 250
await logRepo.truncate(limit: kLogTruncateLimit);
// Get log level from store
final level = await instance._storeRepository.tryGet(StoreKey.logLevel);
if (level != null) {
Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO;
}
return instance;
}
Future<void> setlogLevel(LogLevel level) async {
await _storeRepository.insert(StoreKey.logLevel, level.index);
Logger.root.level = level.toLevel();
}
Future<List<LogMessage>> getMessages() async {
final logsFromDb = await _logRepository.getAll();
if (_msgBuffer.isNotEmpty) {
return [..._msgBuffer.reversed, ...logsFromDb];
}
return logsFromDb;
}
Future<void> clearLogs() async {
_flushTimer?.cancel();
_flushTimer = null;
_msgBuffer.clear();
await _logRepository.deleteAll();
}
/// Flush pending log messages to persistent storage
Future<void> flush() async {
if (_flushTimer == null) {
return;
}
_flushTimer!.cancel();
await _flushBufferToDatabase();
}
Future<void> dispose() {
_flushTimer?.cancel();
_logSubscription.cancel();
return _flushBufferToDatabase();
}
void _writeLogToDatabase(LogRecord r) {
final record = LogMessage(
message: r.message,
level: r.level.toLogLevel(),
createdAt: r.time,
logger: r.loggerName,
error: r.error?.toString(),
stack: r.stackTrace?.toString(),
);
if (_shouldBuffer) {
_msgBuffer.add(record);
_flushTimer ??= Timer(
const Duration(seconds: 5),
() => unawaited(_flushBufferToDatabase()),
);
} else {
unawaited(_logRepository.insert(record));
}
}
Future<void> _flushBufferToDatabase() async {
_flushTimer = null;
final buffer = [..._msgBuffer];
_msgBuffer.clear();
await _logRepository.insertAll(buffer);
}
}
class LoggerUnInitializedException implements Exception {
const LoggerUnInitializedException();
@override
String toString() => 'Logger is not initialized. Call init()';
}
/// Log levels according to dart logging [Level]
extension LevelDomainToInfraExtension on Level {
LogLevel toLogLevel() =>
LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ??
LogLevel.INFO;
}
extension on LogLevel {
Level toLevel() => Level.LEVELS.elementAtOrNull(index) ?? Level.INFO;
}

View File

@@ -1,50 +0,0 @@
// ignore_for_file: constant_identifier_names
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
part 'logger_message.entity.g.dart';
@Collection(inheritance: false)
class LoggerMessage {
Id id = Isar.autoIncrement;
String message;
String? details;
@Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO;
DateTime createdAt;
String? context1;
String? context2;
LoggerMessage({
required this.message,
required this.details,
required this.level,
required this.createdAt,
required this.context1,
required this.context2,
});
@override
String toString() {
return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
}
}
/// Log levels according to dart logging [Level]
enum LogLevel {
ALL,
FINEST,
FINER,
FINE,
CONFIG,
INFO,
WARNING,
SEVERE,
SHOUT,
OFF,
}
extension LevelExtension on Level {
LogLevel toLogLevel() => LogLevel.values[Level.LEVELS.indexOf(this)];
}

View File

@@ -0,0 +1,52 @@
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:isar/isar.dart';
part 'log.entity.g.dart';
@Collection(inheritance: false)
class LoggerMessage {
Id id = Isar.autoIncrement;
String message;
String? details;
@Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO;
DateTime createdAt;
String? context1;
String? context2;
LoggerMessage({
required this.message,
required this.details,
required this.level,
required this.createdAt,
required this.context1,
required this.context2,
});
@override
String toString() {
return 'LoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
}
LogMessage toDto() {
return LogMessage(
message: message,
level: level,
createdAt: createdAt,
logger: context1,
error: details,
stack: context2,
);
}
static LoggerMessage fromDto(LogMessage log) {
return LoggerMessage(
message: log.message,
details: log.error,
level: log.level,
createdAt: log.createdAt,
context1: log.logger,
context2: log.stack,
);
}
}

View File

@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'logger_message.entity.dart'; part of 'log.entity.dart';
// ************************************************************************** // **************************************************************************
// IsarCollectionGenerator // IsarCollectionGenerator

View File

@@ -0,0 +1,53 @@
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:isar/isar.dart';
class IsarLogRepository extends IsarDatabaseRepository
implements ILogRepository {
final Isar _db;
const IsarLogRepository(super.db) : _db = db;
@override
Future<bool> deleteAll() async {
await transaction(() async => await _db.loggerMessages.clear());
return true;
}
@override
Future<List<LogMessage>> getAll() async {
final logs =
await _db.loggerMessages.where().sortByCreatedAtDesc().findAll();
return logs.map((l) => l.toDto()).toList();
}
@override
Future<bool> insert(LogMessage log) async {
final logEntity = LoggerMessage.fromDto(log);
await transaction(() async {
await _db.loggerMessages.put(logEntity);
});
return true;
}
@override
Future<bool> insertAll(Iterable<LogMessage> logs) async {
await transaction(() async {
final logEntities =
logs.map((log) => LoggerMessage.fromDto(log)).toList();
await _db.loggerMessages.putAll(logEntities);
});
return true;
}
@override
Future<void> truncate({int limit = 250}) async {
await transaction(() async {
final count = await _db.loggerMessages.count();
if (count <= limit) return;
final toRemove = count - limit;
await _db.loggerMessages.where().limit(toRemove).deleteAll();
});
}
}

View File

@@ -1,7 +1,7 @@
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:immich_mobile/interfaces/database.interface.dart';
abstract interface class IBackupRepository implements IDatabaseRepository { abstract interface class IBackupAlbumRepository implements IDatabaseRepository {
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}); Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort});
Future<List<String>> getIdsBySelection(BackupSelection backup); Future<List<String>> getIdsBySelection(BackupSelection backup);

View File

@@ -22,6 +22,10 @@ abstract interface class IUserRepository implements IDatabaseRepository {
Future<User> me(); Future<User> me();
Future<void> clearTable(); Future<void> clearTable();
Future<List<int>> getTimelineUserIds(int id);
Stream<List<int>> watchTimelineUsers(int id);
} }
enum UserSort { id } enum UserSort { id }

View File

@@ -10,20 +10,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
@@ -33,23 +20,22 @@ import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/download.dart'; import 'package:immich_mobile/utils/download.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/migration.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:timezone/data/latest.dart'; import 'package:timezone/data/latest.dart';
void main() async { void main() async {
ImmichWidgetsBinding(); ImmichWidgetsBinding();
final db = await loadDb(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db);
await initApp(); await initApp();
await migrateDatabaseIfNeeded(db); await migrateDatabaseIfNeeded(db);
HttpOverrides.global = HttpSSLCertOverride(); HttpOverrides.global = HttpSSLCertOverride();
@@ -80,9 +66,6 @@ Future<void> initApp() async {
await DynamicTheme.fetchSystemPalette(); await DynamicTheme.fetchSystemPalette();
// Initialize Immich Logger Service
ImmichLogger();
final log = Logger("ImmichErrorLogger"); final log = Logger("ImmichErrorLogger");
FlutterError.onError = (details) { FlutterError.onError = (details) {
@@ -122,29 +105,6 @@ Future<void> initApp() async {
await FileDownloader().trackTasks(); await FileDownloader().trackTasks();
} }
Future<Isar> loadDb() async {
final dir = await getApplicationDocumentsDirectory();
Isar db = await Isar.open(
[
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
if (Platform.isAndroid) AndroidDeviceAssetSchema,
if (Platform.isIOS) IOSDeviceAssetSchema,
],
directory: dir.path,
maxSizeMiB: 1024,
);
await StoreService.init(storeRepository: IsarStoreRepository(db));
return db;
}
class ImmichApp extends ConsumerStatefulWidget { class ImmichApp extends ConsumerStatefulWidget {
const ImmichApp({super.key}); const ImmichApp({super.key});

View File

@@ -2,10 +2,11 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -17,8 +18,11 @@ class AppLogPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final immichLogger = ImmichLogger(); final immichLogger = LogService.I;
final logMessages = useState(immichLogger.messages); final shouldReload = useState(false);
final logMessages = useFuture(
useMemoized(() => immichLogger.getMessages(), [shouldReload.value]),
);
Widget colorStatusIndicator(Color color) { Widget colorStatusIndicator(Color color) {
return Column( return Column(
@@ -71,7 +75,7 @@ class AppLogPage extends HookConsumerWidget {
), ),
onPressed: () { onPressed: () {
immichLogger.clearLogs(); immichLogger.clearLogs();
logMessages.value = []; shouldReload.value = !shouldReload.value;
}, },
), ),
Builder( Builder(
@@ -84,7 +88,7 @@ class AppLogPage extends HookConsumerWidget {
size: 20.0, size: 20.0,
), ),
onPressed: () { onPressed: () {
immichLogger.shareLogs(iconContext); ImmichLogger.shareLogs(iconContext);
}, },
); );
}, },
@@ -105,9 +109,9 @@ class AppLogPage extends HookConsumerWidget {
separatorBuilder: (context, index) { separatorBuilder: (context, index) {
return const Divider(height: 0); return const Divider(height: 0);
}, },
itemCount: logMessages.value.length, itemCount: logMessages.data?.length ?? 0,
itemBuilder: (context, index) { itemBuilder: (context, index) {
var logMessage = logMessages.value[index]; var logMessage = logMessages.data![index];
return ListTile( return ListTile(
onTap: () => context.pushRoute( onTap: () => context.pushRoute(
AppLogDetailRoute( AppLogDetailRoute(
@@ -128,7 +132,7 @@ class AppLogPage extends HookConsumerWidget {
), ),
), ),
subtitle: Text( subtitle: Text(
"at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}", "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.logger}",
style: TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
color: context.colorScheme.onSurfaceSecondary, color: context.colorScheme.onSurfaceSecondary,

View File

@@ -1,15 +1,15 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@RoutePage() @RoutePage()
class AppLogDetailPage extends HookConsumerWidget { class AppLogDetailPage extends HookConsumerWidget {
const AppLogDetailPage({super.key, required this.logMessage}); const AppLogDetailPage({super.key, required this.logMessage});
final LoggerMessage logMessage; final LogMessage logMessage;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -126,14 +126,14 @@ class AppLogDetailPage extends HookConsumerWidget {
child: ListView( child: ListView(
children: [ children: [
buildTextWithCopyButton("MESSAGE", logMessage.message), buildTextWithCopyButton("MESSAGE", logMessage.message),
if (logMessage.details != null) if (logMessage.error != null)
buildTextWithCopyButton("DETAILS", logMessage.details.toString()), buildTextWithCopyButton("DETAILS", logMessage.error.toString()),
if (logMessage.context1 != null) if (logMessage.logger != null)
buildLogContext1(logMessage.context1.toString()), buildLogContext1(logMessage.logger.toString()),
if (logMessage.context2 != null) if (logMessage.stack != null)
buildTextWithCopyButton( buildTextWithCopyButton(
"STACK TRACE", "STACK TRACE",
logMessage.context2.toString(), logMessage.stack.toString(),
), ),
], ],
), ),

View File

@@ -110,7 +110,7 @@ class PhotosPage extends HookConsumerWidget {
: const SizedBox(), : const SizedBox(),
renderListProvider: timelineUsers.length > 1 renderListProvider: timelineUsers.length > 1
? multiUsersTimelineProvider(timelineUsers) ? multiUsersTimelineProvider(timelineUsers)
: singleUserTimelineProvider(currentUser!.isarId), : singleUserTimelineProvider(currentUser?.isarId),
buildLoadingIndicator: buildLoadingIndicator, buildLoadingIndicator: buildLoadingIndicator,
onRefresh: refreshAssets, onRefresh: refreshAssets,
stackEnabled: true, stackEnabled: true,

View File

@@ -1,20 +1,22 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
enum AppLifeCycleEnum { enum AppLifeCycleEnum {
@@ -112,7 +114,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(websocketProvider.notifier).disconnect(); _ref.read(websocketProvider.notifier).disconnect();
} }
ImmichLogger().flush(); unawaited(LogService.I.flush());
} }
void handleAppDetached() { void handleAppDetached() {

View File

@@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart';
@@ -23,21 +23,34 @@ import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/backup_album.service.dart';
import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backupProvider =
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authProvider),
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
ref.watch(backupAlbumServiceProvider),
ref,
);
});
class BackupNotifier extends StateNotifier<BackUpState> { class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier( BackupNotifier(
this._backupService, this._backupService,
@@ -45,10 +58,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._authState, this._authState,
this._backgroundService, this._backgroundService,
this._galleryPermissionNotifier, this._galleryPermissionNotifier,
this._db,
this._albumMediaRepository, this._albumMediaRepository,
this._fileMediaRepository, this._fileMediaRepository,
this._backupRepository, this._backupAlbumService,
this.ref, this.ref,
) : super( ) : super(
BackUpState( BackUpState(
@@ -96,10 +108,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final AuthState _authState; final AuthState _authState;
final BackgroundService _backgroundService; final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier; final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db;
final IAlbumMediaRepository _albumMediaRepository; final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository; final IFileMediaRepository _fileMediaRepository;
final IBackupRepository _backupRepository; final BackupAlbumService _backupAlbumService;
final Ref ref; final Ref ref;
/// ///
@@ -260,9 +271,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(availableAlbums: availableAlbums); state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums = final List<BackupAlbum> excludedBackupAlbums =
await _backupRepository.getAllBySelection(BackupSelection.exclude); await _backupAlbumService.getAllBySelection(BackupSelection.exclude);
final List<BackupAlbum> selectedBackupAlbums = final List<BackupAlbum> selectedBackupAlbums =
await _backupRepository.getAllBySelection(BackupSelection.select); await _backupAlbumService.getAllBySelection(BackupSelection.select);
final Set<AvailableAlbum> selectedAlbums = {}; final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) { for (final BackupAlbum ba in selectedBackupAlbums) {
@@ -439,7 +450,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
/// Save user selection of selected albums and excluded albums to database /// Save user selection of selected albums and excluded albums to database
Future<void> _updatePersistentAlbumsSelection() { Future<void> _updatePersistentAlbumsSelection() async {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
final selected = state.selectedBackupAlbums.map( final selected = state.selectedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select), (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
@@ -447,29 +458,30 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final excluded = state.excludedBackupAlbums.map( final excluded = state.excludedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude), (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
); );
final backupAlbums = selected.followedBy(excluded).toList(); final candidates = selected.followedBy(excluded).toList();
backupAlbums.sortBy((e) => e.id); candidates.sortBy((e) => e.id);
return _db.writeTxn(() async {
final dbAlbums = await _db.backupAlbums.where().sortById().findAll(); final savedBackupAlbums =
final List<int> toDelete = []; await _backupAlbumService.getAll(sort: BackupAlbumSort.id);
final List<BackupAlbum> toUpsert = []; final List<int> toDelete = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made final List<BackupAlbum> toUpsert = [];
diffSortedListsSync(
dbAlbums, diffSortedListsSync(
backupAlbums, savedBackupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), candidates,
both: (BackupAlbum a, BackupAlbum b) { compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
b.lastBackup = both: (BackupAlbum a, BackupAlbum b) {
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup; b.lastBackup =
toUpsert.add(b); a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
return true; toUpsert.add(b);
}, return true;
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId), },
onlySecond: (BackupAlbum b) => toUpsert.add(b), onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
); onlySecond: (BackupAlbum b) => toUpsert.add(b),
await _db.backupAlbums.deleteAll(toDelete); );
await _db.backupAlbums.putAll(toUpsert);
}); await _backupAlbumService.deleteAll(toDelete);
await _backupAlbumService.updateAll(toUpsert);
} }
/// Invoke backup process /// Invoke backup process
@@ -686,14 +698,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
Future<void> resumeBackup() async { Future<void> resumeBackup() async {
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums final List<BackupAlbum> selectedBackupAlbums =
.filter() await _backupAlbumService.getAllBySelection(BackupSelection.select);
.selectionEqualTo(BackupSelection.select) final List<BackupAlbum> excludedBackupAlbums =
.findAll(); await _backupAlbumService.getAllBySelection(BackupSelection.exclude);
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.exclude)
.findAll();
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums; Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums; Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (selectedAlbums.isNotEmpty) { if (selectedAlbums.isNotEmpty) {
@@ -756,23 +764,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
BackUpProgressEnum get backupProgress => state.backupProgress; BackUpProgressEnum get backupProgress => state.backupProgress;
void updateBackupProgress(BackUpProgressEnum backupProgress) { void updateBackupProgress(BackUpProgressEnum backupProgress) {
state = state.copyWith(backupProgress: backupProgress); state = state.copyWith(backupProgress: backupProgress);
} }
} }
final backupProvider =
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authProvider),
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(dbProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref,
);
});

View File

@@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.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/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
@@ -24,6 +23,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/services/backup_album.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
@@ -37,7 +37,7 @@ final manualUploadProvider =
ref.watch(localNotificationService), ref.watch(localNotificationService),
ref.watch(backupProvider.notifier), ref.watch(backupProvider.notifier),
ref.watch(backupServiceProvider), ref.watch(backupServiceProvider),
ref.watch(backupRepositoryProvider), ref.watch(backupAlbumServiceProvider),
ref, ref,
); );
}); });
@@ -47,14 +47,14 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final LocalNotificationService _localNotificationService; final LocalNotificationService _localNotificationService;
final BackupNotifier _backupProvider; final BackupNotifier _backupProvider;
final BackupService _backupService; final BackupService _backupService;
final BackupRepository _backupRepository; final BackupAlbumService _backupAlbumService;
final Ref ref; final Ref ref;
ManualUploadNotifier( ManualUploadNotifier(
this._localNotificationService, this._localNotificationService,
this._backupProvider, this._backupProvider,
this._backupService, this._backupService,
this._backupRepository, this._backupAlbumService,
this.ref, this.ref,
) : super( ) : super(
ManualUploadState( ManualUploadState(
@@ -210,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
} }
final selectedBackupAlbums = final selectedBackupAlbums =
await _backupRepository.getAllBySelection(BackupSelection.select); await _backupAlbumService.getAllBySelection(BackupSelection.select);
final excludedBackupAlbums = final excludedBackupAlbums = await _backupAlbumService
await _backupRepository.getAllBySelection(BackupSelection.exclude); .getAllBySelection(BackupSelection.exclude);
// Get candidates from selected albums and excluded albums // Get candidates from selected albums and excluded albums
Set<BackupCandidate> candidates = Set<BackupCandidate> candidates =

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/user.entity.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<User>> { class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
final PartnerService _partnerService; final PartnerService _partnerService;
late final StreamSubscription<List<User>> streamSub;
PartnerSharedWithNotifier(this._partnerService) : super([]) { PartnerSharedWithNotifier(this._partnerService) : super([]) {
Function eq = const ListEquality<User>().equals; Function eq = const ListEquality<User>().equals;
@@ -16,7 +17,7 @@ class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
state = partners; state = partners;
} }
}).then((_) { }).then((_) {
_partnerService.watchSharedWith().listen((partners) { streamSub = _partnerService.watchSharedWith().listen((partners) {
if (!eq(state, partners)) { if (!eq(state, partners)) {
state = partners; state = partners;
} }
@@ -27,6 +28,14 @@ class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
Future<bool> updatePartner(User partner, {required bool inTimeline}) { Future<bool> updatePartner(User partner, {required bool inTimeline}) {
return _partnerService.updatePartner(partner, inTimeline: inTimeline); return _partnerService.updatePartner(partner, inTimeline: inTimeline);
} }
@override
void dispose() {
if (mounted) {
streamSub.cancel();
}
super.dispose();
}
} }
final partnerSharedWithProvider = final partnerSharedWithProvider =
@@ -38,6 +47,7 @@ final partnerSharedWithProvider =
class PartnerSharedByNotifier extends StateNotifier<List<User>> { class PartnerSharedByNotifier extends StateNotifier<List<User>> {
final PartnerService _partnerService; final PartnerService _partnerService;
late final StreamSubscription<List<User>> streamSub;
PartnerSharedByNotifier(this._partnerService) : super([]) { PartnerSharedByNotifier(this._partnerService) : super([]) {
Function eq = const ListEquality<User>().equals; Function eq = const ListEquality<User>().equals;
@@ -54,11 +64,11 @@ class PartnerSharedByNotifier extends StateNotifier<List<User>> {
}); });
} }
late final StreamSubscription<List<User>> streamSub;
@override @override
void dispose() { void dispose() {
streamSub.cancel(); if (mounted) {
streamSub.cancel();
}
super.dispose(); super.dispose();
} }
} }

View File

@@ -5,8 +5,12 @@ import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
final singleUserTimelineProvider = StreamProvider.family<RenderList, int>( final singleUserTimelineProvider = StreamProvider.family<RenderList, int?>(
(ref, userId) { (ref, userId) {
if (userId == null) {
return const Stream.empty();
}
ref.watch(localeProvider); ref.watch(localeProvider);
final timelineService = ref.watch(timelineServiceProvider); final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchHomeTimeline(userId); return timelineService.watchHomeTimeline(userId);

View File

@@ -5,9 +5,8 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:isar/isar.dart'; import 'package:immich_mobile/services/user.service.dart';
class CurrentUserProvider extends StateNotifier<User?> { class CurrentUserProvider extends StateNotifier<User?> {
CurrentUserProvider(this._apiService) : super(null) { CurrentUserProvider(this._apiService) : super(null) {
@@ -47,18 +46,14 @@ final currentUserProvider =
}); });
class TimelineUserIdsProvider extends StateNotifier<List<int>> { class TimelineUserIdsProvider extends StateNotifier<List<int>> {
TimelineUserIdsProvider(Isar db, User? currentUser) : super([]) { TimelineUserIdsProvider(this._userService) : super([]) {
final query = db.users _userService.getTimelineUserIds().then((users) => state = users);
.filter() streamSub =
.inTimelineEqualTo(true) _userService.watchTimelineUserIds().listen((users) => state = users);
.or()
.isarIdEqualTo(currentUser?.isarId ?? Isar.autoIncrement)
.isarIdProperty();
query.findAll().then((users) => state = users);
streamSub = query.watch().listen((users) => state = users);
} }
late final StreamSubscription<List<int>> streamSub; late final StreamSubscription<List<int>> streamSub;
final UserService _userService;
@override @override
void dispose() { void dispose() {
@@ -69,8 +64,5 @@ class TimelineUserIdsProvider extends StateNotifier<List<int>> {
final timelineUsersIdsProvider = final timelineUsersIdsProvider =
StateNotifierProvider<TimelineUserIdsProvider, List<int>>((ref) { StateNotifierProvider<TimelineUserIdsProvider, List<int>>((ref) {
return TimelineUserIdsProvider( return TimelineUserIdsProvider(ref.watch(userServiceProvider));
ref.watch(dbProvider),
ref.watch(currentUserProvider),
);
}); });

View File

@@ -1,15 +1,16 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
final backupRepositoryProvider = final backupAlbumRepositoryProvider =
Provider((ref) => BackupRepository(ref.watch(dbProvider))); Provider((ref) => BackupAlbumRepository(ref.watch(dbProvider)));
class BackupRepository extends DatabaseRepository implements IBackupRepository { class BackupAlbumRepository extends DatabaseRepository
BackupRepository(super.db); implements IBackupAlbumRepository {
BackupAlbumRepository(super.db);
@override @override
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) { Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) {

View File

@@ -70,4 +70,26 @@ class UserRepository extends DatabaseRepository implements IUserRepository {
await db.users.clear(); await db.users.clear();
}); });
} }
@override
Future<List<int>> getTimelineUserIds(int id) {
return db.users
.filter()
.inTimelineEqualTo(true)
.or()
.isarIdEqualTo(id)
.isarIdProperty()
.findAll();
}
@override
Stream<List<int>> watchTimelineUsers(int id) {
return db.users
.filter()
.inTimelineEqualTo(true)
.or()
.isarIdEqualTo(id)
.isarIdProperty()
.watch();
}
} }

View File

@@ -1,44 +1,48 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/pages/backup/album_preview.page.dart';
import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
import 'package:immich_mobile/pages/albums/albums.page.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/pages/library/local_albums.page.dart';
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
import 'package:immich_mobile/pages/library/places/places_collection.page.dart';
import 'package:immich_mobile/pages/library/library.page.dart';
import 'package:immich_mobile/pages/common/activities.page.dart';
import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart';
import 'package:immich_mobile/pages/album/album_asset_selection.page.dart'; import 'package:immich_mobile/pages/album/album_asset_selection.page.dart';
import 'package:immich_mobile/pages/album/album_options.page.dart'; import 'package:immich_mobile/pages/album/album_options.page.dart';
import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart';
import 'package:immich_mobile/pages/album/album_viewer.page.dart'; import 'package:immich_mobile/pages/album/album_viewer.page.dart';
import 'package:immich_mobile/pages/albums/albums.page.dart';
import 'package:immich_mobile/pages/backup/album_preview.page.dart';
import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
import 'package:immich_mobile/pages/common/activities.page.dart';
import 'package:immich_mobile/pages/common/app_log.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart';
import 'package:immich_mobile/pages/common/app_log_detail.page.dart'; import 'package:immich_mobile/pages/common/app_log_detail.page.dart';
import 'package:immich_mobile/pages/common/create_album.page.dart'; import 'package:immich_mobile/pages/common/create_album.page.dart';
import 'package:immich_mobile/pages/common/gallery_viewer.page.dart'; import 'package:immich_mobile/pages/common/gallery_viewer.page.dart';
import 'package:immich_mobile/pages/common/headers_settings.page.dart'; import 'package:immich_mobile/pages/common/headers_settings.page.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/pages/common/tab_controller.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart';
import 'package:immich_mobile/pages/editing/crop.page.dart'; import 'package:immich_mobile/pages/editing/crop.page.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart';
import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/editing/filter.page.dart';
import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart';
import 'package:immich_mobile/pages/library/favorite.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart';
import 'package:immich_mobile/pages/library/library.page.dart';
import 'package:immich_mobile/pages/library/local_albums.page.dart';
import 'package:immich_mobile/pages/library/partner/partner.page.dart';
import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart';
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
import 'package:immich_mobile/pages/library/places/places_collection.page.dart';
import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart';
import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart';
import 'package:immich_mobile/pages/library/trash.page.dart'; import 'package:immich_mobile/pages/library/trash.page.dart';
import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart';
import 'package:immich_mobile/pages/login/login.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart';
@@ -54,10 +58,6 @@ import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart';
import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/person_result.page.dart';
import 'package:immich_mobile/pages/search/recently_added.page.dart'; import 'package:immich_mobile/pages/search/recently_added.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/library/partner/partner.page.dart';
import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart';
import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart';
import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';

View File

@@ -386,7 +386,7 @@ class AllVideosRoute extends PageRouteInfo<void> {
class AppLogDetailRoute extends PageRouteInfo<AppLogDetailRouteArgs> { class AppLogDetailRoute extends PageRouteInfo<AppLogDetailRouteArgs> {
AppLogDetailRoute({ AppLogDetailRoute({
Key? key, Key? key,
required LoggerMessage logMessage, required LogMessage logMessage,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
AppLogDetailRoute.name, AppLogDetailRoute.name,
@@ -419,7 +419,7 @@ class AppLogDetailRouteArgs {
final Key? key; final Key? key;
final LoggerMessage logMessage; final LogMessage logMessage;
@override @override
String toString() { String toString() {

View File

@@ -16,7 +16,7 @@ import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_api.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/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album.repository.dart';
@@ -36,7 +36,7 @@ final albumServiceProvider = Provider(
ref.watch(entityServiceProvider), ref.watch(entityServiceProvider),
ref.watch(albumRepositoryProvider), ref.watch(albumRepositoryProvider),
ref.watch(assetRepositoryProvider), ref.watch(assetRepositoryProvider),
ref.watch(backupRepositoryProvider), ref.watch(backupAlbumRepositoryProvider),
ref.watch(albumMediaRepositoryProvider), ref.watch(albumMediaRepositoryProvider),
ref.watch(albumApiRepositoryProvider), ref.watch(albumApiRepositoryProvider),
), ),
@@ -48,7 +48,7 @@ class AlbumService {
final EntityService _entityService; final EntityService _entityService;
final IAlbumRepository _albumRepository; final IAlbumRepository _albumRepository;
final IAssetRepository _assetRepository; final IAssetRepository _assetRepository;
final IBackupRepository _backupAlbumRepository; final IBackupAlbumRepository _backupAlbumRepository;
final IAlbumMediaRepository _albumMediaRepository; final IAlbumMediaRepository _albumMediaRepository;
final IAlbumApiRepository _albumApiRepository; final IAlbumApiRepository _albumApiRepository;
final Logger _log = Logger('AlbumService'); final Logger _log = Logger('AlbumService');

View File

@@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart';
@@ -39,7 +39,7 @@ final assetServiceProvider = Provider(
ref.watch(exifInfoRepositoryProvider), ref.watch(exifInfoRepositoryProvider),
ref.watch(userRepositoryProvider), ref.watch(userRepositoryProvider),
ref.watch(etagRepositoryProvider), ref.watch(etagRepositoryProvider),
ref.watch(backupRepositoryProvider), ref.watch(backupAlbumRepositoryProvider),
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(syncServiceProvider), ref.watch(syncServiceProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
@@ -55,7 +55,7 @@ class AssetService {
final IExifInfoRepository _exifInfoRepository; final IExifInfoRepository _exifInfoRepository;
final IUserRepository _userRepository; final IUserRepository _userRepository;
final IETagRepository _etagRepository; final IETagRepository _etagRepository;
final IBackupRepository _backupRepository; final IBackupAlbumRepository _backupRepository;
final ApiService _apiService; final ApiService _apiService;
final SyncService _syncService; final SyncService _syncService;
final UserService _userService; final UserService _userService;

View File

@@ -14,8 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@@ -48,6 +47,7 @@ import 'package:immich_mobile/services/network.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/services/user.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:network_info_plus/network_info_plus.dart'; import 'package:network_info_plus/network_info_plus.dart';
@@ -369,7 +369,8 @@ class BackgroundService {
} }
Future<bool> _onAssetsChanged() async { Future<bool> _onAssetsChanged() async {
final db = await loadDb(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db);
HttpOverrides.global = HttpSSLCertOverride(); HttpOverrides.global = HttpSSLCertOverride();
ApiService apiService = ApiService(); ApiService apiService = ApiService();
@@ -377,7 +378,7 @@ class BackgroundService {
AppSettingsService settingsService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService();
AlbumRepository albumRepository = AlbumRepository(db); AlbumRepository albumRepository = AlbumRepository(db);
AssetRepository assetRepository = AssetRepository(db); AssetRepository assetRepository = AssetRepository(db);
BackupRepository backupRepository = BackupRepository(db); BackupAlbumRepository backupRepository = BackupAlbumRepository(db);
ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); ExifInfoRepository exifInfoRepository = ExifInfoRepository(db);
ETagRepository eTagRepository = ETagRepository(db); ETagRepository eTagRepository = ETagRepository(db);
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
@@ -719,7 +720,6 @@ enum IosBackgroundTask { fetch, processing }
/// entry point called by Kotlin/Java code; needs to be a top-level function /// entry point called by Kotlin/Java code; needs to be a top-level function
@pragma('vm:entry-point') @pragma('vm:entry-point')
void _nativeEntry() { void _nativeEntry() {
HttpOverrides.global = HttpSSLCertOverride();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
BackgroundService backgroundService = BackgroundService(); BackgroundService backgroundService = BackgroundService();

View File

@@ -0,0 +1,34 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
final backupAlbumServiceProvider = Provider<BackupAlbumService>((ref) {
return BackupAlbumService(ref.watch(backupAlbumRepositoryProvider));
});
class BackupAlbumService {
final IBackupAlbumRepository _backupAlbumRepository;
BackupAlbumService(this._backupAlbumRepository);
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) {
return _backupAlbumRepository.getAll(sort: sort);
}
Future<List<String>> getIdsBySelection(BackupSelection backup) {
return _backupAlbumRepository.getIdsBySelection(backup);
}
Future<List<BackupAlbum>> getAllBySelection(BackupSelection backup) {
return _backupAlbumRepository.getAllBySelection(backup);
}
Future<void> deleteAll(List<int> ids) {
return _backupAlbumRepository.deleteAll(ids);
}
Future<void> updateAll(List<BackupAlbum> backupAlbums) {
return _backupAlbumRepository.updateAll(backupAlbums);
}
}

View File

@@ -16,6 +16,7 @@ import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
/// Finds duplicates originating from missing EXIF information /// Finds duplicates originating from missing EXIF information
@@ -123,6 +124,8 @@ class BackupVerificationService {
assert(tuple.deleteCandidates.length == tuple.originals.length); assert(tuple.deleteCandidates.length == tuple.originals.length);
final List<Asset> result = []; final List<Asset> result = [];
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db);
await tuple.fileMediaRepository.enableBackgroundAccess(); await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService(); final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint); apiService.setEndpoint(tuple.endpoint);

View File

@@ -2,11 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -18,75 +14,10 @@ import 'package:share_plus/share_plus.dart';
/// ///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
/// and generate a csv file. /// and generate a csv file.
class ImmichLogger { abstract final class ImmichLogger {
static final ImmichLogger _instance = ImmichLogger._internal(); const ImmichLogger();
final maxLogEntries = 500;
final Isar _db = Isar.getInstance()!;
List<LoggerMessage> _msgBuffer = [];
Timer? _timer;
factory ImmichLogger() => _instance; static Future<void> shareLogs(BuildContext context) async {
ImmichLogger._internal() {
_removeOverflowMessages();
final int levelId = Store.get(StoreKey.logLevel, 5); // 5 is INFO
Logger.root.level = Level.LEVELS[levelId];
Logger.root.onRecord.listen(_writeLogToDatabase);
}
set level(Level level) => Logger.root.level = level;
List<LoggerMessage> get messages {
final inDb =
_db.loggerMessages.where(sort: Sort.desc).anyId().findAllSync();
return _msgBuffer.isEmpty ? inDb : _msgBuffer.reversed.toList() + inDb;
}
void _removeOverflowMessages() {
final msgCount = _db.loggerMessages.countSync();
if (msgCount > maxLogEntries) {
final numberOfEntryToBeDeleted = msgCount - maxLogEntries;
_db.writeTxn(
() => _db.loggerMessages
.where()
.limit(numberOfEntryToBeDeleted)
.deleteAll(),
);
}
}
void _writeLogToDatabase(LogRecord record) {
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
final lm = LoggerMessage(
message: record.message,
details: record.error?.toString(),
level: record.level.toLogLevel(),
createdAt: record.time,
context1: record.loggerName,
context2: record.stackTrace?.toString(),
);
_msgBuffer.add(lm);
// delayed batch writing to database: increases performance when logging
// messages in quick succession and reduces NAND wear
_timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase);
}
void _flushBufferToDatabase() {
_timer = null;
final buffer = _msgBuffer;
_msgBuffer = [];
_db.writeTxn(() => _db.loggerMessages.putAll(buffer));
}
void clearLogs() {
_timer?.cancel();
_timer = null;
_msgBuffer.clear();
_db.writeTxn(() => _db.loggerMessages.clear());
}
Future<void> shareLogs(BuildContext context) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final dateTime = DateTime.now().toIso8601String(); final dateTime = DateTime.now().toIso8601String();
final filePath = '${tempDir.path}/Immich_log_$dateTime.log'; final filePath = '${tempDir.path}/Immich_log_$dateTime.log';
@@ -94,13 +25,13 @@ class ImmichLogger {
final io = logFile.openWrite(); final io = logFile.openWrite();
try { try {
// Write messages // Write messages
for (final m in messages) { for (final m in await LogService.I.getMessages()) {
final created = m.createdAt; final created = m.createdAt;
final level = m.level.name.padRight(8); final level = m.level.name.padRight(8);
final logger = (m.context1 ?? "<UNKNOWN_LOGGER>").padRight(20); final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20);
final message = m.message; final message = m.message;
final error = m.details != null ? " ${m.details} |" : ""; final error = m.error == null ? "" : " ${m.error} |";
final stack = m.context2 != null ? "\n${m.context2!}" : ""; final stack = m.stack == null ? "" : "\n${m.stack!}";
io.write('$created | $level | $logger | $message |$error$stack\n'); io.write('$created | $level | $logger | $message |$error$stack\n');
} }
} finally { } finally {
@@ -115,16 +46,6 @@ class ImmichLogger {
[XFile(filePath)], [XFile(filePath)],
subject: "Immich logs $dateTime", subject: "Immich logs $dateTime",
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
).then( ).then((value) => logFile.delete());
(value) => logFile.delete(),
);
}
/// Flush pending log messages to persistent storage
void flush() {
if (_timer != null) {
_timer!.cancel();
_db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer));
}
} }
} }

View File

@@ -107,4 +107,14 @@ class UserService {
Future<void> clearTable() { Future<void> clearTable() {
return _userRepository.clearTable(); return _userRepository.clearTable();
} }
Future<List<int>> getTimelineUserIds() async {
final me = await _userRepository.me();
return _userRepository.getTimelineUserIds(me.isarId);
}
Stream<List<int>> watchTimelineUserIds() async* {
final me = await _userRepository.me();
yield* _userRepository.watchTimelineUsers(me.isarId);
}
} }

View File

@@ -0,0 +1,56 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
abstract final class Bootstrap {
static Future<Isar> initIsar() async {
if (Isar.getInstance() != null) {
return Isar.getInstance()!;
}
final dir = await getApplicationDocumentsDirectory();
return await Isar.open(
[
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
if (Platform.isAndroid) AndroidDeviceAssetSchema,
if (Platform.isIOS) IOSDeviceAssetSchema,
],
directory: dir.path,
maxSizeMiB: 1024,
inspector: kDebugMode,
);
}
static Future<void> initDomain(Isar db) async {
await StoreService.init(storeRepository: IsarStoreRepository(db));
await LogService.init(
logRepo: IsarLogRepository(db),
storeRepo: IsarStoreRepository(db),
);
}
}

View File

@@ -1,18 +1,19 @@
import 'dart:io'; import 'dart:io';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart'; import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -33,7 +34,8 @@ class AdvancedSettings extends HookConsumerWidget {
useValueChanged( useValueChanged(
levelId.value, levelId.value,
(_, __) => ImmichLogger().level = Level.LEVELS[levelId.value], (_, __) =>
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
); );
final advancedSettings = [ final advancedSettings = [

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.127.0 - API version: 1.128.0
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -25,11 +25,13 @@ class SourceType {
static const machineLearning = SourceType._(r'machine-learning'); static const machineLearning = SourceType._(r'machine-learning');
static const exif = SourceType._(r'exif'); static const exif = SourceType._(r'exif');
static const manual = SourceType._(r'manual');
/// List of all possible values in this [enum][SourceType]. /// List of all possible values in this [enum][SourceType].
static const values = <SourceType>[ static const values = <SourceType>[
machineLearning, machineLearning,
exif, exif,
manual,
]; ];
static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value); static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value);
@@ -70,6 +72,7 @@ class SourceTypeTypeTransformer {
switch (data) { switch (data) {
case r'machine-learning': return SourceType.machineLearning; case r'machine-learning': return SourceType.machineLearning;
case r'exif': return SourceType.exif; case r'exif': return SourceType.exif;
case r'manual': return SourceType.manual;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');

View File

@@ -407,7 +407,7 @@ packages:
source: hosted source: hosted
version: "0.0.2" version: "0.0.2"
fake_async: fake_async:
dependency: transitive dependency: "direct dev"
description: description:
name: fake_async name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.127.0+185 version: 1.128.0+186
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
@@ -113,6 +113,7 @@ dev_dependencies:
mocktail: ^1.0.3 mocktail: ^1.0.3
immich_mobile_immich_lint: immich_mobile_immich_lint:
path: './immich_lint' path: './immich_lint'
fake_async: ^1.3.1
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -0,0 +1,186 @@
import 'package:collection/collection.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:logging/logging.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
import '../../test_utils.dart';
final _kInfoLog = LogMessage(
message: '#Info Message',
level: LogLevel.INFO,
createdAt: DateTime(2025, 2, 26),
logger: 'Info Logger',
);
final _kWarnLog = LogMessage(
message: '#Warn Message',
level: LogLevel.WARNING,
createdAt: DateTime(2025, 2, 27),
logger: 'Warn Logger',
);
void main() {
late LogService sut;
late ILogRepository mockLogRepo;
late IStoreRepository mockStoreRepo;
setUp(() async {
mockLogRepo = MockLogRepository();
mockStoreRepo = MockStoreRepository();
registerFallbackValue(_kInfoLog);
when(() => mockLogRepo.truncate(limit: any(named: 'limit')))
.thenAnswer((_) async => {});
when(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel))
.thenAnswer((_) async => LogLevel.FINE.index);
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
sut =
await LogService.create(logRepo: mockLogRepo, storeRepo: mockStoreRepo);
});
tearDown(() async {
await sut.dispose();
});
group("Log Service Init:", () {
test('Truncates the existing logs on init', () {
final limit =
verify(() => mockLogRepo.truncate(limit: captureAny(named: 'limit')))
.captured
.firstOrNull as int?;
expect(limit, kLogTruncateLimit);
});
test('Sets log level based on the store setting', () {
verify(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).called(1);
expect(Logger.root.level, Level.FINE);
});
});
group("Log Service Set Level:", () {
setUp(() async {
when(() => mockStoreRepo.insert<int>(StoreKey.logLevel, any()))
.thenAnswer((_) async => true);
await sut.setlogLevel(LogLevel.SHOUT);
});
test('Updates the log level in store', () {
final index = verify(
() => mockStoreRepo.insert<int>(StoreKey.logLevel, captureAny()),
).captured.firstOrNull;
expect(index, LogLevel.SHOUT.index);
});
test('Sets log level on logger', () {
expect(Logger.root.level, Level.SHOUT);
});
});
group("Log Service Buffer:", () {
test('Buffers logs until timer elapses', () {
TestUtils.fakeAsync((time) async {
sut = await LogService.create(
logRepo: mockLogRepo,
storeRepo: mockStoreRepo,
shouldBuffer: true,
);
final logger = Logger(_kInfoLog.logger!);
logger.info(_kInfoLog.message);
expect(await sut.getMessages(), hasLength(1));
logger.warning(_kWarnLog.message);
expect(await sut.getMessages(), hasLength(2));
time.elapse(const Duration(seconds: 6));
expect(await sut.getMessages(), isEmpty);
});
});
test('Batch inserts all logs on timer', () {
TestUtils.fakeAsync((time) async {
sut = await LogService.create(
logRepo: mockLogRepo,
storeRepo: mockStoreRepo,
shouldBuffer: true,
);
final logger = Logger(_kInfoLog.logger!);
logger.info(_kInfoLog.message);
time.elapse(const Duration(seconds: 6));
final insert = verify(() => mockLogRepo.insertAll(captureAny()));
insert.called(1);
// ignore: prefer-correct-json-casts
final captured = insert.captured.firstOrNull as List<LogMessage>;
expect(captured.firstOrNull?.message, _kInfoLog.message);
expect(captured.firstOrNull?.logger, _kInfoLog.logger);
verifyNever(() => mockLogRepo.insert(captureAny()));
});
});
test('Does not buffer when off', () {
TestUtils.fakeAsync((time) async {
sut = await LogService.create(
logRepo: mockLogRepo,
storeRepo: mockStoreRepo,
shouldBuffer: false,
);
final logger = Logger(_kInfoLog.logger!);
logger.info(_kInfoLog.message);
// Ensure nothing gets buffer. This works because we mock log repo getAll to return nothing
expect(await sut.getMessages(), isEmpty);
final insert = verify(() => mockLogRepo.insert(captureAny()));
insert.called(1);
final captured = insert.captured.firstOrNull as LogMessage;
expect(captured.message, _kInfoLog.message);
expect(captured.logger, _kInfoLog.logger);
verifyNever(() => mockLogRepo.insertAll(captureAny()));
});
});
});
group("Log Service Get messages:", () {
setUp(() {
when(() => mockLogRepo.getAll()).thenAnswer((_) async => [_kInfoLog]);
});
test('Fetches result from DB', () async {
expect(await sut.getMessages(), hasLength(1));
verify(() => mockLogRepo.getAll()).called(1);
});
test('Combines result from both DB + Buffer', () {
TestUtils.fakeAsync((time) async {
sut = await LogService.create(
logRepo: mockLogRepo,
storeRepo: mockStoreRepo,
shouldBuffer: true,
);
final logger = Logger(_kWarnLog.logger!);
logger.warning(_kWarnLog.message);
expect(await sut.getMessages(), hasLength(2)); // 1 - DB, 1 - Buff
final messages = await sut.getMessages();
// Logged time is assigned in the service for messages in the buffer, so compare manually
expect(messages.firstOrNull?.message, _kWarnLog.message);
expect(messages.firstOrNull?.logger, _kWarnLog.logger);
expect(messages.elementAtOrNull(1), _kInfoLog);
});
});
});
}

View File

@@ -1,4 +1,7 @@
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
class MockStoreRepository extends Mock implements IStoreRepository {} class MockStoreRepository extends Mock implements IStoreRepository {}
class MockLogRepository extends Mock implements ILogRepository {}

View File

@@ -1,15 +1,16 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@@ -70,7 +71,10 @@ void main() {
db.writeTxnSync(() => db.clearSync()); db.writeTxnSync(() => db.clearSync());
await StoreService.init(storeRepository: IsarStoreRepository(db)); await StoreService.init(storeRepository: IsarStoreRepository(db));
await Store.put(StoreKey.currentUser, owner); await Store.put(StoreKey.currentUser, owner);
ImmichLogger(); await LogService.init(
logRepo: IsarLogRepository(db),
storeRepo: IsarStoreRepository(db),
);
}); });
final List<Asset> initialAssets = [ final List<Asset> initialAssets = [
makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "a", remoteId: "0-1"),

View File

@@ -5,7 +5,7 @@ import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart';
import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart';
@@ -18,7 +18,7 @@ class MockAssetRepository extends Mock implements IAssetRepository {}
class MockUserRepository extends Mock implements IUserRepository {} class MockUserRepository extends Mock implements IUserRepository {}
class MockBackupRepository extends Mock implements IBackupRepository {} class MockBackupRepository extends Mock implements IBackupAlbumRepository {}
class MockExifInfoRepository extends Mock implements IExifInfoRepository {} class MockExifInfoRepository extends Mock implements IExifInfoRepository {}

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
@@ -11,8 +13,8 @@ import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@@ -88,4 +90,36 @@ abstract final class TestUtils {
WidgetController.hitTestWarningShouldBeFatal = true; WidgetController.hitTestWarningShouldBeFatal = true;
HttpOverrides.global = MockHttpOverrides(); HttpOverrides.global = MockHttpOverrides();
} }
// Workaround till the following issue is resolved
// https://github.com/dart-lang/test/issues/2307
static T fakeAsync<T>(
Future<T> Function(FakeAsync _) callback, {
DateTime? initialTime,
}) {
late final T result;
Object? error;
StackTrace? stack;
FakeAsync(initialTime: initialTime).run((FakeAsync async) {
bool shouldPump = true;
unawaited(
callback(async).then<void>(
(value) => result = value,
onError: (e, s) {
error = e;
stack = s;
},
).whenComplete(() => shouldPump = false),
);
while (shouldPump) {
async.flushMicrotasks();
}
});
if (error != null) {
Error.throwWithStackTrace(error!, stack!);
}
return result;
}
} }

View File

@@ -7655,7 +7655,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.127.0", "version": "1.128.0",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@@ -11952,7 +11952,8 @@
"SourceType": { "SourceType": {
"enum": [ "enum": [
"machine-learning", "machine-learning",
"exif" "exif",
"manual"
], ],
"type": "string" "type": "string"
}, },

View File

@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.127.0", "version": "1.128.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.127.0", "version": "1.128.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.127.0", "version": "1.128.0",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.127.0 * 1.128.0
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
@@ -3453,7 +3453,8 @@ export enum AlbumUserRole {
} }
export enum SourceType { export enum SourceType {
MachineLearning = "machine-learning", MachineLearning = "machine-learning",
Exif = "exif" Exif = "exif",
Manual = "manual"
} }
export enum AssetTypeEnum { export enum AssetTypeEnum {
Image = "IMAGE", Image = "IMAGE",

View File

@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.127.0", "version": "1.128.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.127.0", "version": "1.128.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^11.0.1", "@nestjs/bullmq": "^11.0.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.127.0", "version": "1.128.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassConstructor } from 'class-transformer'; import { ClassConstructor } from 'class-transformer';
import { PostgresJSDialect } from 'kysely-postgres-js'; import { PostgresJSDialect } from 'kysely-postgres-js';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely'; import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel'; import { OpenTelemetryModule } from 'nestjs-otel';
import { mkdir, rm, writeFile } from 'node:fs/promises'; import { mkdir, rm, writeFile } from 'node:fs/promises';
@@ -77,7 +78,7 @@ class SqlGenerator {
await mkdir(this.options.targetDir); await mkdir(this.options.targetDir);
process.env.DB_HOSTNAME = 'localhost'; process.env.DB_HOSTNAME = 'localhost';
const { database, otel } = new ConfigRepository().getEnv(); const { database, cls, otel } = new ConfigRepository().getEnv();
const moduleFixture = await Test.createTestingModule({ const moduleFixture = await Test.createTestingModule({
imports: [ imports: [
@@ -92,6 +93,7 @@ class SqlGenerator {
} }
}, },
}), }),
ClsModule.forRoot(cls.config),
TypeOrmModule.forRoot({ TypeOrmModule.forRoot({
...database.config.typeorm, ...database.config.typeorm,
entities, entities,

View File

@@ -38,6 +38,11 @@ export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload';
export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000);
export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number(
process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000,
);
export const citiesFile = 'cities500.txt'; export const citiesFile = 'cities500.txt';
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';

16
server/src/db.d.ts vendored
View File

@@ -29,7 +29,7 @@ export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive; export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Sourcetype = 'exif' | 'machine-learning'; export type Sourcetype = 'exif' | 'machine-learning' | 'manual';
export type Timestamp = ColumnType<Date, Date | string, Date | string>; export type Timestamp = ColumnType<Date, Date | string, Date | string>;
@@ -41,6 +41,7 @@ export interface Activity {
id: Generated<string>; id: Generated<string>;
isLiked: Generated<boolean>; isLiked: Generated<boolean>;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string; userId: string;
} }
@@ -58,6 +59,7 @@ export interface Albums {
order: Generated<string>; order: Generated<string>;
ownerId: string; ownerId: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
export interface AlbumsAssetsAssets { export interface AlbumsAssetsAssets {
@@ -79,6 +81,7 @@ export interface ApiKeys {
name: string; name: string;
permissions: Permission[]; permissions: Permission[];
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string; userId: string;
} }
@@ -103,6 +106,7 @@ export interface AssetFiles {
path: string; path: string;
type: string; type: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
export interface AssetJobStatus { export interface AssetJobStatus {
@@ -143,6 +147,7 @@ export interface Assets {
thumbhash: Buffer | null; thumbhash: Buffer | null;
type: string; type: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
export interface AssetStack { export interface AssetStack {
@@ -221,6 +226,7 @@ export interface Libraries {
ownerId: string; ownerId: string;
refreshedAt: Timestamp | null; refreshedAt: Timestamp | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
export interface Memories { export interface Memories {
@@ -236,6 +242,7 @@ export interface Memories {
showAt: Timestamp | null; showAt: Timestamp | null;
type: string; type: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
export interface MemoriesAssetsAssets { export interface MemoriesAssetsAssets {
@@ -271,6 +278,7 @@ export interface Partners {
sharedById: string; sharedById: string;
sharedWithId: string; sharedWithId: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
export interface Person { export interface Person {
@@ -285,6 +293,7 @@ export interface Person {
ownerId: string; ownerId: string;
thumbnailPath: Generated<string>; thumbnailPath: Generated<string>;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
export interface Sessions { export interface Sessions {
@@ -294,6 +303,7 @@ export interface Sessions {
id: Generated<string>; id: Generated<string>;
token: string; token: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string; userId: string;
} }
@@ -303,6 +313,7 @@ export interface SessionSyncCheckpoints {
sessionId: string; sessionId: string;
type: SyncEntityType; type: SyncEntityType;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
@@ -358,6 +369,7 @@ export interface Tags {
id: Generated<string>; id: Generated<string>;
parentId: string | null; parentId: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string; userId: string;
value: string; value: string;
} }
@@ -399,9 +411,11 @@ export interface Users {
status: Generated<string>; status: Generated<string>;
storageLabel: string | null; storageLabel: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
} }
export interface UsersAudit { export interface UsersAudit {
id: Generated<string>;
userId: string; userId: string;
deletedAt: Generated<Timestamp>; deletedAt: Generated<Timestamp>;
} }

View File

@@ -25,6 +25,10 @@ export class ActivityEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_activity_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column() @Column()
albumId!: string; albumId!: string;

View File

@@ -8,6 +8,7 @@ import {
CreateDateColumn, CreateDateColumn,
DeleteDateColumn, DeleteDateColumn,
Entity, Entity,
Index,
JoinTable, JoinTable,
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
@@ -39,6 +40,10 @@ export class AlbumEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_albums_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz' }) @DeleteDateColumn({ type: 'timestamptz' })
deletedAt!: Date | null; deletedAt!: Date | null;

View File

@@ -1,6 +1,6 @@
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('api_keys') @Entity('api_keys')
export class APIKeyEntity { export class APIKeyEntity {
@@ -27,4 +27,8 @@ export class APIKeyEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_api_keys_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
} }

View File

@@ -30,6 +30,10 @@ export class AssetFileEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_asset_files_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column() @Column()
type!: AssetFileType; type!: AssetFileType;

View File

@@ -96,6 +96,10 @@ export class AssetEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_assets_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz', nullable: true }) @DeleteDateColumn({ type: 'timestamptz', nullable: true })
deletedAt!: Date | null; deletedAt!: Date | null;
@@ -256,6 +260,7 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds:
.selectFrom('asset_faces') .selectFrom('asset_faces')
.select('assetId') .select('assetId')
.where('personId', '=', anyUuid(personIds!)) .where('personId', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null)
.groupBy('assetId') .groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.as('has_people'), .as('has_people'),
@@ -347,7 +352,7 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */ /** TODO: This should only be used for search-related queries, not as a general purpose query builder */
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) { export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.isArchived ??= options.withArchived ? undefined : false; options.isArchived ??= options.withArchived ? undefined : false;
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore); options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
return kysely return kysely
.withPlugin(joinDeduplicationPlugin) .withPlugin(joinDeduplicationPlugin)
.selectFrom('assets') .selectFrom('assets')

View File

@@ -5,6 +5,7 @@ import {
CreateDateColumn, CreateDateColumn,
DeleteDateColumn, DeleteDateColumn,
Entity, Entity,
Index,
JoinTable, JoinTable,
ManyToOne, ManyToOne,
OneToMany, OneToMany,
@@ -42,6 +43,10 @@ export class LibraryEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_libraries_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz' }) @DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date; deletedAt?: Date;

View File

@@ -6,6 +6,7 @@ import {
CreateDateColumn, CreateDateColumn,
DeleteDateColumn, DeleteDateColumn,
Entity, Entity,
Index,
JoinTable, JoinTable,
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
@@ -30,6 +31,10 @@ export class MemoryEntity<T extends MemoryType = MemoryType> {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_memories_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz' }) @DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date; deletedAt?: Date;

View File

@@ -1,5 +1,14 @@
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('partners') @Entity('partners')
export class PartnerEntity { export class PartnerEntity {
@@ -23,6 +32,10 @@ export class PartnerEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_partners_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
inTimeline!: boolean; inTimeline!: boolean;
} }

View File

@@ -5,6 +5,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
Index,
ManyToOne, ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@@ -23,6 +24,10 @@ export class PersonEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_person_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column() @Column()
ownerId!: string; ownerId!: string;

View File

@@ -1,7 +1,7 @@
import { ExpressionBuilder } from 'kysely'; import { ExpressionBuilder } from 'kysely';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('sessions') @Entity('sessions')
export class SessionEntity { export class SessionEntity {
@@ -23,6 +23,10 @@ export class SessionEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_sessions_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId!: string;
@Column({ default: '' }) @Column({ default: '' })
deviceType!: string; deviceType!: string;

View File

@@ -1,6 +1,6 @@
import { SessionEntity } from 'src/entities/session.entity'; import { SessionEntity } from 'src/entities/session.entity';
import { SyncEntityType } from 'src/enum'; import { SyncEntityType } from 'src/enum';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm';
@Entity('session_sync_checkpoints') @Entity('session_sync_checkpoints')
export class SessionSyncCheckpointEntity { export class SessionSyncCheckpointEntity {
@@ -19,6 +19,10 @@ export class SessionSyncCheckpointEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_session_sync_checkpoints_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column() @Column()
ack!: string; ack!: string;
} }

View File

@@ -4,6 +4,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
Index,
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@@ -30,6 +31,10 @@ export class TagEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_tags_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column({ type: 'varchar', nullable: true, default: null }) @Column({ type: 'varchar', nullable: true, default: null })
color!: string | null; color!: string | null;

View File

@@ -1,14 +1,14 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity('users_audit') @Entity('users_audit')
@Index('IDX_users_audit_deleted_at_asc_user_id_asc', ['deletedAt', 'userId'])
export class UserAuditEntity { export class UserAuditEntity {
@PrimaryGeneratedColumn('increment') @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
id!: number; id!: string;
@Column({ type: 'uuid' }) @Column({ type: 'uuid' })
userId!: string; userId!: string;
@CreateDateColumn({ type: 'timestamptz' }) @Index('IDX_users_audit_deleted_at')
@CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
deletedAt!: Date; deletedAt!: Date;
} }

View File

@@ -58,6 +58,10 @@ export class UserEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@Index('IDX_users_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@OneToMany(() => TagEntity, (tag) => tag.user) @OneToMany(() => TagEntity, (tag) => tag.user)
tags!: TagEntity[]; tags!: TagEntity[];

View File

@@ -228,6 +228,7 @@ export enum AssetStatus {
export enum SourceType { export enum SourceType {
MACHINE_LEARNING = 'machine-learning', MACHINE_LEARNING = 'machine-learning',
EXIF = 'exif', EXIF = 'exif',
MANUAL = 'manual',
} }
export enum ManualJobName { export enum ManualJobName {

View File

@@ -0,0 +1,134 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUpdateIdColumns1740586617223 implements MigrationInterface {
name = 'AddUpdateIdColumns1740586617223'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create or replace function immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp())
returns uuid
as $$
select encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid())
placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex')::uuid;
$$
language SQL
volatile;
`)
await queryRunner.query(`
CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER
LANGUAGE plpgsql
as $$
BEGIN
return new;
END;
$$;
`)
await queryRunner.query(`ALTER TABLE "person" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "asset_files" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "libraries" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "tags" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "assets" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "users" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "albums" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "sessions" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "partners" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "memories" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "api_keys" ADD "updateId" uuid`);
await queryRunner.query(`ALTER TABLE "activity" ADD "updateId" uuid`);
await queryRunner.query(`UPDATE "person" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "asset_files" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "libraries" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "tags" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "assets" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "users" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "albums" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "sessions" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "session_sync_checkpoints" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "partners" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "memories" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "api_keys" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`UPDATE "activity" SET "updateId" = immich_uuid_v7("updatedAt")`);
await queryRunner.query(`ALTER TABLE "person" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "asset_files" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "libraries" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "tags" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "albums" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "partners" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "memories" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "api_keys" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "activity" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`);
await queryRunner.query(`CREATE INDEX "IDX_person_update_id" ON "person" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_asset_files_update_id" ON "asset_files" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_libraries_update_id" ON "libraries" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_tags_update_id" ON "tags" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_assets_update_id" ON "assets" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_users_update_id" ON "users" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_albums_update_id" ON "albums" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_sessions_update_id" ON "sessions" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_partners_update_id" ON "partners" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_memories_update_id" ON "memories" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_api_keys_update_id" ON "api_keys" ("updateId")`);
await queryRunner.query(`CREATE INDEX "IDX_activity_update_id" ON "activity" ("updateId")`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER
LANGUAGE plpgsql
as $$
DECLARE
clock_timestamp TIMESTAMP := clock_timestamp();
BEGIN
new."updatedAt" = clock_timestamp;
new."updateId" = immich_uuid_v7(clock_timestamp);
return new;
END;
$$;
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "activity" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "partners" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "libraries" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "asset_files" DROP COLUMN "updateId"`);
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "updateId"`);
await queryRunner.query(`DROP FUNCTION immich_uuid_v7`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER
LANGUAGE plpgsql
as $$
BEGIN
new."updatedAt" = now();
return new;
END;
$$;
`)
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UsersAuditUuidv7PrimaryKey1740595460866 implements MigrationInterface {
name = 'UsersAuditUuidv7PrimaryKey1740595460866'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at_asc_user_id_asc"`);
await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`);
await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`);
await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" uuid NOT NULL DEFAULT immich_uuid_v7()`);
await queryRunner.query(`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id")`);
await queryRunner.query(`ALTER TABLE "users_audit" ALTER COLUMN "deletedAt" SET DEFAULT clock_timestamp()`)
await queryRunner.query(`CREATE INDEX "IDX_users_audit_deleted_at" ON "users_audit" ("deletedAt")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at"`);
await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`);
await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`);
await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" SERIAL NOT NULL`);
await queryRunner.query(`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id")`);
await queryRunner.query(`ALTER TABLE "users_audit" ALTER COLUMN "deletedAt" SET DEFAULT now()`);
await queryRunner.query(`CREATE INDEX "IDX_users_audit_deleted_at_asc_user_id_asc" ON "users_audit" ("userId", "deletedAt") `);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddManualSourceType1740619600996 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE sourceType ADD VALUE 'manual'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Prior to this migration, manually tagged pictures had the 'machine-learning' type
await queryRunner.query(
`UPDATE "asset_faces" SET "sourceType" = 'machine-learning' WHERE "sourceType" = 'manual';`,
);
// Postgres doesn't allow removing values from enums, we have to recreate the type
await queryRunner.query(`ALTER TYPE sourceType RENAME TO oldSourceType`);
await queryRunner.query(`CREATE TYPE sourceType AS ENUM ('machine-learning', 'exif');`);
await queryRunner.query(`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" DROP DEFAULT;`);
await queryRunner.query(
`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" TYPE sourceType USING "sourceType"::text::sourceType;`,
);
await queryRunner.query(
`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" SET DEFAULT 'machine-learning'::sourceType;`,
);
await queryRunner.query(`DROP TYPE oldSourceType;`);
}
}

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UnsetStackedAssetsFromDuplicateStatus1740654480319 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
update assets
set "duplicateId" = null
where "stackId" is not null`);
}
public async down(): Promise<void> {
// No need to revert this migration
}
}

View File

@@ -55,9 +55,10 @@ with
inner join "exif" on "a"."id" = "exif"."assetId" inner join "exif" on "a"."id" = "exif"."assetId"
) )
select select
( date_part(
(now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date 'year',
) / 365 as "yearsAgo", ("localDateTime" at time zone 'UTC')::date
)::int as "year",
json_agg("res") as "assets" json_agg("res") as "assets"
from from
"res" "res"
@@ -333,6 +334,7 @@ with
and "assets"."duplicateId" is not null and "assets"."duplicateId" is not null
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."isVisible" = $2 and "assets"."isVisible" = $2
and "assets"."stackId" is null
group by group by
"assets"."duplicateId" "assets"."duplicateId"
), ),

View File

@@ -112,6 +112,7 @@ with
and "assets"."isVisible" = $3 and "assets"."isVisible" = $3
and "assets"."type" = $4 and "assets"."type" = $4
and "assets"."id" != $5::uuid and "assets"."id" != $5::uuid
and "assets"."stackId" is null
order by order by
smart_search.embedding <=> $6 smart_search.embedding <=> $6
limit limit

View File

@@ -192,7 +192,7 @@ export class AssetRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<DayOfYearAssets[]> { getByDayOfYear(ownerIds: string[], { day, month }: MonthDay) {
return this.db return this.db
.with('res', (qb) => .with('res', (qb) =>
qb qb
@@ -239,16 +239,12 @@ export class AssetRepository {
.select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')), .select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')),
) )
.selectFrom('res') .selectFrom('res')
.select( .select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
sql<number>`((now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date) / 365`.as(
'yearsAgo',
),
)
.select((eb) => eb.fn.jsonAgg(eb.table('res')).as('assets')) .select((eb) => eb.fn.jsonAgg(eb.table('res')).as('assets'))
.groupBy(sql`("localDateTime" at time zone 'UTC')::date`) .groupBy(sql`("localDateTime" at time zone 'UTC')::date`)
.orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc') .orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc')
.limit(10) .limit(10)
.execute() as any as Promise<DayOfYearAssets[]>; .execute();
} }
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
@@ -794,6 +790,7 @@ export class AssetRepository {
.where('assets.duplicateId', 'is not', null) .where('assets.duplicateId', 'is not', null)
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.stackId', 'is', null)
.groupBy('assets.duplicateId'), .groupBy('assets.duplicateId'),
) )
.with('unique', (qb) => .with('unique', (qb) =>

View File

@@ -1,8 +1,8 @@
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository, MyConsoleLogger } from 'src/repositories/logging.repository';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(LoggingRepository.name, () => { describe(LoggingRepository.name, () => {
@@ -18,23 +18,25 @@ describe(LoggingRepository.name, () => {
} as unknown as Mocked<ClsService>; } as unknown as Mocked<ClsService>;
}); });
describe('formatContext', () => { describe(MyConsoleLogger.name, () => {
it('should use colors', () => { describe('formatContext', () => {
configMock.getEnv.mockReturnValue(mockEnvData({ noColor: false })); it('should use colors', () => {
sut = new LoggingRepository(clsMock, configMock);
sut.setAppName(ImmichWorker.API);
sut = new LoggingRepository(clsMock, configMock); const logger = new MyConsoleLogger(clsMock, { color: true });
sut.setAppName(ImmichWorker.API);
expect(sut['formatContext']('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); expect(logger.formatContext('context')).toBe('\u001B[33m[Api:context]\u001B[39m ');
}); });
it('should not use colors when noColor is true', () => { it('should not use colors when color is false', () => {
configMock.getEnv.mockReturnValue(mockEnvData({ noColor: true })); sut = new LoggingRepository(clsMock, configMock);
sut.setAppName(ImmichWorker.API);
sut = new LoggingRepository(clsMock, configMock); const logger = new MyConsoleLogger(clsMock, { color: false });
sut.setAppName(ImmichWorker.API);
expect(sut['formatContext']('context')).toBe('[Api:context] '); expect(logger.formatContext('context')).toBe('[Api:context] ');
});
}); });
}); });
}); });

View File

@@ -5,6 +5,9 @@ import { Telemetry } from 'src/decorators';
import { LogLevel } from 'src/enum'; import { LogLevel } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
type LogDetails = any[];
type LogFunction = () => string;
const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
enum LogColor { enum LogColor {
@@ -16,38 +19,26 @@ enum LogColor {
CYAN_BRIGHT = 96, CYAN_BRIGHT = 96,
} }
@Injectable({ scope: Scope.TRANSIENT }) let appName: string | undefined;
@Telemetry({ enabled: false }) let logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
export class LoggingRepository extends ConsoleLogger {
private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; export class MyConsoleLogger extends ConsoleLogger {
private noColor: boolean; private isColorEnabled: boolean;
constructor( constructor(
private cls: ClsService, private cls: ClsService,
configRepository: ConfigRepository, options?: { color?: boolean; context?: string },
) { ) {
super(LoggingRepository.name); super(options?.context || MyConsoleLogger.name);
this.isColorEnabled = options?.color || false;
const { noColor } = configRepository.getEnv();
this.noColor = noColor;
}
private static appName?: string = undefined;
setAppName(name: string): void {
LoggingRepository.appName = name.charAt(0).toUpperCase() + name.slice(1);
} }
isLevelEnabled(level: LogLevel) { isLevelEnabled(level: LogLevel) {
return isLogLevelEnabled(level, LoggingRepository.logLevels); return isLogLevelEnabled(level, logLevels);
} }
setLogLevel(level: LogLevel | false): void { formatContext(context: string): string {
LoggingRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; let prefix = appName || '';
}
protected formatContext(context: string): string {
let prefix = LoggingRepository.appName || '';
if (context) { if (context) {
prefix += (prefix ? ':' : '') + context; prefix += (prefix ? ':' : '') + context;
} }
@@ -74,6 +65,105 @@ export class LoggingRepository extends ConsoleLogger {
}; };
private withColor(text: string, color: LogColor) { private withColor(text: string, color: LogColor) {
return this.noColor ? text : `\u001B[${color}m${text}\u001B[39m`; return this.isColorEnabled ? `\u001B[${color}m${text}\u001B[39m` : text;
}
}
@Injectable({ scope: Scope.TRANSIENT })
@Telemetry({ enabled: false })
export class LoggingRepository {
private logger: MyConsoleLogger;
constructor(cls: ClsService, configRepository: ConfigRepository) {
const { noColor } = configRepository.getEnv();
this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor });
}
setAppName(name: string): void {
appName = name.charAt(0).toUpperCase() + name.slice(1);
}
setContext(context: string) {
this.logger.setContext(context);
}
isLevelEnabled(level: LogLevel) {
return this.logger.isLevelEnabled(level);
}
setLogLevel(level: LogLevel | false): void {
logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : [];
}
verbose(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.VERBOSE, message, details);
}
verboseFn(message: LogFunction, ...details: LogDetails) {
this.handleFunction(LogLevel.VERBOSE, message, details);
}
debug(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.DEBUG, message, details);
}
debugFn(message: LogFunction, ...details: LogDetails) {
this.handleFunction(LogLevel.DEBUG, message, details);
}
log(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.LOG, message, details);
}
warn(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.WARN, message, details);
}
error(message: string | Error, ...details: LogDetails) {
this.handleMessage(LogLevel.ERROR, message, details);
}
fatal(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.FATAL, message, details);
}
private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) {
if (this.logger.isLevelEnabled(level)) {
this.handleMessage(level, message(), details);
}
}
private handleMessage(level: LogLevel, message: string | Error, details: LogDetails[]) {
switch (level) {
case LogLevel.VERBOSE: {
this.logger.verbose(message, ...details);
break;
}
case LogLevel.DEBUG: {
this.logger.debug(message, ...details);
break;
}
case LogLevel.LOG: {
this.logger.log(message, ...details);
break;
}
case LogLevel.WARN: {
this.logger.warn(message, ...details);
break;
}
case LogLevel.ERROR: {
this.logger.error(message, ...details);
break;
}
case LogLevel.FATAL: {
this.logger.fatal(message, ...details);
break;
}
}
} }
} }

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME, MACHINE_LEARNING_PING_TIMEOUT } from 'src/constants';
import { CLIPConfig } from 'src/dtos/model-config.dto'; import { CLIPConfig } from 'src/dtos/model-config.dto';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -55,16 +56,80 @@ export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | Fa
@Injectable() @Injectable()
export class MachineLearningRepository { export class MachineLearningRepository {
// Note that deleted URL's are not removed from this map (ie: they're leaked)
// Cleaning them up is low priority since there should be very few over a
// typical server uptime cycle
private urlAvailability: {
[url: string]:
| {
active: boolean;
lastChecked: number;
}
| undefined;
};
constructor(private logger: LoggingRepository) { constructor(private logger: LoggingRepository) {
this.logger.setContext(MachineLearningRepository.name); this.logger.setContext(MachineLearningRepository.name);
this.urlAvailability = {};
}
private setUrlAvailability(url: string, active: boolean) {
const current = this.urlAvailability[url];
if (current?.active !== active) {
this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`);
}
this.urlAvailability[url] = {
active,
lastChecked: Date.now(),
};
}
private async checkAvailability(url: string) {
let active = false;
try {
const response = await fetch(new URL('/ping', url), {
signal: AbortSignal.timeout(MACHINE_LEARNING_PING_TIMEOUT),
});
active = response.ok;
} catch {}
this.setUrlAvailability(url, active);
return active;
}
private async shouldSkipUrl(url: string) {
const availability = this.urlAvailability[url];
if (availability === undefined) {
// If this is a new endpoint, then check inline and skip if it fails
if (!(await this.checkAvailability(url))) {
return true;
}
return false;
}
if (!availability.active && Date.now() - availability.lastChecked < MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME) {
// If this is an old inactive endpoint that hasn't been checked in a
// while then check but don't wait for the result, just skip it
// This avoids delays on every search whilst allowing higher priority
// ML servers to recover over time.
void this.checkAvailability(url);
return true;
}
return false;
} }
private async predict<T>(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise<T> { private async predict<T>(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
const formData = await this.getFormData(payload, config); const formData = await this.getFormData(payload, config);
let urlCounter = 0;
for (const url of urls) { for (const url of urls) {
urlCounter++;
const isLast = urlCounter >= urls.length;
if (!isLast && (await this.shouldSkipUrl(url))) {
continue;
}
try { try {
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
if (response.ok) { if (response.ok) {
this.setUrlAvailability(url, true);
return response.json(); return response.json();
} }
@@ -76,6 +141,7 @@ export class MachineLearningRepository {
`Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`, `Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`,
); );
} }
this.setUrlAvailability(url, false);
} }
throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`); throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`);

View File

@@ -10,7 +10,7 @@ import { citiesFile } from 'src/constants';
import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { LogLevel, SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
@@ -137,9 +137,7 @@ export class MapRepository {
.executeTakeFirst(); .executeTakeFirst();
if (response) { if (response) {
if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { this.logger.verboseFn(() => `Raw: ${JSON.stringify(response, null, 2)}`);
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
}
const { countryCode, name: city, admin1Name } = response; const { countryCode, name: city, admin1Name } = response;
const country = getName(countryCode, 'en') ?? null; const country = getName(countryCode, 'en') ?? null;
@@ -167,9 +165,8 @@ export class MapRepository {
return { country: null, state: null, city: null }; return { country: null, state: null, city: null };
} }
if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { this.logger.verboseFn(() => `Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
}
const { admin_a3 } = ne_response; const { admin_a3 } = ne_response;
const country = getName(admin_a3, 'en') ?? null; const country = getName(admin_a3, 'en') ?? null;
const state = null; const state = null;

View File

@@ -85,6 +85,10 @@ export class MetadataRepository {
this.logger.setContext(MetadataRepository.name); this.logger.setContext(MetadataRepository.name);
} }
setMaxConcurrency(concurrency: number) {
this.exiftool.batchCluster.setMaxProcs(concurrency);
}
async teardown() { async teardown() {
await this.exiftool.end(); await this.exiftool.end();
} }

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