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

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.51",
"version": "2.2.52",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"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.
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.
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`.
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`.
```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
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
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>
- 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.
- Set your timezone by uncommenting the `TZ=` line.
- 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_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_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.

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`
:::
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

View File

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

View File

@@ -5,7 +5,7 @@ module.exports = {
preflight: false, // disable Tailwind's reset
},
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: {
extend: {
colors: {

8
e2e/package-lock.json generated
View File

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

View File

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

View File

@@ -526,6 +526,47 @@ describe('/libraries', () => {
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 () => {
const library = await utils.createLibrary(admin.accessToken, {
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 { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses';
@@ -6,8 +6,6 @@ import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'sr
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
describe('/trash', () => {
let admin: LoginResponseDto;
let ws: Socket;
@@ -81,8 +79,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.items.length).toBe(1);
@@ -90,8 +87,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.scan(admin.accessToken, library.id);
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
@@ -116,8 +112,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.items.length).toBe(1);
@@ -125,8 +120,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.scan(admin.accessToken, library.id);
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
@@ -180,8 +174,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
@@ -189,9 +182,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.scan(admin.accessToken, library.id);
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
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) });
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`);
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 });
@@ -247,7 +240,7 @@ describe('/trash', () => {
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 utils.getAssetInfo(admin.accessToken, assetId);
@@ -261,6 +254,8 @@ describe('/trash', () => {
const after = await utils.getAssetInfo(admin.accessToken, assetId);
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_enabled": "Enable 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_log_settings": "Manage log settings",
"map_dark_style": "Dark style",

View File

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

View File

@@ -67,7 +67,7 @@ custom_lint:
- lib/entities/*.entity.dart
- lib/repositories/{album,asset,backup,database,etag,exif_info,user,timeline,partner}.repository.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
# acceptable exceptions for the time being (until Isar is fully replaced)
- integration_test/test_utils/general_helper.dart
@@ -76,17 +76,17 @@ custom_lint:
- lib/routing/router.dart
- lib/services/immich_logger.service.dart # not really a service... more a util
- lib/utils/{db,migration}.dart
- lib/utils/bootstrap.dart
- lib/widgets/asset_grid/asset_grid_data_structure.dart
- test/**.dart
# refactor the remaining providers
- lib/providers/{db,user}.provider.dart
- lib/providers/backup/backup.provider.dart
- lib/providers/db.provider.dart
- import_rule_openapi:
message: openapi must only be used through ApiRepositories
restrict: package:openapi
allowed:
# requried / wanted
# required / wanted
- lib/repositories/*_api.repository.dart
# acceptable exceptions for the time being
- 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',
build_type: 'Release',
properties: {
"android.injected.version.code" => 185,
"android.injected.version.name" => "1.127.0",
"android.injected.version.code" => 186,
"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')

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/providers/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:isar/isar.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
@@ -39,7 +39,8 @@ class ImmichTestHelper {
static Future<void> loadApp(WidgetTester tester) async {
await EasyLocalization.ensureInitialized();
// 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 db.writeTxn(() => db.clear());
// Load main Widget

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1;
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
part of 'logger_message.entity.dart';
part of 'log.entity.dart';
// **************************************************************************
// 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/interfaces/database.interface.dart';
abstract interface class IBackupRepository implements IDatabaseRepository {
abstract interface class IBackupAlbumRepository implements IDatabaseRepository {
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort});
Future<List<String>> getIdsBySelection(BackupSelection backup);

View File

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

View File

@@ -10,20 +10,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/asset_viewer/share_intent_upload.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/tab_navigation_observer.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/theme/dynamic_theme.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/download.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:timezone/data/latest.dart';
void main() async {
ImmichWidgetsBinding();
final db = await loadDb();
final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db);
await initApp();
await migrateDatabaseIfNeeded(db);
HttpOverrides.global = HttpSSLCertOverride();
@@ -80,9 +66,6 @@ Future<void> initApp() async {
await DynamicTheme.fetchSystemPalette();
// Initialize Immich Logger Service
ImmichLogger();
final log = Logger("ImmichErrorLogger");
FlutterError.onError = (details) {
@@ -122,29 +105,6 @@ Future<void> initApp() async {
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 {
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_hooks/flutter_hooks.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/theme_extensions.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:intl/intl.dart';
@@ -17,8 +18,11 @@ class AppLogPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final immichLogger = ImmichLogger();
final logMessages = useState(immichLogger.messages);
final immichLogger = LogService.I;
final shouldReload = useState(false);
final logMessages = useFuture(
useMemoized(() => immichLogger.getMessages(), [shouldReload.value]),
);
Widget colorStatusIndicator(Color color) {
return Column(
@@ -71,7 +75,7 @@ class AppLogPage extends HookConsumerWidget {
),
onPressed: () {
immichLogger.clearLogs();
logMessages.value = [];
shouldReload.value = !shouldReload.value;
},
),
Builder(
@@ -84,7 +88,7 @@ class AppLogPage extends HookConsumerWidget {
size: 20.0,
),
onPressed: () {
immichLogger.shareLogs(iconContext);
ImmichLogger.shareLogs(iconContext);
},
);
},
@@ -105,9 +109,9 @@ class AppLogPage extends HookConsumerWidget {
separatorBuilder: (context, index) {
return const Divider(height: 0);
},
itemCount: logMessages.value.length,
itemCount: logMessages.data?.length ?? 0,
itemBuilder: (context, index) {
var logMessage = logMessages.value[index];
var logMessage = logMessages.data![index];
return ListTile(
onTap: () => context.pushRoute(
AppLogDetailRoute(
@@ -128,7 +132,7 @@ class AppLogPage extends HookConsumerWidget {
),
),
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(
fontSize: 12.0,
color: context.colorScheme.onSurfaceSecondary,

View File

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

View File

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

View File

@@ -1,20 +1,22 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/domain/services/log.service.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/ios_background_settings.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/memory.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/tab.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';
enum AppLifeCycleEnum {
@@ -112,7 +114,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(websocketProvider.notifier).disconnect();
}
ImmichLogger().flush();
unawaited(LogService.I.flush());
}
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/store.entity.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/models/auth/auth_state.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/auth.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/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/services/background.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/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
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> {
BackupNotifier(
this._backupService,
@@ -45,10 +58,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._authState,
this._backgroundService,
this._galleryPermissionNotifier,
this._db,
this._albumMediaRepository,
this._fileMediaRepository,
this._backupRepository,
this._backupAlbumService,
this.ref,
) : super(
BackUpState(
@@ -96,10 +108,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final AuthState _authState;
final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
final IBackupRepository _backupRepository;
final BackupAlbumService _backupAlbumService;
final Ref ref;
///
@@ -260,9 +271,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums =
await _backupRepository.getAllBySelection(BackupSelection.exclude);
await _backupAlbumService.getAllBySelection(BackupSelection.exclude);
final List<BackupAlbum> selectedBackupAlbums =
await _backupRepository.getAllBySelection(BackupSelection.select);
await _backupAlbumService.getAllBySelection(BackupSelection.select);
final Set<AvailableAlbum> selectedAlbums = {};
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
Future<void> _updatePersistentAlbumsSelection() {
Future<void> _updatePersistentAlbumsSelection() async {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
final selected = state.selectedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
@@ -447,29 +458,30 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final excluded = state.excludedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
);
final backupAlbums = selected.followedBy(excluded).toList();
backupAlbums.sortBy((e) => e.id);
return _db.writeTxn(() async {
final dbAlbums = await _db.backupAlbums.where().sortById().findAll();
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made
diffSortedListsSync(
dbAlbums,
backupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
b.lastBackup =
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
toUpsert.add(b);
return true;
},
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
onlySecond: (BackupAlbum b) => toUpsert.add(b),
);
await _db.backupAlbums.deleteAll(toDelete);
await _db.backupAlbums.putAll(toUpsert);
});
final candidates = selected.followedBy(excluded).toList();
candidates.sortBy((e) => e.id);
final savedBackupAlbums =
await _backupAlbumService.getAll(sort: BackupAlbumSort.id);
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
diffSortedListsSync(
savedBackupAlbums,
candidates,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
b.lastBackup =
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
toUpsert.add(b);
return true;
},
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
onlySecond: (BackupAlbum b) => toUpsert.add(b),
);
await _backupAlbumService.deleteAll(toDelete);
await _backupAlbumService.updateAll(toUpsert);
}
/// Invoke backup process
@@ -686,14 +698,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
Future<void> resumeBackup() async {
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.findAll();
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.exclude)
.findAll();
final List<BackupAlbum> selectedBackupAlbums =
await _backupAlbumService.getAllBySelection(BackupSelection.select);
final List<BackupAlbum> excludedBackupAlbums =
await _backupAlbumService.getAllBySelection(BackupSelection.exclude);
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (selectedAlbums.isNotEmpty) {
@@ -756,23 +764,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
BackUpProgressEnum get backupProgress => state.backupProgress;
void updateBackupProgress(BackUpProgressEnum 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/models/backup/backup_candidate.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/services/background.service.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/entities/asset.entity.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/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
@@ -37,7 +37,7 @@ final manualUploadProvider =
ref.watch(localNotificationService),
ref.watch(backupProvider.notifier),
ref.watch(backupServiceProvider),
ref.watch(backupRepositoryProvider),
ref.watch(backupAlbumServiceProvider),
ref,
);
});
@@ -47,14 +47,14 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final LocalNotificationService _localNotificationService;
final BackupNotifier _backupProvider;
final BackupService _backupService;
final BackupRepository _backupRepository;
final BackupAlbumService _backupAlbumService;
final Ref ref;
ManualUploadNotifier(
this._localNotificationService,
this._backupProvider,
this._backupService,
this._backupRepository,
this._backupAlbumService,
this.ref,
) : super(
ManualUploadState(
@@ -210,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
}
final selectedBackupAlbums =
await _backupRepository.getAllBySelection(BackupSelection.select);
final excludedBackupAlbums =
await _backupRepository.getAllBySelection(BackupSelection.exclude);
await _backupAlbumService.getAllBySelection(BackupSelection.select);
final excludedBackupAlbums = await _backupAlbumService
.getAllBySelection(BackupSelection.exclude);
// Get candidates from selected albums and excluded albums
Set<BackupCandidate> candidates =

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/user.entity.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
final PartnerService _partnerService;
late final StreamSubscription<List<User>> streamSub;
PartnerSharedWithNotifier(this._partnerService) : super([]) {
Function eq = const ListEquality<User>().equals;
@@ -16,7 +17,7 @@ class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
state = partners;
}
}).then((_) {
_partnerService.watchSharedWith().listen((partners) {
streamSub = _partnerService.watchSharedWith().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
@@ -27,6 +28,14 @@ class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
Future<bool> updatePartner(User partner, {required bool inTimeline}) {
return _partnerService.updatePartner(partner, inTimeline: inTimeline);
}
@override
void dispose() {
if (mounted) {
streamSub.cancel();
}
super.dispose();
}
}
final partnerSharedWithProvider =
@@ -38,6 +47,7 @@ final partnerSharedWithProvider =
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
final PartnerService _partnerService;
late final StreamSubscription<List<User>> streamSub;
PartnerSharedByNotifier(this._partnerService) : super([]) {
Function eq = const ListEquality<User>().equals;
@@ -54,11 +64,11 @@ class PartnerSharedByNotifier extends StateNotifier<List<User>> {
});
}
late final StreamSubscription<List<User>> streamSub;
@override
void dispose() {
streamSub.cancel();
if (mounted) {
streamSub.cancel();
}
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/widgets/asset_grid/asset_grid_data_structure.dart';
final singleUserTimelineProvider = StreamProvider.family<RenderList, int>(
final singleUserTimelineProvider = StreamProvider.family<RenderList, int?>(
(ref, userId) {
if (userId == null) {
return const Stream.empty();
}
ref.watch(localeProvider);
final timelineService = ref.watch(timelineServiceProvider);
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/user.entity.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:isar/isar.dart';
import 'package:immich_mobile/services/user.service.dart';
class CurrentUserProvider extends StateNotifier<User?> {
CurrentUserProvider(this._apiService) : super(null) {
@@ -47,18 +46,14 @@ final currentUserProvider =
});
class TimelineUserIdsProvider extends StateNotifier<List<int>> {
TimelineUserIdsProvider(Isar db, User? currentUser) : super([]) {
final query = db.users
.filter()
.inTimelineEqualTo(true)
.or()
.isarIdEqualTo(currentUser?.isarId ?? Isar.autoIncrement)
.isarIdProperty();
query.findAll().then((users) => state = users);
streamSub = query.watch().listen((users) => state = users);
TimelineUserIdsProvider(this._userService) : super([]) {
_userService.getTimelineUserIds().then((users) => state = users);
streamSub =
_userService.watchTimelineUserIds().listen((users) => state = users);
}
late final StreamSubscription<List<int>> streamSub;
final UserService _userService;
@override
void dispose() {
@@ -69,8 +64,5 @@ class TimelineUserIdsProvider extends StateNotifier<List<int>> {
final timelineUsersIdsProvider =
StateNotifierProvider<TimelineUserIdsProvider, List<int>>((ref) {
return TimelineUserIdsProvider(
ref.watch(dbProvider),
ref.watch(currentUserProvider),
);
return TimelineUserIdsProvider(ref.watch(userServiceProvider));
});

View File

@@ -1,15 +1,16 @@
import 'package:hooks_riverpod/hooks_riverpod.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/repositories/database.repository.dart';
import 'package:isar/isar.dart';
final backupRepositoryProvider =
Provider((ref) => BackupRepository(ref.watch(dbProvider)));
final backupAlbumRepositoryProvider =
Provider((ref) => BackupAlbumRepository(ref.watch(dbProvider)));
class BackupRepository extends DatabaseRepository implements IBackupRepository {
BackupRepository(super.db);
class BackupAlbumRepository extends DatabaseRepository
implements IBackupAlbumRepository {
BackupAlbumRepository(super.db);
@override
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) {

View File

@@ -70,4 +70,26 @@ class UserRepository extends DatabaseRepository implements IUserRepository {
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:flutter/material.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/asset.entity.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/entities/user.entity.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/shared_link/shared_link.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_asset_selection.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_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_detail.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/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/splash_screen.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/edit.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/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/login/change_password.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/recently_added.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/providers/api.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> {
AppLogDetailRoute({
Key? key,
required LoggerMessage logMessage,
required LogMessage logMessage,
List<PageRouteInfo>? children,
}) : super(
AppLogDetailRoute.name,
@@ -419,7 +419,7 @@ class AppLogDetailRouteArgs {
final Key? key;
final LoggerMessage logMessage;
final LogMessage logMessage;
@override
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_media.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_search.model.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
@@ -36,7 +36,7 @@ final albumServiceProvider = Provider(
ref.watch(entityServiceProvider),
ref.watch(albumRepositoryProvider),
ref.watch(assetRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref.watch(backupAlbumRepositoryProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(albumApiRepositoryProvider),
),
@@ -48,7 +48,7 @@ class AlbumService {
final EntityService _entityService;
final IAlbumRepository _albumRepository;
final IAssetRepository _assetRepository;
final IBackupRepository _backupAlbumRepository;
final IBackupAlbumRepository _backupAlbumRepository;
final IAlbumMediaRepository _albumMediaRepository;
final IAlbumApiRepository _albumApiRepository;
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_api.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/exif_info.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
@@ -39,7 +39,7 @@ final assetServiceProvider = Provider(
ref.watch(exifInfoRepositoryProvider),
ref.watch(userRepositoryProvider),
ref.watch(etagRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref.watch(backupAlbumRepositoryProvider),
ref.watch(apiServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(userServiceProvider),
@@ -55,7 +55,7 @@ class AssetService {
final IExifInfoRepository _exifInfoRepository;
final IUserRepository _userRepository;
final IETagRepository _etagRepository;
final IBackupRepository _backupRepository;
final IBackupAlbumRepository _backupRepository;
final ApiService _apiService;
final SyncService _syncService;
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/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.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/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/user.service.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/http_ssl_cert_override.dart';
import 'package:network_info_plus/network_info_plus.dart';
@@ -369,7 +369,8 @@ class BackgroundService {
}
Future<bool> _onAssetsChanged() async {
final db = await loadDb();
final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db);
HttpOverrides.global = HttpSSLCertOverride();
ApiService apiService = ApiService();
@@ -377,7 +378,7 @@ class BackgroundService {
AppSettingsService settingsService = AppSettingsService();
AlbumRepository albumRepository = AlbumRepository(db);
AssetRepository assetRepository = AssetRepository(db);
BackupRepository backupRepository = BackupRepository(db);
BackupAlbumRepository backupRepository = BackupAlbumRepository(db);
ExifInfoRepository exifInfoRepository = ExifInfoRepository(db);
ETagRepository eTagRepository = ETagRepository(db);
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
@pragma('vm:entry-point')
void _nativeEntry() {
HttpOverrides.global = HttpSSLCertOverride();
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
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/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/diff.dart';
/// Finds duplicates originating from missing EXIF information
@@ -123,6 +124,8 @@ class BackupVerificationService {
assert(tuple.deleteCandidates.length == tuple.originals.length);
final List<Asset> result = [];
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db);
await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint);

View File

@@ -2,11 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/store.model.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:immich_mobile/domain/services/log.service.dart';
import 'package:path_provider/path_provider.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
/// and generate a csv file.
class ImmichLogger {
static final ImmichLogger _instance = ImmichLogger._internal();
final maxLogEntries = 500;
final Isar _db = Isar.getInstance()!;
List<LoggerMessage> _msgBuffer = [];
Timer? _timer;
abstract final class ImmichLogger {
const ImmichLogger();
factory ImmichLogger() => _instance;
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 {
static Future<void> shareLogs(BuildContext context) async {
final tempDir = await getTemporaryDirectory();
final dateTime = DateTime.now().toIso8601String();
final filePath = '${tempDir.path}/Immich_log_$dateTime.log';
@@ -94,13 +25,13 @@ class ImmichLogger {
final io = logFile.openWrite();
try {
// Write messages
for (final m in messages) {
for (final m in await LogService.I.getMessages()) {
final created = m.createdAt;
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 error = m.details != null ? " ${m.details} |" : "";
final stack = m.context2 != null ? "\n${m.context2!}" : "";
final error = m.error == null ? "" : " ${m.error} |";
final stack = m.stack == null ? "" : "\n${m.stack!}";
io.write('$created | $level | $logger | $message |$error$stack\n');
}
} finally {
@@ -115,16 +46,6 @@ class ImmichLogger {
[XFile(filePath)],
subject: "Immich logs $dateTime",
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
).then(
(value) => logFile.delete(),
);
}
/// Flush pending log messages to persistent storage
void flush() {
if (_timer != null) {
_timer!.cancel();
_db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer));
}
).then((value) => logFile.delete());
}
}

View File

@@ -107,4 +107,14 @@ class UserService {
Future<void> 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 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
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/local_storage_settings.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_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:logging/logging.dart';
@@ -33,7 +34,8 @@ class AdvancedSettings extends HookConsumerWidget {
useValueChanged(
levelId.value,
(_, __) => ImmichLogger().level = Level.LEVELS[levelId.value],
(_, __) =>
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
);
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:
- API version: 1.127.0
- API version: 1.128.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.127.0+185
version: 1.128.0+186
environment:
sdk: '>=3.3.0 <4.0.0'
@@ -113,6 +113,7 @@ dev_dependencies:
mocktail: ^1.0.3
immich_mobile_immich_lint:
path: './immich_lint'
fake_async: ^1.3.1
flutter:
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:mocktail/mocktail.dart';
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_test/flutter_test.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/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/store.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/interfaces/asset.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:mocktail/mocktail.dart';
@@ -70,7 +71,10 @@ void main() {
db.writeTxnSync(() => db.clearSync());
await StoreService.init(storeRepository: IsarStoreRepository(db));
await Store.put(StoreKey.currentUser, owner);
ImmichLogger();
await LogService.init(
logRepo: IsarLogRepository(db),
storeRepo: IsarStoreRepository(db),
);
});
final List<Asset> initialAssets = [
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/auth.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/exif_info.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 MockBackupRepository extends Mock implements IBackupRepository {}
class MockBackupRepository extends Mock implements IBackupAlbumRepository {}
class MockExifInfoRepository extends Mock implements IExifInfoRepository {}

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
@@ -88,4 +90,36 @@ abstract final class TestUtils {
WidgetController.hitTestWarningShouldBeFatal = true;
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": {
"title": "Immich",
"description": "Immich API",
"version": "1.127.0",
"version": "1.128.0",
"contact": {}
},
"tags": [],
@@ -11952,7 +11952,8 @@
"SourceType": {
"enum": [
"machine-learning",
"exif"
"exif",
"manual"
],
"type": "string"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassConstructor } from 'class-transformer';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { mkdir, rm, writeFile } from 'node:fs/promises';
@@ -77,7 +78,7 @@ class SqlGenerator {
await mkdir(this.options.targetDir);
process.env.DB_HOSTNAME = 'localhost';
const { database, otel } = new ConfigRepository().getEnv();
const { database, cls, otel } = new ConfigRepository().getEnv();
const moduleFixture = await Test.createTestingModule({
imports: [
@@ -92,6 +93,7 @@ class SqlGenerator {
}
},
}),
ClsModule.forRoot(cls.config),
TypeOrmModule.forRoot({
...database.config.typeorm,
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 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 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 Sourcetype = 'exif' | 'machine-learning';
export type Sourcetype = 'exif' | 'machine-learning' | 'manual';
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
@@ -41,6 +41,7 @@ export interface Activity {
id: Generated<string>;
isLiked: Generated<boolean>;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string;
}
@@ -58,6 +59,7 @@ export interface Albums {
order: Generated<string>;
ownerId: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface AlbumsAssetsAssets {
@@ -79,6 +81,7 @@ export interface ApiKeys {
name: string;
permissions: Permission[];
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string;
}
@@ -103,6 +106,7 @@ export interface AssetFiles {
path: string;
type: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface AssetJobStatus {
@@ -143,6 +147,7 @@ export interface Assets {
thumbhash: Buffer | null;
type: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface AssetStack {
@@ -221,6 +226,7 @@ export interface Libraries {
ownerId: string;
refreshedAt: Timestamp | null;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface Memories {
@@ -236,6 +242,7 @@ export interface Memories {
showAt: Timestamp | null;
type: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface MemoriesAssetsAssets {
@@ -271,6 +278,7 @@ export interface Partners {
sharedById: string;
sharedWithId: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface Person {
@@ -285,6 +293,7 @@ export interface Person {
ownerId: string;
thumbnailPath: Generated<string>;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface Sessions {
@@ -294,6 +303,7 @@ export interface Sessions {
id: Generated<string>;
token: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string;
}
@@ -303,6 +313,7 @@ export interface SessionSyncCheckpoints {
sessionId: string;
type: SyncEntityType;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
@@ -358,6 +369,7 @@ export interface Tags {
id: Generated<string>;
parentId: string | null;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string;
value: string;
}
@@ -399,9 +411,11 @@ export interface Users {
status: Generated<string>;
storageLabel: string | null;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface UsersAudit {
id: Generated<string>;
userId: string;
deletedAt: Generated<Timestamp>;
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { UserEntity } from 'src/entities/user.entity';
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')
export class APIKeyEntity {
@@ -27,4 +27,8 @@ export class APIKeyEntity {
@UpdateDateColumn({ type: 'timestamptz' })
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' })
updatedAt!: Date;
@Index('IDX_asset_files_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column()
type!: AssetFileType;

View File

@@ -96,6 +96,10 @@ export class AssetEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_assets_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz', nullable: true })
deletedAt!: Date | null;
@@ -256,6 +260,7 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds:
.selectFrom('asset_faces')
.select('assetId')
.where('personId', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null)
.groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.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 */
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.isArchived ??= options.withArchived ? undefined : false;
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore);
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
return kysely
.withPlugin(joinDeduplicationPlugin)
.selectFrom('assets')

View File

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

View File

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

View File

@@ -1,5 +1,14 @@
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')
export class PartnerEntity {
@@ -23,6 +32,10 @@ export class PartnerEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_partners_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column({ type: 'boolean', default: false })
inTimeline!: boolean;
}

View File

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

View File

@@ -1,7 +1,7 @@
import { ExpressionBuilder } from 'kysely';
import { DB } from 'src/db';
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')
export class SessionEntity {
@@ -23,6 +23,10 @@ export class SessionEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_sessions_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId!: string;
@Column({ default: '' })
deviceType!: string;

View File

@@ -1,6 +1,6 @@
import { SessionEntity } from 'src/entities/session.entity';
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')
export class SessionSyncCheckpointEntity {
@@ -19,6 +19,10 @@ export class SessionSyncCheckpointEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_session_sync_checkpoints_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column()
ack!: string;
}

View File

@@ -4,6 +4,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
@@ -30,6 +31,10 @@ export class TagEntity {
@UpdateDateColumn({ type: 'timestamptz' })
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 })
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')
@Index('IDX_users_audit_deleted_at_asc_user_id_asc', ['deletedAt', 'userId'])
export class UserAuditEntity {
@PrimaryGeneratedColumn('increment')
id!: number;
@PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
id!: string;
@Column({ type: 'uuid' })
userId!: string;
@CreateDateColumn({ type: 'timestamptz' })
@Index('IDX_users_audit_deleted_at')
@CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
deletedAt!: Date;
}

View File

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

View File

@@ -228,6 +228,7 @@ export enum AssetStatus {
export enum SourceType {
MACHINE_LEARNING = 'machine-learning',
EXIF = 'exif',
MANUAL = 'manual',
}
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"
)
select
(
(now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date
) / 365 as "yearsAgo",
date_part(
'year',
("localDateTime" at time zone 'UTC')::date
)::int as "year",
json_agg("res") as "assets"
from
"res"
@@ -333,6 +334,7 @@ with
and "assets"."duplicateId" is not null
and "assets"."deletedAt" is null
and "assets"."isVisible" = $2
and "assets"."stackId" is null
group by
"assets"."duplicateId"
),

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { ClsService } from 'nestjs-cls';
import { ImmichWorker } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { LoggingRepository, MyConsoleLogger } from 'src/repositories/logging.repository';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { Mocked } from 'vitest';
describe(LoggingRepository.name, () => {
@@ -18,23 +18,25 @@ describe(LoggingRepository.name, () => {
} as unknown as Mocked<ClsService>;
});
describe('formatContext', () => {
it('should use colors', () => {
configMock.getEnv.mockReturnValue(mockEnvData({ noColor: false }));
describe(MyConsoleLogger.name, () => {
describe('formatContext', () => {
it('should use colors', () => {
sut = new LoggingRepository(clsMock, configMock);
sut.setAppName(ImmichWorker.API);
sut = new LoggingRepository(clsMock, configMock);
sut.setAppName(ImmichWorker.API);
const logger = new MyConsoleLogger(clsMock, { color: true });
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', () => {
configMock.getEnv.mockReturnValue(mockEnvData({ noColor: true }));
it('should not use colors when color is false', () => {
sut = new LoggingRepository(clsMock, configMock);
sut.setAppName(ImmichWorker.API);
sut = new LoggingRepository(clsMock, configMock);
sut.setAppName(ImmichWorker.API);
const logger = new MyConsoleLogger(clsMock, { color: false });
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 { 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];
enum LogColor {
@@ -16,38 +19,26 @@ enum LogColor {
CYAN_BRIGHT = 96,
}
@Injectable({ scope: Scope.TRANSIENT })
@Telemetry({ enabled: false })
export class LoggingRepository extends ConsoleLogger {
private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
private noColor: boolean;
let appName: string | undefined;
let logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
export class MyConsoleLogger extends ConsoleLogger {
private isColorEnabled: boolean;
constructor(
private cls: ClsService,
configRepository: ConfigRepository,
options?: { color?: boolean; context?: string },
) {
super(LoggingRepository.name);
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);
super(options?.context || MyConsoleLogger.name);
this.isColorEnabled = options?.color || false;
}
isLevelEnabled(level: LogLevel) {
return isLogLevelEnabled(level, LoggingRepository.logLevels);
return isLogLevelEnabled(level, logLevels);
}
setLogLevel(level: LogLevel | false): void {
LoggingRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : [];
}
protected formatContext(context: string): string {
let prefix = LoggingRepository.appName || '';
formatContext(context: string): string {
let prefix = appName || '';
if (context) {
prefix += (prefix ? ':' : '') + context;
}
@@ -74,6 +65,105 @@ export class LoggingRepository extends ConsoleLogger {
};
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 { 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 { LoggingRepository } from 'src/repositories/logging.repository';
@@ -55,16 +56,80 @@ export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | Fa
@Injectable()
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) {
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> {
const formData = await this.getFormData(payload, config);
let urlCounter = 0;
for (const url of urls) {
urlCounter++;
const isLast = urlCounter >= urls.length;
if (!isLast && (await this.shouldSkipUrl(url))) {
continue;
}
try {
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
if (response.ok) {
this.setUrlAvailability(url, true);
return response.json();
}
@@ -76,6 +141,7 @@ export class MachineLearningRepository {
`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`);

View File

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

View File

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

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