mirror of
https://github.com/immich-app/immich.git
synced 2025-12-08 13:51:02 -08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc143046e3 | ||
|
|
e684062569 | ||
|
|
5c0538e52c | ||
|
|
84cf0d1670 | ||
|
|
bfcde05b1c | ||
|
|
b3b15e9b61 | ||
|
|
819e56d9ca | ||
|
|
9a98712db7 | ||
|
|
a185e06399 | ||
|
|
f2be9f7ad1 | ||
|
|
5c879acd5b | ||
|
|
28c664c769 | ||
|
|
fbd85a89e0 | ||
|
|
1c86293035 | ||
|
|
4a9d80298b | ||
|
|
362feb1e62 | ||
|
|
5503bf7a60 | ||
|
|
d20e2e268a | ||
|
|
a708649504 | ||
|
|
a808b8610e | ||
|
|
c70c9067b0 | ||
|
|
082471dfd9 | ||
|
|
9a098b4658 | ||
|
|
9d705097e8 | ||
|
|
6050485ad8 | ||
|
|
fb907d707d | ||
|
|
7d6cfd09e6 | ||
|
|
967c69317b | ||
|
|
128d653fc6 | ||
|
|
8b69114924 | ||
|
|
4b55888d16 | ||
|
|
8fbd650483 | ||
|
|
c778516ce2 | ||
|
|
2969e25ff7 | ||
|
|
c055e1aefe |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
||||
custom: ['https://buy.immich.app']
|
||||
custom: ['https://buy.immich.app', 'https://immich.store']
|
||||
|
||||
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -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"
|
||||
|
||||
@@ -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
8
e2e/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.127.0",
|
||||
"version": "1.128.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
mobile/lib/domain/interfaces/log.interface.dart
Normal file
16
mobile/lib/domain/interfaces/log.interface.dart
Normal 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});
|
||||
}
|
||||
69
mobile/lib/domain/models/log.model.dart
Normal file
69
mobile/lib/domain/models/log.model.dart
Normal 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>'},
|
||||
}''';
|
||||
}
|
||||
}
|
||||
153
mobile/lib/domain/services/log.service.dart
Normal file
153
mobile/lib/domain/services/log.service.dart
Normal 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;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
52
mobile/lib/infrastructure/entities/log.entity.dart
Normal file
52
mobile/lib/infrastructure/entities/log.entity.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'logger_message.entity.dart';
|
||||
part of 'log.entity.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// IsarCollectionGenerator
|
||||
53
mobile/lib/infrastructure/repositories/log.repository.dart
Normal file
53
mobile/lib/infrastructure/repositories/log.repository.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 }
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
34
mobile/lib/services/backup_album.service.dart
Normal file
34
mobile/lib/services/backup_album.service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
56
mobile/lib/utils/bootstrap.dart
Normal file
56
mobile/lib/utils/bootstrap.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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
|
||||
|
||||
|
||||
3
mobile/openapi/lib/model/source_type.dart
generated
3
mobile/openapi/lib/model/source_type.dart
generated
@@ -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');
|
||||
|
||||
@@ -407,7 +407,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
|
||||
|
||||
@@ -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
|
||||
|
||||
186
mobile/test/domain/services/log_service_test.dart
Normal file
186
mobile/test/domain/services/log_service_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.127.0",
|
||||
"version": "1.128.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
16
server/src/db.d.ts
vendored
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ export enum AssetStatus {
|
||||
export enum SourceType {
|
||||
MACHINE_LEARNING = 'machine-learning',
|
||||
EXIF = 'exif',
|
||||
MANUAL = 'manual',
|
||||
}
|
||||
|
||||
export enum ManualJobName {
|
||||
|
||||
134
server/src/migrations/1740586617223-AddUpdateIdColumns.ts
Normal file
134
server/src/migrations/1740586617223-AddUpdateIdColumns.ts
Normal 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;
|
||||
$$;
|
||||
`)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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") `);
|
||||
}
|
||||
|
||||
}
|
||||
27
server/src/migrations/1740619600996-AddManualSourceType.ts
Normal file
27
server/src/migrations/1740619600996-AddManualSourceType.ts
Normal 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;`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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] ');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user