diff --git a/docs/docs/administration/email-notification.mdx b/docs/docs/administration/email-notification.mdx index 4a2a0b5a83..93b1051053 100644 --- a/docs/docs/administration/email-notification.mdx +++ b/docs/docs/administration/email-notification.mdx @@ -8,13 +8,11 @@ Immich supports the option to send notifications via Email for the following eve ## SMTP settings -You can access the settings panel from the web at `Administration -> Settings -> Notification settings` +You can access the settings panel from the web at `Administration -> Settings -> Notification settings`. -Under Email, enter the following details to connect with SMTP servers. +Under Email, enter the required details to connect with an SMTP server. -You can use the following [guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. - - +You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. ## User's notifications settings diff --git a/docs/docs/administration/img/email-settings.png b/docs/docs/administration/img/email-settings.png deleted file mode 100644 index a0d7135426..0000000000 Binary files a/docs/docs/administration/img/email-settings.png and /dev/null differ diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 9ef63523a0..a3bd703a01 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -80,6 +80,10 @@ The Compose file './docker-compose.yml' is invalid because: See the previous paragraph about installing from the official docker repository. ::: +:::info Health check start interval +If you get an error `can't set healthcheck.start_interval as feature require Docker Engine v25 or later`, it helps to comment out the line for `start_interval` in the `database` section of the `docker-compose.yml` file. +::: + :::tip For more information on how to use the application, please refer to the [Post Installation](/docs/install/post-install.mdx) guide. ::: diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index e49fde1464..e394091ae1 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:3cd9b520be95c671135ea1318f32be6912876024ee16d0f472669d3878801651 AS builder-cpu +FROM python:3.11-bookworm@sha256:157a371e60389919fe4a72dff71ce86eaa5234f59114c23b0b346d0d02c74d39 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:50ec89bdac0a845ec1751f91cb6187a3d8adb2b919d6e82d17acf48d1a9743fc AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:669bbd08353610485a94d5d0c976b4b6498c55280fe42c00f7581f85ee9f3121 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 85be083c3c..0754f882f3 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:b10f75974a30a6889b03519ac48d3e1510fd13d0689468c2c443033a15d84f1b AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:5f32c5742e2248f2ca07ccae6861371321aba37372bf8e1a80d6f728f1ab4418 AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 20ffc6466a..659c4c2f4d 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.114.0" +version = "0.114.2" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.114.0-py3-none-any.whl", hash = "sha256:83c8e95301c75c6575f7f6c4b885bf42a4c0b4a85e936e2faca25055470d0afe"}, - {file = "fastapi_slim-0.114.0.tar.gz", hash = "sha256:2299d5e0b8818f264725bd13dd91c80b904589be06c98c3d8115132576e5e2dd"}, + {file = "fastapi_slim-0.114.2-py3-none-any.whl", hash = "sha256:52ae76c53a30ad0fa96beb84c1bf4bef9c72e88c2f7c0473e836f01d7ac3ca6b"}, + {file = "fastapi_slim-0.114.2.tar.gz", hash = "sha256:76d0a450826fb0fa740268be55ef04c44807da87a94fbbf5f16338b5a4a2d321"}, ] [package.dependencies] @@ -2473,13 +2473,13 @@ files = [ [[package]] name = "pytest" -version = "8.3.2" +version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, - {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] @@ -2816,13 +2816,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.8.0" +version = "13.8.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" files = [ - {file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"}, - {file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"}, + {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, + {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, ] [package.dependencies] diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index c05b849dcd..b20cec97c3 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -164,12 +164,13 @@ class Album { } extension AssetsHelper on IsarCollection { - Future store(Album a) async { + Future store(Album a) async { await put(a); await a.owner.save(); await a.thumbnail.save(); await a.sharedUsers.save(); await a.assets.save(); + return a; } } diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart new file mode 100644 index 0000000000..c2ba650b6f --- /dev/null +++ b/mobile/lib/interfaces/album.interface.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IAlbumRepository { + Future count({bool? local}); + Future create(Album album); + Future getById(int id); + Future getByName( + String name, { + bool? shared, + bool? remote, + }); + Future update(Album album); + Future delete(int albumId); + Future> getAll({bool? shared}); + Future removeUsers(Album album, List users); + Future addAssets(Album album, List assets); + Future removeAssets(Album album, List assets); + Future recalculateMetadata(Album album); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart new file mode 100644 index 0000000000..46425ba617 --- /dev/null +++ b/mobile/lib/interfaces/asset.interface.dart @@ -0,0 +1,8 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IAssetRepository { + Future> getByAlbum(Album album, {User? notOwnedBy}); + Future deleteById(List ids); +} diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart new file mode 100644 index 0000000000..e343a9d390 --- /dev/null +++ b/mobile/lib/interfaces/backup.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/entities/backup_album.entity.dart'; + +abstract interface class IBackupRepository { + Future> getIdsBySelection(BackupSelection backup); +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart new file mode 100644 index 0000000000..d9841a1187 --- /dev/null +++ b/mobile/lib/interfaces/user.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IUserRepository { + Future> getByIds(List ids); +} diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart new file mode 100644 index 0000000000..08c939aa6c --- /dev/null +++ b/mobile/lib/repositories/album.repository.dart @@ -0,0 +1,85 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final albumRepositoryProvider = + Provider((ref) => AlbumRepository(ref.watch(dbProvider))); + +class AlbumRepository implements IAlbumRepository { + final Isar _db; + + AlbumRepository( + this._db, + ); + + @override + Future count({bool? local}) { + if (local == true) return _db.albums.where().localIdIsNotNull().count(); + if (local == false) return _db.albums.where().remoteIdIsNotNull().count(); + return _db.albums.count(); + } + + @override + Future create(Album album) => + _db.writeTxn(() => _db.albums.store(album)); + + @override + Future getByName(String name, {bool? shared, bool? remote}) { + var query = _db.albums.filter().nameEqualTo(name); + if (shared != null) { + query = query.sharedEqualTo(shared); + } + if (remote == true) { + query = query.localIdIsNull(); + } else if (remote == false) { + query = query.remoteIdIsNull(); + } + return query.findFirst(); + } + + @override + Future update(Album album) => + _db.writeTxn(() => _db.albums.store(album)); + + @override + Future delete(int albumId) => + _db.writeTxn(() => _db.albums.delete(albumId)); + + @override + Future> getAll({bool? shared}) { + final baseQuery = _db.albums.filter(); + QueryBuilder? query; + if (shared != null) { + query = baseQuery.sharedEqualTo(true); + } + return query?.findAll() ?? _db.albums.where().findAll(); + } + + @override + Future getById(int id) => _db.albums.get(id); + + @override + Future removeUsers(Album album, List users) => + _db.writeTxn(() => album.sharedUsers.update(unlink: users)); + + @override + Future addAssets(Album album, List assets) => + _db.writeTxn(() => album.assets.update(link: assets)); + + @override + Future removeAssets(Album album, List assets) => + _db.writeTxn(() => album.assets.update(unlink: assets)); + + @override + Future recalculateMetadata(Album album) async { + album.startDate = await album.assets.filter().fileCreatedAtProperty().min(); + album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); + album.lastModifiedAssetTimestamp = + await album.assets.filter().updatedAtProperty().max(); + return album; + } +} diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart new file mode 100644 index 0000000000..ea05feab38 --- /dev/null +++ b/mobile/lib/repositories/asset.repository.dart @@ -0,0 +1,31 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final assetRepositoryProvider = + Provider((ref) => AssetRepository(ref.watch(dbProvider))); + +class AssetRepository implements IAssetRepository { + final Isar _db; + + AssetRepository( + this._db, + ); + + @override + Future> getByAlbum(Album album, {User? notOwnedBy}) { + var query = album.assets.filter(); + if (notOwnedBy != null) { + query = query.not().ownerIdEqualTo(notOwnedBy.isarId); + } + return query.findAll(); + } + + @override + Future deleteById(List ids) => + _db.writeTxn(() => _db.assets.deleteAll(ids)); +} diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart new file mode 100644 index 0000000000..c9d93f7877 --- /dev/null +++ b/mobile/lib/repositories/backup.repository.dart @@ -0,0 +1,20 @@ +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/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final backupRepositoryProvider = + Provider((ref) => BackupRepository(ref.watch(dbProvider))); + +class BackupRepository implements IBackupRepository { + final Isar _db; + + BackupRepository( + this._db, + ); + + @override + Future> getIdsBySelection(BackupSelection backup) => + _db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart new file mode 100644 index 0000000000..cd87eb17ec --- /dev/null +++ b/mobile/lib/repositories/user.repository.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final userRepositoryProvider = + Provider((ref) => UserRepository(ref.watch(dbProvider))); + +class UserRepository implements IUserRepository { + final Isar _db; + + UserRepository( + this._db, + ); + + @override + Future> getByIds(List ids) async => + (await _db.users.getAllById(ids)).cast(); +} diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index ef56f9bf6c..92302a0d88 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -5,6 +5,10 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -12,11 +16,13 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -26,7 +32,10 @@ final albumServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(syncServiceProvider), - ref.watch(dbProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(backupRepositoryProvider), ), ); @@ -34,7 +43,10 @@ class AlbumService { final ApiService _apiService; final UserService _userService; final SyncService _syncService; - final Isar _db; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IUserRepository _userRepository; + final IBackupRepository _backupAlbumRepository; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); @@ -43,16 +55,12 @@ class AlbumService { this._apiService, this._userService, this._syncService, - this._db, + this._albumRepository, + this._assetRepository, + this._userRepository, + this._backupAlbumRepository, ); - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Checks all selected device albums for changes of albums and their assets /// Updates the local database and returns `true` if there were any changes Future refreshDeviceAlbums() async { @@ -65,12 +73,12 @@ class AlbumService { final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { - final List excludedIds = - await excludedAlbumsQuery().idProperty().findAll(); - final List selectedIds = - await selectedAlbumsQuery().idProperty().findAll(); + final List excludedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.exclude); + final List selectedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.select); if (selectedIds.isEmpty) { - final numLocal = await _db.albums.where().localIdIsNotNull().count(); + final numLocal = await _albumRepository.count(local: true); if (numLocal > 0) { _syncService.removeAllLocalAlbumsAndAssets(); } @@ -194,8 +202,8 @@ class AlbumService { ), ); if (remote != null) { - Album album = await Album.remote(remote); - await _db.writeTxn(() => _db.albums.store(album)); + final Album album = await Album.remote(remote); + await _albumRepository.create(album); return album; } } catch (e) { @@ -212,8 +220,7 @@ class AlbumService { for (int round = 0;; round++) { final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; - if (null == - await _db.albums.filter().nameEqualTo(proposedName).findFirst()) { + if (null == await _albumRepository.getByName(proposedName)) { return proposedName; } } @@ -268,20 +275,15 @@ class AlbumService { Future _updateAssets( int albumId, { - Iterable add = const [], - Iterable remove = const [], - }) { - return _db.writeTxn(() async { - final album = await _db.albums.get(albumId); - if (album == null) return; - await album.assets.update(link: add, unlink: remove); - album.startDate = - await album.assets.filter().fileCreatedAtProperty().min(); - album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); - album.lastModifiedAssetTimestamp = - await album.assets.filter().updatedAtProperty().max(); - await _db.albums.put(album); - }); + List add = const [], + List remove = const [], + }) async { + final album = await _albumRepository.getById(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); } Future addAdditionalUserToAlbum( @@ -298,13 +300,9 @@ class AlbumService { AddUsersDto(albumUsers: albumUsers), ); if (result != null) { - album.sharedUsers - .addAll((await _db.users.getAllById(sharedUserIds)).cast()); + album.sharedUsers.addAll(await _userRepository.getByIds(sharedUserIds)); album.shared = result.shared; - await _db.writeTxn(() async { - await _db.albums.put(album); - await album.sharedUsers.save(); - }); + await _albumRepository.update(album); return true; } } catch (e) { @@ -321,7 +319,7 @@ class AlbumService { ); if (result != null) { album.activityEnabled = enabled; - await _db.writeTxn(() => _db.albums.put(album)); + await _albumRepository.update(album); return true; } } catch (e) { @@ -332,29 +330,29 @@ class AlbumService { Future deleteAlbum(Album album) async { try { - final userId = Store.get(StoreKey.currentUser).isarId; - if (album.owner.value?.isarId == userId) { + final user = Store.get(StoreKey.currentUser); + if (album.owner.value?.isarId == user.isarId) { await _apiService.albumsApi.deleteAlbum(album.remoteId!); } if (album.shared) { final foreignAssets = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); - await _db.writeTxn(() => _db.albums.delete(album.id)); - final List albums = - await _db.albums.filter().sharedEqualTo(true).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: user); + await _albumRepository.delete(album.id); + + final List albums = await _albumRepository.getAll(shared: true); final List existing = []; - for (Album a in albums) { + for (Album album in albums) { existing.addAll( - await a.assets.filter().not().ownerIdEqualTo(userId).findAll(), + await _assetRepository.getByAlbum(album, notOwnedBy: user), ); } final List idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove)); + await _assetRepository.deleteById(idsToRemove); } } else { - await _db.writeTxn(() => _db.albums.delete(album.id)); + await _albumRepository.delete(album.id); } return true; } catch (e) { @@ -390,7 +388,7 @@ class AlbumService { : response .where((e) => e.success) .map((e) => assets.firstWhere((a) => a.remoteId == e.id)); - await _updateAssets(album.id, remove: toRemove); + await _updateAssets(album.id, remove: toRemove.toList()); return true; } } catch (e) { @@ -410,12 +408,10 @@ class AlbumService { ); album.sharedUsers.remove(user); - await _db.writeTxn(() async { - await album.sharedUsers.update(unlink: [user]); - final a = await _db.albums.get(album.id); - // trigger watcher - await _db.albums.put(a!); - }); + await _albumRepository.removeUsers(album, [user]); + final a = await _albumRepository.getById(album.id); + // trigger watcher + await _albumRepository.update(a!); return true; } catch (e) { @@ -436,7 +432,7 @@ class AlbumService { ), ); album.name = newAlbumTitle; - await _db.writeTxn(() => _db.albums.put(album)); + await _albumRepository.update(album); return true; } catch (e) { @@ -445,14 +441,8 @@ class AlbumService { } } - Future getAlbumByName(String name, bool remoteOnly) async { - return _db.albums - .filter() - .optional(remoteOnly, (q) => q.localIdIsNull()) - .nameEqualTo(name) - .sharedEqualTo(false) - .findFirst(); - } + Future getAlbumByName(String name, bool remoteOnly) => + _albumRepository.getByName(name, remote: remoteOnly ? true : null); /// /// Add the uploaded asset to the selected albums diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index fc3feb174d..0d4d547434 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -12,6 +12,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; @@ -355,12 +359,23 @@ class BackgroundService { AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); PartnerService partnerService = PartnerService(apiService, db); + AlbumRepository albumRepository = AlbumRepository(db); + AssetRepository assetRepository = AssetRepository(db); + UserRepository userRepository = UserRepository(db); + BackupRepository backupAlbumRepository = BackupRepository(db); HashService hashService = HashService(db, this); SyncService syncSerive = SyncService(db, hashService); UserService userService = UserService(apiService, db, syncSerive, partnerService); - AlbumService albumService = - AlbumService(apiService, userService, syncSerive, db); + AlbumService albumService = AlbumService( + apiService, + userService, + syncSerive, + albumRepository, + assetRepository, + userRepository, + backupAlbumRepository, + ); BackupService backupService = BackupService(apiService, db, settingService, albumService); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 36b2c7bbf4..16f293f81a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -124,6 +124,7 @@ Class | Method | HTTP request | Description *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | *FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports | *FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum | +*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | *JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | *JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | *LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | @@ -330,6 +331,7 @@ Class | Method | HTTP request | Description - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) - [JobCountsDto](doc//JobCountsDto.md) + - [JobCreateDto](doc//JobCreateDto.md) - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - [JobStatusDto](doc//JobStatusDto.md) @@ -341,6 +343,7 @@ Class | Method | HTTP request | Description - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) + - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapTheme](doc//MapTheme.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 091e900145..915c70f08e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -144,6 +144,7 @@ part 'model/image_format.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; part 'model/job_counts_dto.dart'; +part 'model/job_create_dto.dart'; part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; @@ -155,6 +156,7 @@ part 'model/log_level.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; +part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_theme.dart'; diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 5f9501d126..78afc15c93 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -16,6 +16,45 @@ class JobsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /jobs' operation and returns the [Response]. + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJobWithHttpInfo(JobCreateDto jobCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/jobs'; + + // ignore: prefer_final_locals + Object? postBody = jobCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJob(JobCreateDto jobCreateDto,) async { + final response = await createJobWithHttpInfo(jobCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /jobs' operation and returns the [Response]. Future getAllJobsStatusWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9ec00aecc8..6a40de730c 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -343,6 +343,8 @@ class ApiClient { return JobCommandDto.fromJson(value); case 'JobCountsDto': return JobCountsDto.fromJson(value); + case 'JobCreateDto': + return JobCreateDto.fromJson(value); case 'JobName': return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': @@ -365,6 +367,8 @@ class ApiClient { return LoginResponseDto.fromJson(value); case 'LogoutResponseDto': return LogoutResponseDto.fromJson(value); + case 'ManualJobName': + return ManualJobNameTypeTransformer().decode(value); case 'MapMarkerResponseDto': return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 8dcef880f5..0f3cc41097 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -97,6 +97,9 @@ String parameterToString(dynamic value) { if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } + if (value is ManualJobName) { + return ManualJobNameTypeTransformer().encode(value).toString(); + } if (value is MapTheme) { return MapThemeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/job_create_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart new file mode 100644 index 0000000000..a4734791bb --- /dev/null +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class JobCreateDto { + /// Returns a new [JobCreateDto] instance. + JobCreateDto({ + required this.name, + }); + + ManualJobName name; + + @override + bool operator ==(Object other) => identical(this, other) || other is JobCreateDto && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (name.hashCode); + + @override + String toString() => 'JobCreateDto[name=$name]'; + + Map toJson() { + final json = {}; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [JobCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static JobCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return JobCreateDto( + name: ManualJobName.fromJson(json[r'name'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = JobCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = JobCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of JobCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = JobCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'name', + }; +} + diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart new file mode 100644 index 0000000000..7e8d9d51b2 --- /dev/null +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ManualJobName { + /// Instantiate a new enum with the provided [value]. + const ManualJobName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const personCleanup = ManualJobName._(r'person-cleanup'); + static const tagCleanup = ManualJobName._(r'tag-cleanup'); + static const userCleanup = ManualJobName._(r'user-cleanup'); + + /// List of all possible values in this [enum][ManualJobName]. + static const values = [ + personCleanup, + tagCleanup, + userCleanup, + ]; + + static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ManualJobName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ManualJobName] to String, +/// and [decode] dynamic data back to [ManualJobName]. +class ManualJobNameTypeTransformer { + factory ManualJobNameTypeTransformer() => _instance ??= const ManualJobNameTypeTransformer._(); + + const ManualJobNameTypeTransformer._(); + + String encode(ManualJobName data) => data.value; + + /// Decodes a [dynamic value][data] to a ManualJobName. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ManualJobName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'person-cleanup': return ManualJobName.personCleanup; + case r'tag-cleanup': return ManualJobName.tagCleanup; + case r'user-cleanup': return ManualJobName.userCleanup; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ManualJobNameTypeTransformer] instance. + static ManualJobNameTypeTransformer? _instance; +} + diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart new file mode 100644 index 0000000000..e54d82739e --- /dev/null +++ b/mobile/test/repository.mocks.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAlbumRepository extends Mock implements IAlbumRepository {} + +class MockAssetRepository extends Mock implements IAssetRepository {} + +class MockUserRepository extends Mock implements IUserRepository {} + +class MockBackupRepository extends Mock implements IBackupRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart new file mode 100644 index 0000000000..ba4c129e5c --- /dev/null +++ b/mobile/test/service.mocks.dart @@ -0,0 +1,10 @@ +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockApiService extends Mock implements ApiService {} + +class MockUserService extends Mock implements UserService {} + +class MockSyncService extends Mock implements SyncService {} diff --git a/mobile/test/services/album.service.test.dart b/mobile/test/services/album.service.test.dart new file mode 100644 index 0000000000..790a0eba35 --- /dev/null +++ b/mobile/test/services/album.service.test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; + +void main() { + late AlbumService sut; + late MockApiService apiService; + late MockUserService userService; + late MockSyncService syncService; + late MockAlbumRepository albumRepository; + late MockAssetRepository assetRepository; + late MockUserRepository userRepository; + late MockBackupRepository backupRepository; + + setUp(() { + apiService = MockApiService(); + userService = MockUserService(); + syncService = MockSyncService(); + albumRepository = MockAlbumRepository(); + assetRepository = MockAssetRepository(); + userRepository = MockUserRepository(); + backupRepository = MockBackupRepository(); + + sut = AlbumService( + apiService, + userService, + syncService, + albumRepository, + assetRepository, + userRepository, + backupRepository, + ); + }); + + group('refreshDeviceAlbums', () { + test('empty selection with one album in db', () async { + when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)) + .thenAnswer((_) async => []); + when(() => backupRepository.getIdsBySelection(BackupSelection.select)) + .thenAnswer((_) async => []); + when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1); + when(() => syncService.removeAllLocalAlbumsAndAssets()) + .thenAnswer((_) async => true); + final result = await sut.refreshDeviceAlbums(); + expect(result, false); + verify(() => syncService.removeAllLocalAlbumsAndAssets()); + }); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b4ec4505b9..af79815563 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2561,6 +2561,39 @@ "tags": [ "Jobs" ] + }, + "post": { + "operationId": "createJob", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Jobs" + ] } }, "/jobs/{id}": { @@ -9269,6 +9302,17 @@ ], "type": "object" }, + "JobCreateDto": { + "properties": { + "name": { + "$ref": "#/components/schemas/ManualJobName" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "JobName": { "enum": [ "thumbnailGeneration", @@ -9511,6 +9555,14 @@ ], "type": "object" }, + "ManualJobName": { + "enum": [ + "person-cleanup", + "tag-cleanup", + "user-cleanup" + ], + "type": "string" + }, "MapMarkerResponseDto": { "properties": { "city": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9350bd5604..da57313692 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -548,6 +548,9 @@ export type AllJobStatusResponseDto = { thumbnailGeneration: JobStatusDto; videoConversion: JobStatusDto; }; +export type JobCreateDto = { + name: ManualJobName; +}; export type JobCommandDto = { command: JobCommand; force: boolean; @@ -1941,6 +1944,15 @@ export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createJob({ jobCreateDto }: { + jobCreateDto: JobCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/jobs", oazapfts.json({ + ...opts, + method: "POST", + body: jobCreateDto + }))); +} export function sendJobCommand({ id, jobCommandDto }: { id: JobName; jobCommandDto: JobCommandDto; @@ -3364,6 +3376,11 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } +export enum ManualJobName { + PersonCleanup = "person-cleanup", + TagCleanup = "tag-cleanup", + UserCleanup = "user-cleanup" +} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 2aa5920fab..7da19e207f 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; @@ -15,6 +15,12 @@ export class JobController { return this.service.getAllJobsStatus(); } + @Post() + @Authenticated({ admin: true }) + createJob(@Body() dto: JobCreateDto): Promise { + return this.service.create(dto); + } + @Put(':id') @Authenticated({ admin: true }) sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise { diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index b7d8cf59bf..895f710b7a 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; +import { ManualJobName } from 'src/enum'; import { JobCommand, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean } from 'src/validation'; @@ -20,6 +21,12 @@ export class JobCommandDto { force!: boolean; } +export class JobCreateDto { + @IsEnum(ManualJobName) + @ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' }) + name!: ManualJobName; +} + export class JobCountsDto { @ApiProperty({ type: 'integer' }) active!: number; diff --git a/server/src/enum.ts b/server/src/enum.ts index 32254854e4..d76d97371c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -186,3 +186,9 @@ export enum SourceType { MACHINE_LEARNING = 'machine-learning', EXIF = 'exif', } + +export enum ManualJobName { + PERSON_CLEANUP = 'person-cleanup', + TAG_CLEANUP = 'tag-cleanup', + USER_CLEANUP = 'user-cleanup', +} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index a0533fa63f..d0a15bfa5d 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -60,6 +60,9 @@ export enum JobName { STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', + // tags + TAG_CLEANUP = 'tag-cleanup', + // migration QUEUE_MIGRATION = 'queue-migration', MIGRATE_ASSET = 'migrate-asset', @@ -262,6 +265,9 @@ export type JobItem = | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } + // Tags + | { name: JobName.TAG_CLEANUP; data?: IBaseJob } + // Asset Deletion | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index aca9c223d5..16a34d6ac4 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -17,4 +17,5 @@ export interface ITagRepository extends IBulkAsset { upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; upsertAssetIds(items: AssetTagItem[]): Promise; + deleteEmptyTags(): Promise; } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index f64e5175e5..2981fa4bdd 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -41,6 +41,9 @@ export const JOBS_TO_QUEUE: Record = { [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, + // tags + [JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK, + // metadata [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 9389aeb13b..1a5415b8db 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,10 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { DataSource, In, Repository } from 'typeorm'; +import { DataSource, In, Repository, TreeRepository } from 'typeorm'; @Instrumentation() @Injectable() @@ -12,7 +13,11 @@ export class TagRepository implements ITagRepository { constructor( @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, - ) {} + @InjectRepository(TagEntity) private tree: TreeRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(TagRepository.name); + } get(id: string): Promise { return this.repository.findOne({ where: { id } }); @@ -174,6 +179,34 @@ export class TagRepository implements ITagRepository { }); } + async deleteEmptyTags() { + await this.dataSource.transaction(async (manager) => { + const ids = new Set(); + const tags = await manager.find(TagEntity); + for (const tag of tags) { + const count = await manager + .createQueryBuilder('assets', 'asset') + .innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', + { tagId: tag.id }, + ) + .getCount(); + + if (count === 0) { + this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`); + ids.add(tag.id); + } + } + + if (ids.size > 0) { + await manager.delete(TagEntity, { id: In([...ids]) }); + this.logger.log(`Deleted ${ids.size} empty tags`); + } + }); + } + private async save(partial: Partial): Promise { const { id } = await this.repository.save(partial); return this.repository.findOneOrFail({ where: { id } }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa61ccf3cb..03a6edf126 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -2,8 +2,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType } from 'src/enum'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { @@ -22,6 +22,26 @@ import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +const asJobItem = (dto: JobCreateDto): JobItem => { + switch (dto.name) { + case ManualJobName.TAG_CLEANUP: { + return { name: JobName.TAG_CLEANUP }; + } + + case ManualJobName.PERSON_CLEANUP: { + return { name: JobName.PERSON_CLEANUP }; + } + + case ManualJobName.USER_CLEANUP: { + return { name: JobName.USER_DELETE_CHECK }; + } + + default: { + throw new BadRequestException('Invalid job name'); + } + } +}; + @Injectable() export class JobService { private configCore: SystemConfigCore; @@ -39,6 +59,10 @@ export class JobService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } + async create(dto: JobCreateDto): Promise { + await this.jobRepository.queue(asJobItem(dto)); + } + async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 025400cc9b..df4b072d56 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -15,6 +15,7 @@ import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; +import { TagService } from 'src/services/tag.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { otelShutdown } from 'src/utils/instrumentation'; @@ -34,6 +35,7 @@ export class MicroservicesService { private sessionService: SessionService, private storageTemplateService: StorageTemplateService, private storageService: StorageService, + private tagService: TagService, private userService: UserService, private duplicateService: DuplicateService, private versionService: VersionService, @@ -93,6 +95,7 @@ export class MicroservicesService { [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), [JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), + [JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(), [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), }); } diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 97b0ef1be6..cc6d64f749 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -14,6 +14,7 @@ import { TagEntity } from 'src/entities/tag.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; +import { JobStatus } from 'src/interfaces/job.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @@ -138,6 +139,11 @@ export class TagService { return results; } + async handleTagCleanup() { + await this.repository.deleteEmptyTags(); + return JobStatus.SUCCESS; + } + private async findOrFail(id: string) { const tag = await this.repository.get(id); if (!tag) { diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a3fc0e77e0..acc2b59f6d 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -17,5 +17,6 @@ export const newTagRepositoryMock = (): Mocked => { addAssetIds: vitest.fn(), removeAssetIds: vitest.fn(), upsertAssetIds: vitest.fn(), + deleteEmptyTags: vitest.fn(), }; }; diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 37f875c604..9b0e4b3270 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -71,7 +71,7 @@
-
+
diff --git a/web/src/lib/components/shared-components/settings/setting-accordion.svelte b/web/src/lib/components/shared-components/settings/setting-accordion.svelte index eec4fae9c2..d8b50b2132 100755 --- a/web/src/lib/components/shared-components/settings/setting-accordion.svelte +++ b/web/src/lib/components/shared-components/settings/setting-accordion.svelte @@ -2,6 +2,7 @@ import { slide } from 'svelte/transition'; import { getAccordionState } from './setting-accordion-state.svelte'; import { onDestroy } from 'svelte'; + import Icon from '$lib/components/elements/icon.svelte'; const accordionState = getAccordionState(); @@ -10,6 +11,7 @@ export let key: string; export let isOpen = $accordionState.has(key); export let autoScrollTo = false; + export let icon = ''; let accordionElement: HTMLDivElement; @@ -38,7 +40,12 @@ }); -
+