Compare commits

..

10 Commits

Author SHA1 Message Date
Alex Tran 9b31ca6e75 feat: workflow step card with drag-n-drop ghost target 2026-05-29 09:40:18 -05:00
pneuly a838167f11 fix(ml): pass model_root_dir to OcrOptions for RapidOCR compatibility (#28610)
* fix(ml): pass model_root_dir to OcrOptions for RapidOCR compatibility

Fix a TypeError (Path(None)) when the OCR model is invoked, caused by an upstream change in RapidOCR v3.8.1 (RapidAI/RapidOCR@8ea9626).
RapidOCR now internally calls `Path(cfg.get("model_root_dir"))`. Since `model_root_dir` was missing from `OcrOptions`, it evaluated to `None` and triggered a `TypeError: argument should be a str or an os.PathLike`.
This fix adds the missing `model_root_dir` argument to prevent the error.
Ref: #28331

* fix(ml-test): update OCR tests for RapidOCR schema change

* chore(ml-test): remove unused `cache_dir` parameter from `TextRecognizer`

* Revert "chore(ml-test): remove unused `cache_dir` parameter from `TextRecognizer`"

This reverts commit 007ad7b3f2.

* fix(ml): use self.cache_dir for model_root_dir in OcrOptions
2026-05-28 22:54:04 -04:00
Mert b189fc571c fix: make ts a peer dependency for swagger (#28677)
make ts a peer dependency
2026-05-28 22:04:25 +00:00
Jason Rasmussen 96923f6115 refactor: plugin sdk types (#28674) 2026-05-28 22:04:15 +00:00
shenlong 0d6cce4a5b fix: api repositories using stale endpoint (#28667)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 16:44:11 -05:00
shenlong 55947cb227 refactor: drop metadata scope (#28668)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 16:42:59 -05:00
Jason Rasmussen 8783180cf3 refactor: plugin manifest (#28673) 2026-05-28 17:23:49 -04:00
Jason Rasmussen 134c0d4dfb feat: search by album name and id (#28672) 2026-05-28 17:01:47 -04:00
Alex aecf8ec88b fix: timeline scroll flicker (#28653)
* test: fix scroll flicker

* lint
2026-05-28 08:20:54 -05:00
Daniel Dietzler bcff1d42b0 chore: migrate more make targets (#28663) 2026-05-28 08:33:57 -04:00
123 changed files with 926 additions and 2785 deletions
+15 -24
View File
@@ -1,46 +1,46 @@
dev:
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1
dev-down:
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n" >&2 && exit 1
dev-update:
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n" >&2 && exit 1
dev-scale:
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n" >&2 && exit 1
dev-docs:
npm --prefix docs run start
.PHONY: e2e
e2e:
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1
e2e-dev:
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n" >&2 && exit 1
e2e-update:
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n" >&2 && exit 1
e2e-down:
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n" >&2 && exit 1
prod:
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1
prod-down:
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n" >&2 && exit 1
prod-scale:
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n" >&2 && exit 1
.PHONY: open-api
open-api:
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n" >&2 && exit 1
sql:
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n" >&2 && exit 1
renovate:
@@ -52,16 +52,7 @@ renovate:
MODULES = e2e server web cli sdk docs .github
test-e2e:
docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
@printf "This command has been removed. Please use:\n\n mise //e2e:test # or mise //e2e:test-web for web tests, respectively\n\n" >&2 && exit 1
clean:
find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
@printf "This command has been removed. Please use:\n\n mise clean # or mise //:clean from another directory\n\n" >&2 && exit 1
+16 -1
View File
@@ -1,11 +1,21 @@
[tasks.install]
run = "pnpm install --filter immich-e2e --frozen-lockfile"
[tasks.build]
dir = "{{ config_root }}"
run = "docker compose build"
[tasks.test]
depends = ["//e2e:build", "//e2e:ci-setup"]
env._.path = "./node_modules/.bin"
run = "vitest --run"
[tasks.playwright-install]
env._.path = "./node_modules/.bin"
run = "playwright install"
[tasks."test-web"]
depends = ["//e2e:build", "//e2e:ci-setup", "//e2e:playwright-install"]
env._.path = "./node_modules/.bin"
run = "playwright test"
@@ -30,7 +40,12 @@ run = "tsc --noEmit"
[tasks.ci-setup]
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
depends = [
"//:sdk:install",
"//:sdk:build",
"//packages/cli:install",
"//packages/cli:build",
]
run = { task = ":install" }
+1
View File
@@ -2233,6 +2233,7 @@
"slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
"smart_album": "Smart album",
"sort_albums_by": "Sort albums by...",
"sort_created": "Date created",
"sort_items": "Number of items",
@@ -64,6 +64,7 @@ class TextRecognizer(InferenceModel):
rec_batch_num=max_batch_size if max_batch_size else 6,
rec_img_shape=(3, 48, 320),
lang_type=self.language,
model_root_dir=self.cache_dir,
)
)
return session
+18 -3
View File
@@ -1028,7 +1028,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
@@ -1041,7 +1046,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=4,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
def test_ignore_other_custom_max_batch_size(
@@ -1056,7 +1066,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
+14 -3
View File
@@ -54,8 +54,8 @@ lockfile = true
[tasks.plugins]
run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core build",
]
[tasks.open-api-typescript]
@@ -108,7 +108,7 @@ depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.prod.yml up --remove-orphans"
run = "docker compose -f ./docker-compose.prod.yml up --build --remove-orphans"
depends_post = "//:prod-down"
[tasks.prod-scale]
@@ -165,3 +165,14 @@ run = "pnpm format"
[tasks."i18n:format-fix"]
run = "pnpm format:fix"
[tasks.clean]
run = [
"find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
"find . -name 'dist' -type d -prune -exec rm -rf '{}' +",
"find . -name 'build' -type d -prune -exec rm -rf '{}' +",
"find . -name '.svelte-kit' -type d -prune -exec rm -rf '{}' +",
"find . -name 'coverage' -type d -prune -exec rm -rf '{}' +",
"find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +",
{ task = "//:*-down" },
]
@@ -143,13 +143,8 @@ class AppConfig {
})
as T;
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) {
var config = const AppConfig();
for (final MapEntry(key: key, value: value) in entries.entries) {
config = config.write(key, value);
}
return config;
}
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> overrides) =>
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
return switch (key) {
+7 -18
View File
@@ -7,13 +7,6 @@ import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataScope {
user, // keys with this scope are deleted on logout
system;
const MetadataScope();
}
enum MetadataKey<T extends Object> {
// Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
@@ -32,14 +25,11 @@ enum MetadataKey<T extends Object> {
viewerTapToNavigate<bool>(),
// Network
networkAutoEndpointSwitching<bool>(scope: .system),
networkPreferredWifiName<String>(scope: .system),
networkLocalEndpoint<String>(scope: .system),
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(
scope: .system,
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
networkAutoEndpointSwitching<bool>(),
networkPreferredWifiName<String>(),
networkLocalEndpoint<String>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
// Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
@@ -60,7 +50,7 @@ enum MetadataKey<T extends Object> {
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(),
@@ -83,10 +73,9 @@ enum MetadataKey<T extends Object> {
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final MetadataScope scope;
final _MetadataCodec<T>? _codecOverride;
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
const MetadataKey({_MetadataCodec<T>? codec}) : _codecOverride = codec;
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
@@ -34,21 +34,33 @@ class MetadataRepository extends DriftDatabaseRepository {
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
Future<void> clear(Iterable<MetadataKey> keys) async {
if (keys.isEmpty) {
return;
}
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.metadataEntity)..where((row) => row.key.isIn(names))).go();
for (final key in keys) {
_appConfig = _appConfig.write(key, defaultConfig.read(key));
}
}
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (value == _appConfig.read(key)) {
return;
}
if (value == defaultConfig.read(key)) {
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
} else {
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
return clear([key]);
}
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
_appConfig = _appConfig.write(key, value);
}
+2 -2
View File
@@ -13,7 +13,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class ApiService {
late ApiClient _apiClient;
final ApiClient _apiClient = ApiClient(basePath: '');
late UsersApi usersApi;
late AuthenticationApi authenticationApi;
@@ -54,7 +54,7 @@ class ApiService {
}
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint);
_apiClient.basePath = endpoint;
_apiClient.client = NetworkRepository.client;
usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient);
+7 -1
View File
@@ -110,7 +110,7 @@ class AuthService {
/// - Authentication repository data
/// - Current user information
/// - Access token
/// - Asset ETag
/// - Server-specific endpoint configuration
///
/// All deletions are executed in parallel using [Future.wait].
Future<void> clearLocalData() async {
@@ -120,6 +120,12 @@ class AuthService {
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
MetadataRepository.instance.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,
.networkLocalEndpoint,
.networkExternalEndpointList,
]),
]);
}
+1 -6
View File
@@ -92,12 +92,10 @@ Class | Method | HTTP request | Description
*AlbumsApi* | [**getAlbumMapMarkers**](doc//AlbumsApi.md#getalbummapmarkers) | **GET** /albums/{id}/map-markers | Retrieve album map markers
*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | Retrieve album statistics
*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | List all albums
*AlbumsApi* | [**getOwnAlbumUser**](doc//AlbumsApi.md#getownalbumuser) | **GET** /albums/{id}/user/self | Get own sharing permissions
*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | Remove assets from an album
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role
*AlbumsApi* | [**updateOwnAlbumUser**](doc//AlbumsApi.md#updateownalbumuser) | **PUT** /albums/{id}/user/self | Update own sharing permissions
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload
*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset
*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key
@@ -455,7 +453,7 @@ Class | Method | HTTP request | Description
- [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
- [MemoryType](doc//MemoryType.md)
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergeFaceClusterDto](doc//MergeFaceClusterDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md)
- [MirrorAxis](doc//MirrorAxis.md)
- [MirrorParameters](doc//MirrorParameters.md)
@@ -549,8 +547,6 @@ Class | Method | HTTP request | Description
- [SharedLinkType](doc//SharedLinkType.md)
- [SharedLinksResponse](doc//SharedLinksResponse.md)
- [SharedLinksUpdate](doc//SharedLinksUpdate.md)
- [SharingOptionsResponseDto](doc//SharingOptionsResponseDto.md)
- [SharingPermission](doc//SharingPermission.md)
- [SignUpDto](doc//SignUpDto.md)
- [SmartSearchDto](doc//SmartSearchDto.md)
- [SourceType](doc//SourceType.md)
@@ -650,7 +646,6 @@ Class | Method | HTTP request | Description
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UpdateSharingOptionsDto](doc//UpdateSharingOptionsDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserAdminCreateDto](doc//UserAdminCreateDto.md)
- [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
+1 -4
View File
@@ -198,7 +198,7 @@ part 'model/memory_search_order.dart';
part 'model/memory_statistics_response_dto.dart';
part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
part 'model/merge_face_cluster_dto.dart';
part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart';
part 'model/mirror_axis.dart';
part 'model/mirror_parameters.dart';
@@ -292,8 +292,6 @@ part 'model/shared_link_response_dto.dart';
part 'model/shared_link_type.dart';
part 'model/shared_links_response.dart';
part 'model/shared_links_update.dart';
part 'model/sharing_options_response_dto.dart';
part 'model/sharing_permission.dart';
part 'model/sign_up_dto.dart';
part 'model/smart_search_dto.dart';
part 'model/source_type.dart';
@@ -393,7 +391,6 @@ part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart';
part 'model/update_library_dto.dart';
part 'model/update_sharing_options_dto.dart';
part 'model/usage_by_user_dto.dart';
part 'model/user_admin_create_dto.dart';
part 'model/user_admin_delete_dto.dart';
+21 -113
View File
@@ -508,12 +508,18 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters)
///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
///
/// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async {
///
/// * [String] name:
/// Album name (exact match)
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -527,12 +533,18 @@ class AlbumsApi {
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (isOwned != null) {
queryParams.addAll(_queryParams('', 'isOwned', isOwned));
}
if (isShared != null) {
queryParams.addAll(_queryParams('', 'isShared', isShared));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
const contentTypes = <String>[];
@@ -557,13 +569,19 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters)
///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
///
/// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, );
///
/// * [String] name:
/// Album name (exact match)
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, id: id, isOwned: isOwned, isShared: isShared, name: name, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -580,63 +598,6 @@ class AlbumsApi {
return null;
}
/// Get own sharing permissions
///
/// Get the own sharing permissions in a specific album.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getOwnAlbumUserWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/self'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Get own sharing permissions
///
/// Get the own sharing permissions in a specific album.
///
/// Parameters:
///
/// * [String] id (required):
Future<SharingOptionsResponseDto?> getOwnAlbumUser(String id,) async {
final response = await getOwnAlbumUserWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SharingOptionsResponseDto',) as SharingOptionsResponseDto;
}
return null;
}
/// Remove assets from an album
///
/// Remove multiple assets from a specific album by its ID.
@@ -873,57 +834,4 @@ class AlbumsApi {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Update own sharing permissions
///
/// Change the own sharing permissions in a specific album.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateSharingOptionsDto] updateSharingOptionsDto (required):
Future<Response> updateOwnAlbumUserWithHttpInfo(String id, UpdateSharingOptionsDto updateSharingOptionsDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/self'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = updateSharingOptionsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Update own sharing permissions
///
/// Change the own sharing permissions in a specific album.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateSharingOptionsDto] updateSharingOptionsDto (required):
Future<void> updateOwnAlbumUser(String id, UpdateSharingOptionsDto updateSharingOptionsDto,) async {
final response = await updateOwnAlbumUserWithHttpInfo(id, updateSharingOptionsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}
+6 -6
View File
@@ -448,14 +448,14 @@ class PeopleApi {
///
/// * [String] id (required):
///
/// * [MergeFaceClusterDto] mergeFaceClusterDto (required):
Future<Response> mergePersonWithHttpInfo(String id, MergeFaceClusterDto mergeFaceClusterDto,) async {
/// * [MergePersonDto] mergePersonDto (required):
Future<Response> mergePersonWithHttpInfo(String id, MergePersonDto mergePersonDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/people/{id}/merge'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = mergeFaceClusterDto;
Object? postBody = mergePersonDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
@@ -483,9 +483,9 @@ class PeopleApi {
///
/// * [String] id (required):
///
/// * [MergeFaceClusterDto] mergeFaceClusterDto (required):
Future<List<BulkIdResponseDto>?> mergePerson(String id, MergeFaceClusterDto mergeFaceClusterDto,) async {
final response = await mergePersonWithHttpInfo(id, mergeFaceClusterDto,);
/// * [MergePersonDto] mergePersonDto (required):
Future<List<BulkIdResponseDto>?> mergePerson(String id, MergePersonDto mergePersonDto,) async {
final response = await mergePersonWithHttpInfo(id, mergePersonDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+3 -9
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,});
final String basePath;
String basePath;
final Authentication? authentication;
var _client = Client();
@@ -442,8 +442,8 @@ class ApiClient {
return MemoryTypeTypeTransformer().decode(value);
case 'MemoryUpdateDto':
return MemoryUpdateDto.fromJson(value);
case 'MergeFaceClusterDto':
return MergeFaceClusterDto.fromJson(value);
case 'MergePersonDto':
return MergePersonDto.fromJson(value);
case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value);
case 'MirrorAxis':
@@ -630,10 +630,6 @@ class ApiClient {
return SharedLinksResponse.fromJson(value);
case 'SharedLinksUpdate':
return SharedLinksUpdate.fromJson(value);
case 'SharingOptionsResponseDto':
return SharingOptionsResponseDto.fromJson(value);
case 'SharingPermission':
return SharingPermissionTypeTransformer().decode(value);
case 'SignUpDto':
return SignUpDto.fromJson(value);
case 'SmartSearchDto':
@@ -832,8 +828,6 @@ class ApiClient {
return UpdateAssetDto.fromJson(value);
case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value);
case 'UpdateSharingOptionsDto':
return UpdateSharingOptionsDto.fromJson(value);
case 'UsageByUserDto':
return UsageByUserDto.fromJson(value);
case 'UserAdminCreateDto':
-3
View File
@@ -163,9 +163,6 @@ String parameterToString(dynamic value) {
if (value is SharedLinkType) {
return SharedLinkTypeTypeTransformer().encode(value).toString();
}
if (value is SharingPermission) {
return SharingPermissionTypeTransformer().encode(value).toString();
}
if (value is SourceType) {
return SourceTypeTypeTransformer().encode(value).toString();
}
+1 -9
View File
@@ -37,7 +37,6 @@ class AssetResponseDto {
this.owner,
required this.ownerId,
this.people = const [],
this.permissions = const [],
this.resized,
this.stack,
this.tags = const [],
@@ -141,8 +140,6 @@ class AssetResponseDto {
List<PersonResponseDto> people;
List<SharingPermission> permissions;
/// Is resized
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -198,7 +195,6 @@ class AssetResponseDto {
other.owner == owner &&
other.ownerId == ownerId &&
_deepEquality.equals(other.people, people) &&
_deepEquality.equals(other.permissions, permissions) &&
other.resized == resized &&
other.stack == stack &&
_deepEquality.equals(other.tags, tags) &&
@@ -235,7 +231,6 @@ class AssetResponseDto {
(owner == null ? 0 : owner!.hashCode) +
(ownerId.hashCode) +
(people.hashCode) +
(permissions.hashCode) +
(resized == null ? 0 : resized!.hashCode) +
(stack == null ? 0 : stack!.hashCode) +
(tags.hashCode) +
@@ -246,7 +241,7 @@ class AssetResponseDto {
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, permissions=$permissions, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -306,7 +301,6 @@ class AssetResponseDto {
}
json[r'ownerId'] = this.ownerId;
json[r'people'] = this.people;
json[r'permissions'] = this.permissions;
if (this.resized != null) {
json[r'resized'] = this.resized;
} else {
@@ -367,7 +361,6 @@ class AssetResponseDto {
owner: UserResponseDto.fromJson(json[r'owner']),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
people: PersonResponseDto.listFromJson(json[r'people']),
permissions: SharingPermission.listFromJson(json[r'permissions']),
resized: mapValueOfType<bool>(json, r'resized'),
stack: AssetStackResponseDto.fromJson(json[r'stack']),
tags: TagResponseDto.listFromJson(json[r'tags']),
@@ -440,7 +433,6 @@ class AssetResponseDto {
'originalFileName',
'originalPath',
'ownerId',
'permissions',
'thumbhash',
'type',
'updatedAt',
+3 -6
View File
@@ -42,7 +42,6 @@ class JobName {
static const databaseBackup = JobName._(r'DatabaseBackup');
static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll');
static const facialRecognition = JobName._(r'FacialRecognition');
static const facialRecognitionMerge = JobName._(r'FacialRecognitionMerge');
static const fileDelete = JobName._(r'FileDelete');
static const fileMigrationQueueAll = JobName._(r'FileMigrationQueueAll');
static const libraryDeleteCheck = JobName._(r'LibraryDeleteCheck');
@@ -78,7 +77,7 @@ class JobName {
static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr');
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
static const workflowAssetTrigger = JobName._(r'WorkflowAssetTrigger');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
@@ -101,7 +100,6 @@ class JobName {
databaseBackup,
facialRecognitionQueueAll,
facialRecognition,
facialRecognitionMerge,
fileDelete,
fileMigrationQueueAll,
libraryDeleteCheck,
@@ -137,7 +135,7 @@ class JobName {
versionCheck,
ocrQueueAll,
ocr,
workflowAssetCreate,
workflowAssetTrigger,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -195,7 +193,6 @@ class JobNameTypeTransformer {
case r'DatabaseBackup': return JobName.databaseBackup;
case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll;
case r'FacialRecognition': return JobName.facialRecognition;
case r'FacialRecognitionMerge': return JobName.facialRecognitionMerge;
case r'FileDelete': return JobName.fileDelete;
case r'FileMigrationQueueAll': return JobName.fileMigrationQueueAll;
case r'LibraryDeleteCheck': return JobName.libraryDeleteCheck;
@@ -231,7 +228,7 @@ class JobNameTypeTransformer {
case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr;
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
case r'WorkflowAssetTrigger': return JobName.workflowAssetTrigger;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
-3
View File
@@ -29,7 +29,6 @@ class ManualJobName {
static const memoryCleanup = ManualJobName._(r'memory-cleanup');
static const memoryCreate = ManualJobName._(r'memory-create');
static const backupDatabase = ManualJobName._(r'backup-database');
static const personGroupMerge = ManualJobName._(r'person-group-merge');
/// List of all possible values in this [enum][ManualJobName].
static const values = <ManualJobName>[
@@ -39,7 +38,6 @@ class ManualJobName {
memoryCleanup,
memoryCreate,
backupDatabase,
personGroupMerge,
];
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
@@ -84,7 +82,6 @@ class ManualJobNameTypeTransformer {
case r'memory-cleanup': return ManualJobName.memoryCleanup;
case r'memory-create': return ManualJobName.memoryCreate;
case r'backup-database': return ManualJobName.backupDatabase;
case r'person-group-merge': return ManualJobName.personGroupMerge;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
@@ -10,17 +10,17 @@
part of openapi.api;
class MergeFaceClusterDto {
/// Returns a new [MergeFaceClusterDto] instance.
MergeFaceClusterDto({
class MergePersonDto {
/// Returns a new [MergePersonDto] instance.
MergePersonDto({
this.ids = const [],
});
/// Face cluster IDs to merge
/// Person IDs to merge
List<String> ids;
@override
bool operator ==(Object other) => identical(this, other) || other is MergeFaceClusterDto &&
bool operator ==(Object other) => identical(this, other) || other is MergePersonDto &&
_deepEquality.equals(other.ids, ids);
@override
@@ -29,7 +29,7 @@ class MergeFaceClusterDto {
(ids.hashCode);
@override
String toString() => 'MergeFaceClusterDto[ids=$ids]';
String toString() => 'MergePersonDto[ids=$ids]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -37,15 +37,15 @@ class MergeFaceClusterDto {
return json;
}
/// Returns a new [MergeFaceClusterDto] instance and imports its values from
/// Returns a new [MergePersonDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MergeFaceClusterDto? fromJson(dynamic value) {
upgradeDto(value, "MergeFaceClusterDto");
static MergePersonDto? fromJson(dynamic value) {
upgradeDto(value, "MergePersonDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MergeFaceClusterDto(
return MergePersonDto(
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
@@ -54,11 +54,11 @@ class MergeFaceClusterDto {
return null;
}
static List<MergeFaceClusterDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MergeFaceClusterDto>[];
static List<MergePersonDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MergePersonDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MergeFaceClusterDto.fromJson(row);
final value = MergePersonDto.fromJson(row);
if (value != null) {
result.add(value);
}
@@ -67,12 +67,12 @@ class MergeFaceClusterDto {
return result.toList(growable: growable);
}
static Map<String, MergeFaceClusterDto> mapFromJson(dynamic json) {
final map = <String, MergeFaceClusterDto>{};
static Map<String, MergePersonDto> mapFromJson(dynamic json) {
final map = <String, MergePersonDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MergeFaceClusterDto.fromJson(entry.value);
final value = MergePersonDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
@@ -81,14 +81,14 @@ class MergeFaceClusterDto {
return map;
}
// maps a json object with a list of MergeFaceClusterDto-objects as value to a dart map
static Map<String, List<MergeFaceClusterDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MergeFaceClusterDto>>{};
// maps a json object with a list of MergePersonDto-objects as value to a dart map
static Map<String, List<MergePersonDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MergePersonDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MergeFaceClusterDto.listFromJson(entry.value, growable: growable,);
map[entry.key] = MergePersonDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
+1 -14
View File
@@ -15,7 +15,6 @@ class PersonResponseDto {
PersonResponseDto({
required this.birthDate,
this.color,
required this.faceClusterId,
required this.id,
this.isFavorite,
required this.isHidden,
@@ -36,9 +35,6 @@ class PersonResponseDto {
///
String? color;
/// Face cluster ID
String? faceClusterId;
/// Person ID
String id;
@@ -73,7 +69,6 @@ class PersonResponseDto {
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.birthDate == birthDate &&
other.color == color &&
other.faceClusterId == faceClusterId &&
other.id == id &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
@@ -86,7 +81,6 @@ class PersonResponseDto {
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(color == null ? 0 : color!.hashCode) +
(faceClusterId == null ? 0 : faceClusterId!.hashCode) +
(id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden.hashCode) +
@@ -95,7 +89,7 @@ class PersonResponseDto {
(updatedAt == null ? 0 : updatedAt!.hashCode);
@override
String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, faceClusterId=$faceClusterId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -108,11 +102,6 @@ class PersonResponseDto {
json[r'color'] = this.color;
} else {
// json[r'color'] = null;
}
if (this.faceClusterId != null) {
json[r'faceClusterId'] = this.faceClusterId;
} else {
// json[r'faceClusterId'] = null;
}
json[r'id'] = this.id;
if (this.isFavorite != null) {
@@ -142,7 +131,6 @@ class PersonResponseDto {
return PersonResponseDto(
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
faceClusterId: mapValueOfType<String>(json, r'faceClusterId'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
@@ -197,7 +185,6 @@ class PersonResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'birthDate',
'faceClusterId',
'id',
'isHidden',
'name',
+14 -3
View File
@@ -18,6 +18,7 @@ class PluginTemplateResponseDto {
this.steps = const [],
required this.title,
required this.trigger,
this.uiHints = const [],
});
/// Template description
@@ -34,13 +35,17 @@ class PluginTemplateResponseDto {
WorkflowTrigger trigger;
/// Ui hints, for example \"smart-album\"
List<String> uiHints;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
other.description == description &&
other.key == key &&
_deepEquality.equals(other.steps, steps) &&
other.title == title &&
other.trigger == trigger;
other.trigger == trigger &&
_deepEquality.equals(other.uiHints, uiHints);
@override
int get hashCode =>
@@ -49,10 +54,11 @@ class PluginTemplateResponseDto {
(key.hashCode) +
(steps.hashCode) +
(title.hashCode) +
(trigger.hashCode);
(trigger.hashCode) +
(uiHints.hashCode);
@override
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger, uiHints=$uiHints]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -61,6 +67,7 @@ class PluginTemplateResponseDto {
json[r'steps'] = this.steps;
json[r'title'] = this.title;
json[r'trigger'] = this.trigger;
json[r'uiHints'] = this.uiHints;
return json;
}
@@ -78,6 +85,9 @@ class PluginTemplateResponseDto {
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
title: mapValueOfType<String>(json, r'title')!,
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
uiHints: json[r'uiHints'] is Iterable
? (json[r'uiHints'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
@@ -130,6 +140,7 @@ class PluginTemplateResponseDto {
'steps',
'title',
'trigger',
'uiHints',
};
}
-107
View File
@@ -1,107 +0,0 @@
//
// 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 SharingOptionsResponseDto {
/// Returns a new [SharingOptionsResponseDto] instance.
SharingOptionsResponseDto({
required this.inTimeline,
this.permissions = const [],
});
bool inTimeline;
List<SharingPermission> permissions;
@override
bool operator ==(Object other) => identical(this, other) || other is SharingOptionsResponseDto &&
other.inTimeline == inTimeline &&
_deepEquality.equals(other.permissions, permissions);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(inTimeline.hashCode) +
(permissions.hashCode);
@override
String toString() => 'SharingOptionsResponseDto[inTimeline=$inTimeline, permissions=$permissions]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'inTimeline'] = this.inTimeline;
json[r'permissions'] = this.permissions;
return json;
}
/// Returns a new [SharingOptionsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SharingOptionsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "SharingOptionsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SharingOptionsResponseDto(
inTimeline: mapValueOfType<bool>(json, r'inTimeline')!,
permissions: SharingPermission.listFromJson(json[r'permissions']),
);
}
return null;
}
static List<SharingOptionsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharingOptionsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SharingOptionsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SharingOptionsResponseDto> mapFromJson(dynamic json) {
final map = <String, SharingOptionsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SharingOptionsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SharingOptionsResponseDto-objects as value to a dart map
static Map<String, List<SharingOptionsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SharingOptionsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SharingOptionsResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'inTimeline',
'permissions',
};
}
-112
View File
@@ -1,112 +0,0 @@
//
// 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;
/// Sharing permission schema
class SharingPermission {
/// Instantiate a new enum with the provided [value].
const SharingPermission._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const all = SharingPermission._(r'all');
static const assetPeriodRead = SharingPermission._(r'asset.read');
static const assetPeriodUpdate = SharingPermission._(r'asset.update');
static const assetPeriodEdit = SharingPermission._(r'asset.edit');
static const assetPeriodDelete = SharingPermission._(r'asset.delete');
static const assetPeriodShare = SharingPermission._(r'asset.share');
static const exifPeriodRead = SharingPermission._(r'exif.read');
static const personPeriodRead = SharingPermission._(r'person.read');
static const personPeriodUpdate = SharingPermission._(r'person.update');
static const personPeriodMerge = SharingPermission._(r'person.merge');
static const personPeriodDelete = SharingPermission._(r'person.delete');
/// List of all possible values in this [enum][SharingPermission].
static const values = <SharingPermission>[
all,
assetPeriodRead,
assetPeriodUpdate,
assetPeriodEdit,
assetPeriodDelete,
assetPeriodShare,
exifPeriodRead,
personPeriodRead,
personPeriodUpdate,
personPeriodMerge,
personPeriodDelete,
];
static SharingPermission? fromJson(dynamic value) => SharingPermissionTypeTransformer().decode(value);
static List<SharingPermission> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharingPermission>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SharingPermission.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [SharingPermission] to String,
/// and [decode] dynamic data back to [SharingPermission].
class SharingPermissionTypeTransformer {
factory SharingPermissionTypeTransformer() => _instance ??= const SharingPermissionTypeTransformer._();
const SharingPermissionTypeTransformer._();
String encode(SharingPermission data) => data.value;
/// Decodes a [dynamic value][data] to a SharingPermission.
///
/// 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.
SharingPermission? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'all': return SharingPermission.all;
case r'asset.read': return SharingPermission.assetPeriodRead;
case r'asset.update': return SharingPermission.assetPeriodUpdate;
case r'asset.edit': return SharingPermission.assetPeriodEdit;
case r'asset.delete': return SharingPermission.assetPeriodDelete;
case r'asset.share': return SharingPermission.assetPeriodShare;
case r'exif.read': return SharingPermission.exifPeriodRead;
case r'person.read': return SharingPermission.personPeriodRead;
case r'person.update': return SharingPermission.personPeriodUpdate;
case r'person.merge': return SharingPermission.personPeriodMerge;
case r'person.delete': return SharingPermission.personPeriodDelete;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [SharingPermissionTypeTransformer] instance.
static SharingPermissionTypeTransformer? _instance;
}
+14 -14
View File
@@ -19,11 +19,11 @@ class SyncAssetFaceV2 {
required this.boundingBoxY1,
required this.boundingBoxY2,
required this.deletedAt,
required this.faceClusterId,
required this.id,
required this.imageHeight,
required this.imageWidth,
required this.isVisible,
required this.personId,
required this.sourceType,
});
@@ -57,9 +57,6 @@ class SyncAssetFaceV2 {
/// Face deleted at
DateTime? deletedAt;
/// Person ID
String? faceClusterId;
/// Asset face ID
String id;
@@ -78,6 +75,9 @@ class SyncAssetFaceV2 {
/// Is the face visible in the asset
bool isVisible;
/// Person ID
String? personId;
/// Source type
String sourceType;
@@ -89,11 +89,11 @@ class SyncAssetFaceV2 {
other.boundingBoxY1 == boundingBoxY1 &&
other.boundingBoxY2 == boundingBoxY2 &&
other.deletedAt == deletedAt &&
other.faceClusterId == faceClusterId &&
other.id == id &&
other.imageHeight == imageHeight &&
other.imageWidth == imageWidth &&
other.isVisible == isVisible &&
other.personId == personId &&
other.sourceType == sourceType;
@override
@@ -105,15 +105,15 @@ class SyncAssetFaceV2 {
(boundingBoxY1.hashCode) +
(boundingBoxY2.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(faceClusterId == null ? 0 : faceClusterId!.hashCode) +
(id.hashCode) +
(imageHeight.hashCode) +
(imageWidth.hashCode) +
(isVisible.hashCode) +
(personId == null ? 0 : personId!.hashCode) +
(sourceType.hashCode);
@override
String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, faceClusterId=$faceClusterId, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, sourceType=$sourceType]';
String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, personId=$personId, sourceType=$sourceType]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -128,16 +128,16 @@ class SyncAssetFaceV2 {
: this.deletedAt!.toUtc().toIso8601String();
} else {
// json[r'deletedAt'] = null;
}
if (this.faceClusterId != null) {
json[r'faceClusterId'] = this.faceClusterId;
} else {
// json[r'faceClusterId'] = null;
}
json[r'id'] = this.id;
json[r'imageHeight'] = this.imageHeight;
json[r'imageWidth'] = this.imageWidth;
json[r'isVisible'] = this.isVisible;
if (this.personId != null) {
json[r'personId'] = this.personId;
} else {
// json[r'personId'] = null;
}
json[r'sourceType'] = this.sourceType;
return json;
}
@@ -157,11 +157,11 @@ class SyncAssetFaceV2 {
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
faceClusterId: mapValueOfType<String>(json, r'faceClusterId'),
id: mapValueOfType<String>(json, r'id')!,
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
personId: mapValueOfType<String>(json, r'personId'),
sourceType: mapValueOfType<String>(json, r'sourceType')!,
);
}
@@ -216,11 +216,11 @@ class SyncAssetFaceV2 {
'boundingBoxY1',
'boundingBoxY2',
'deletedAt',
'faceClusterId',
'id',
'imageHeight',
'imageWidth',
'isVisible',
'personId',
'sourceType',
};
}
-107
View File
@@ -1,107 +0,0 @@
//
// 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 UpdateSharingOptionsDto {
/// Returns a new [UpdateSharingOptionsDto] instance.
UpdateSharingOptionsDto({
required this.inTimeline,
this.permissions = const [],
});
bool inTimeline;
List<SharingPermission> permissions;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateSharingOptionsDto &&
other.inTimeline == inTimeline &&
_deepEquality.equals(other.permissions, permissions);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(inTimeline.hashCode) +
(permissions.hashCode);
@override
String toString() => 'UpdateSharingOptionsDto[inTimeline=$inTimeline, permissions=$permissions]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'inTimeline'] = this.inTimeline;
json[r'permissions'] = this.permissions;
return json;
}
/// Returns a new [UpdateSharingOptionsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UpdateSharingOptionsDto? fromJson(dynamic value) {
upgradeDto(value, "UpdateSharingOptionsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return UpdateSharingOptionsDto(
inTimeline: mapValueOfType<bool>(json, r'inTimeline')!,
permissions: SharingPermission.listFromJson(json[r'permissions']),
);
}
return null;
}
static List<UpdateSharingOptionsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateSharingOptionsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateSharingOptionsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UpdateSharingOptionsDto> mapFromJson(dynamic json) {
final map = <String, UpdateSharingOptionsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateSharingOptionsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UpdateSharingOptionsDto-objects as value to a dart map
static Map<String, List<UpdateSharingOptionsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateSharingOptionsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UpdateSharingOptionsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'inTimeline',
'permissions',
};
}
+40 -195
View File
@@ -1627,6 +1627,17 @@
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "isOwned",
"required": false,
@@ -1644,6 +1655,15 @@
"schema": {
"type": "boolean"
}
},
{
"name": "name",
"required": false,
"in": "query",
"description": "Album name (exact match)",
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -2277,121 +2297,6 @@
"x-immich-permission": "album.read"
}
},
"/albums/{id}/user/self": {
"get": {
"description": "Get the own sharing permissions in a specific album.",
"operationId": "getOwnAlbumUser",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharingOptionsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get own sharing permissions",
"tags": [
"Albums"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Stable"
}
],
"x-immich-permission": "albumAsset.create",
"x-immich-state": "Stable"
},
"put": {
"description": "Change the own sharing permissions in a specific album.",
"operationId": "updateOwnAlbumUser",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSharingOptionsDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Update own sharing permissions",
"tags": [
"Albums"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Stable"
}
],
"x-immich-permission": "albumAsset.create",
"x-immich-state": "Stable"
}
},
"/albums/{id}/user/{userId}": {
"delete": {
"description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.",
@@ -8460,7 +8365,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MergeFaceClusterDto"
"$ref": "#/components/schemas/MergePersonDto"
}
}
},
@@ -17101,12 +17006,6 @@
},
"type": "array"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
},
"resized": {
"description": "Is resized",
"type": "boolean",
@@ -17178,7 +17077,6 @@
"originalFileName",
"originalPath",
"ownerId",
"permissions",
"thumbhash",
"type",
"updatedAt",
@@ -18238,7 +18136,6 @@
"DatabaseBackup",
"FacialRecognitionQueueAll",
"FacialRecognition",
"FacialRecognitionMerge",
"FileDelete",
"FileMigrationQueueAll",
"LibraryDeleteCheck",
@@ -18274,7 +18171,7 @@
"VersionCheck",
"OcrQueueAll",
"Ocr",
"WorkflowAssetCreate"
"WorkflowAssetTrigger"
],
"type": "string"
},
@@ -18648,8 +18545,7 @@
"user-cleanup",
"memory-cleanup",
"memory-create",
"backup-database",
"person-group-merge"
"backup-database"
],
"type": "string"
},
@@ -18975,10 +18871,10 @@
},
"type": "object"
},
"MergeFaceClusterDto": {
"MergePersonDto": {
"properties": {
"ids": {
"description": "Face cluster IDs to merge",
"description": "Person IDs to merge",
"items": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -20003,11 +19899,6 @@
],
"x-immich-state": "Stable"
},
"faceClusterId": {
"description": "Face cluster ID",
"nullable": true,
"type": "string"
},
"id": {
"description": "Person ID",
"type": "string"
@@ -20058,7 +19949,6 @@
},
"required": [
"birthDate",
"faceClusterId",
"id",
"isHidden",
"name",
@@ -20329,6 +20219,13 @@
"trigger": {
"$ref": "#/components/schemas/WorkflowTrigger",
"description": "Workflow trigger"
},
"uiHints": {
"description": "Ui hints, for example \"smart-album\"",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
@@ -20336,7 +20233,8 @@
"key",
"steps",
"title",
"trigger"
"trigger",
"uiHints"
],
"type": "object"
},
@@ -22036,41 +21934,6 @@
},
"type": "object"
},
"SharingOptionsResponseDto": {
"properties": {
"inTimeline": {
"type": "boolean"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
}
},
"required": [
"inTimeline",
"permissions"
],
"type": "object"
},
"SharingPermission": {
"description": "Sharing permission schema",
"enum": [
"all",
"asset.read",
"asset.update",
"asset.edit",
"asset.delete",
"asset.share",
"exif.read",
"person.read",
"person.update",
"person.merge",
"person.delete"
],
"type": "string"
},
"SignUpDto": {
"properties": {
"email": {
@@ -23167,11 +23030,6 @@
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"faceClusterId": {
"description": "Person ID",
"nullable": true,
"type": "string"
},
"id": {
"description": "Asset face ID",
"type": "string"
@@ -23192,6 +23050,11 @@
"description": "Is the face visible in the asset",
"type": "boolean"
},
"personId": {
"description": "Person ID",
"nullable": true,
"type": "string"
},
"sourceType": {
"description": "Source type",
"type": "string"
@@ -23204,11 +23067,11 @@
"boundingBoxY1",
"boundingBoxY2",
"deletedAt",
"faceClusterId",
"id",
"imageHeight",
"imageWidth",
"isVisible",
"personId",
"sourceType"
],
"type": "object"
@@ -25698,24 +25561,6 @@
},
"type": "object"
},
"UpdateSharingOptionsDto": {
"properties": {
"inTimeline": {
"type": "boolean"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
}
},
"required": [
"inTimeline",
"permissions"
],
"type": "object"
},
"UsageByUserDto": {
"properties": {
"photos": {
+9
View File
@@ -1,3 +1,12 @@
@@ -13,7 +13,7 @@
class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,});
- final String basePath;
+ String basePath;
final Authentication? authentication;
var _client = Client();
@@ -143,19 +143,19 @@
);
}
+15 -14
View File
@@ -20,19 +20,20 @@
"caseSensitive": false
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
},
{
"method": "immich-plugin-core#assetArchive",
"config": {
"inverse": false
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
}
]
],
"uiHints": ["SmartAlbum"]
}
],
"methods": [
@@ -65,7 +66,7 @@
},
"required": ["pattern"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "filterFileType",
@@ -85,7 +86,7 @@
},
"required": ["fileTypes"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "filterPerson",
@@ -99,7 +100,7 @@
"array": true,
"title": "Person IDs",
"description": "List of person to match",
"uiHint": "personI"
"uiHint": "personId"
},
"matchAny": {
"type": "boolean",
@@ -110,7 +111,7 @@
},
"required": ["personIds"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "assetArchive",
@@ -187,7 +188,7 @@
"title": "Album IDs",
"array": true,
"description": "Target album IDs",
"uiHint": "albumId"
"uiHint": "AlbumId"
}
},
"required": ["albumIds"]
@@ -272,14 +273,14 @@
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": "albumId"
"uiHint": "AlbumId"
},
"albumIds": {
"type": "string",
"title": "Album IDs",
"description": "Target album IDs",
"array": true,
"uiHint": "albumId"
"uiHint": "AlbumId"
}
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ export const assetTrash = () => {
changes: {
asset: config.inverse
? { deletedAt: null, status: AssetStatus.Active }
: { deletedAt: new Date(), status: AssetStatus.Trashed },
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
},
}));
};
+1
View File
@@ -27,6 +27,7 @@
"packageManager": "pnpm@10.33.4",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
"@types/node": "^24.12.4",
"esbuild": "^0.28.0",
"tsc-alias": "^1.8.16",
+8 -1
View File
@@ -1,3 +1,6 @@
import { type BulkIdResponseDto, type BulkIdsDto } from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' {
interface user {
albumAddAssets(ptr: PTR): I64;
@@ -45,7 +48,11 @@ type AlbumsToAssets = {
export const hostFunctions = (authToken: string) => ({
albumAddAssets: (albumId: string, assetIds: string[]) =>
call('albumAddAssets', authToken, [albumId, { ids: assetIds }]),
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
'albumAddAssets',
authToken,
[albumId, { ids: assetIds }],
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
});
+9 -9
View File
@@ -68,19 +68,19 @@ export type AssetV1 = {
ownerId: string;
type: AssetType;
originalPath: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
fileCreatedAt: string;
fileModifiedAt: string;
isFavorite: boolean;
checksum: Buffer; // sha1 checksum
livePhotoVideoId: string | null;
updatedAt: Date;
createdAt: Date;
updatedAt: string;
createdAt: string;
originalFileName: string;
isOffline: boolean;
libraryId: string | null;
isExternal: boolean;
deletedAt: Date | null;
localDateTime: Date;
deletedAt: string | null;
localDateTime: string;
stackId: string | null;
duplicateId: string | null;
status: AssetStatus;
@@ -93,8 +93,8 @@ export type AssetV1 = {
exifImageHeight: number | null;
fileSizeInByte: number | null;
orientation: string | null;
dateTimeOriginal: Date | null;
modifyDate: Date | null;
dateTimeOriginal: string | null;
modifyDate: string | null;
lensModel: string | null;
fNumber: number | null;
focalLength: number | null;
@@ -116,7 +116,7 @@ export type AssetV1 = {
autoStackId: string | null;
rating: number | null;
tags: string[] | null;
updatedAt: Date | null;
updatedAt: string | null;
} | null;
};
};
+17 -63
View File
@@ -555,14 +555,6 @@ export type MapMarkerResponseDto = {
/** State/Province name */
state: string | null;
};
export type SharingOptionsResponseDto = {
inTimeline: boolean;
permissions: SharingPermission[];
};
export type UpdateSharingOptionsDto = {
inTimeline: boolean;
permissions: SharingPermission[];
};
export type UpdateAlbumUserDto = {
role: AlbumUserRole;
};
@@ -800,8 +792,6 @@ export type PersonResponseDto = {
birthDate: string | null;
/** Person color (hex) */
color?: string;
/** Face cluster ID */
faceClusterId: string | null;
/** Person ID */
id: string;
/** Is favorite */
@@ -885,7 +875,6 @@ export type AssetResponseDto = {
/** Owner user ID */
ownerId: string;
people?: PersonResponseDto[];
permissions: SharingPermission[];
/** Is resized */
resized?: boolean;
stack?: (AssetStackResponseDto) | null;
@@ -1471,8 +1460,8 @@ export type PersonUpdateDto = {
/** Person name */
name?: string;
};
export type MergeFaceClusterDto = {
/** Face cluster IDs to merge */
export type MergePersonDto = {
/** Person IDs to merge */
ids: string[];
};
export type AssetFaceUpdateItem = {
@@ -1546,6 +1535,8 @@ export type PluginTemplateResponseDto = {
title: string;
/** Workflow trigger */
trigger: WorkflowTrigger;
/** Ui hints, for example "smart-album" */
uiHints: string[];
};
export type QueueResponseDto = {
/** Whether the queue is paused */
@@ -2955,8 +2946,6 @@ export type SyncAssetFaceV2 = {
boundingBoxY2: number;
/** Face deleted at */
deletedAt: string | null;
/** Person ID */
faceClusterId: string | null;
/** Asset face ID */
id: string;
/** Image height */
@@ -2965,6 +2954,8 @@ export type SyncAssetFaceV2 = {
imageWidth: number;
/** Is the face visible in the asset */
isVisible: boolean;
/** Person ID */
personId: string | null;
/** Source type */
sourceType: string;
};
@@ -3608,18 +3599,22 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/**
* List all albums
*/
export function getAllAlbums({ assetId, isOwned, isShared }: {
export function getAllAlbums({ assetId, id, isOwned, isShared, name }: {
assetId?: string;
id?: string;
isOwned?: boolean;
isShared?: boolean;
name?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({
assetId,
id,
isOwned,
isShared
isShared,
name
}))}`, {
...opts
}));
@@ -3760,32 +3755,6 @@ export function getAlbumMapMarkers({ id, key, slug }: {
...opts
}));
}
/**
* Get own sharing permissions
*/
export function getOwnAlbumUser({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SharingOptionsResponseDto;
}>(`/albums/${encodeURIComponent(id)}/user/self`, {
...opts
}));
}
/**
* Update own sharing permissions
*/
export function updateOwnAlbumUser({ id, updateSharingOptionsDto }: {
id: string;
updateSharingOptionsDto: UpdateSharingOptionsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/albums/${encodeURIComponent(id)}/user/self`, oazapfts.json({
...opts,
method: "PUT",
body: updateSharingOptionsDto
})));
}
/**
* Remove user from album
*/
@@ -5190,9 +5159,9 @@ export function updatePerson({ id, personUpdateDto }: {
/**
* Merge people
*/
export function mergePerson({ id, mergeFaceClusterDto }: {
export function mergePerson({ id, mergePersonDto }: {
id: string;
mergeFaceClusterDto: MergeFaceClusterDto;
mergePersonDto: MergePersonDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@@ -5200,7 +5169,7 @@ export function mergePerson({ id, mergeFaceClusterDto }: {
}>(`/people/${encodeURIComponent(id)}/merge`, oazapfts.json({
...opts,
method: "POST",
body: mergeFaceClusterDto
body: mergePersonDto
})));
}
/**
@@ -6858,19 +6827,6 @@ export enum BulkIdErrorReason {
Unknown = "unknown",
Validation = "validation"
}
export enum SharingPermission {
All = "all",
AssetRead = "asset.read",
AssetUpdate = "asset.update",
AssetEdit = "asset.edit",
AssetDelete = "asset.delete",
AssetShare = "asset.share",
ExifRead = "exif.read",
PersonRead = "person.read",
PersonUpdate = "person.update",
PersonMerge = "person.merge",
PersonDelete = "person.delete"
}
export enum Permission {
All = "all",
ActivityCreate = "activity.create",
@@ -7078,8 +7034,7 @@ export enum ManualJobName {
UserCleanup = "user-cleanup",
MemoryCleanup = "memory-cleanup",
MemoryCreate = "memory-create",
BackupDatabase = "backup-database",
PersonGroupMerge = "person-group-merge"
BackupDatabase = "backup-database"
}
export enum QueueName {
ThumbnailGeneration = "thumbnailGeneration",
@@ -7156,7 +7111,6 @@ export enum JobName {
DatabaseBackup = "DatabaseBackup",
FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
FacialRecognition = "FacialRecognition",
FacialRecognitionMerge = "FacialRecognitionMerge",
FileDelete = "FileDelete",
FileMigrationQueueAll = "FileMigrationQueueAll",
LibraryDeleteCheck = "LibraryDeleteCheck",
@@ -7192,7 +7146,7 @@ export enum JobName {
VersionCheck = "VersionCheck",
OcrQueueAll = "OcrQueueAll",
Ocr = "Ocr",
WorkflowAssetCreate = "WorkflowAssetCreate"
WorkflowAssetTrigger = "WorkflowAssetTrigger"
}
export enum SearchSuggestionType {
Country = "country",
+11 -6
View File
@@ -10,7 +10,7 @@ overrides:
sharp: ^0.34.5
webpackbar: ^7.0.0
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
@@ -332,6 +332,9 @@ importers:
'@extism/js-pdk':
specifier: ^1.1.1
version: 1.1.1
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
'@types/node':
specifier: ^24.12.4
version: 24.12.4
@@ -389,7 +392,7 @@ importers:
version: 6.1.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/swagger':
specifier: ^11.4.2
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
'@nestjs/websockets':
specifier: ^11.0.4
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-socket.io@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -536,7 +539,7 @@ importers:
version: 8.0.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
nestjs-zod:
specifier: ^5.3.0
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6)
nodemailer:
specifier: ^8.0.0
version: 8.0.7
@@ -3795,6 +3798,7 @@ packages:
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
typescript: '*'
peerDependenciesMeta:
'@fastify/static':
optional: true
@@ -16570,7 +16574,7 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)':
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16581,6 +16585,7 @@ snapshots:
path-to-regexp: 8.4.2
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6
typescript: 6.0.3
'@nestjs/testing@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)':
dependencies:
@@ -23229,14 +23234,14 @@ snapshots:
'@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1)
tslib: 2.8.1
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6):
dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1
rxjs: 7.8.2
zod: 4.3.6
optionalDependencies:
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
next-tick@1.1.0: {}
+3
View File
@@ -60,6 +60,9 @@ packageExtensions:
dependencies:
node-addon-api: '*'
node-gyp: '*'
'@nestjs/swagger':
peerDependencies:
typescript: '*'
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true
+4 -2
View File
@@ -13,14 +13,15 @@ FROM builder AS server
WORKDIR /usr/src/app
COPY ./server ./server/
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
FROM builder AS web
@@ -66,6 +67,7 @@ ENV MISE_DISABLE_TOOLS=flutter
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-core ./packages/plugin-core/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
+1
View File
@@ -68,6 +68,7 @@ run = [
[tasks.ci-medium]
run = [
{ task = ":install" },
{ task = "//:plugins" },
{ task = "//packages/plugin-core:build" },
{ task = ":test-medium --run" },
]
@@ -11,7 +11,6 @@ import {
GetAlbumsDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
UpdateSharingPermissionsDto as UpdateSharingOptionsDto,
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -166,33 +165,6 @@ export class AlbumController {
return this.service.addUsers(auth, id, dto);
}
@Get(':id/user/self')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@Endpoint({
summary: 'Get own sharing permissions',
description: 'Get the own sharing permissions in a specific album.',
history: new HistoryBuilder().added('v3').stable('v3'),
})
getOwnAlbumUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.getSelf(auth, id);
}
@Put(':id/user/self')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Update own sharing permissions',
description: 'Change the own sharing permissions in a specific album.',
history: new HistoryBuilder().added('v3').stable('v3'),
})
updateOwnAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateSharingOptionsDto,
): Promise<void> {
return this.service.updateSelf(auth, id, dto);
}
@Put(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
+2 -2
View File
@@ -19,7 +19,7 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceUpdateDto,
MergeFaceClusterDto,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
@@ -182,7 +182,7 @@ export class PersonController {
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: MergeFaceClusterDto,
@Body() dto: MergePersonDto,
): Promise<BulkIdResponseDto[]> {
return this.service.mergePerson(auth, id, dto);
}
+1 -4
View File
@@ -9,7 +9,6 @@ import {
MemoryType,
Permission,
SharedLinkType,
SharingPermission,
SourceType,
UserAvatarColor,
UserStatus,
@@ -210,7 +209,6 @@ export type Partner = {
updatedAt: Date;
updateId: string;
inTimeline: boolean;
permissions: SharingPermission[];
};
export type Place = {
@@ -254,7 +252,6 @@ export type Person = {
faceAssetId: string | null;
isHidden: boolean;
thumbnailPath: string;
faceClusterId: string | null;
};
export type AssetFace = {
@@ -267,7 +264,7 @@ export type AssetFace = {
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
faceClusterId: string | null;
personId: string | null;
sourceType: SourceType;
person?: ShallowDehydrateObject<Person> | null;
updatedAt: Date;
+4 -12
View File
@@ -3,8 +3,8 @@ import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { mapUser, UserResponseSchema } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema, SharingPermissionSchema } from 'src/enum';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date';
import { stringToBool } from 'src/validation';
@@ -63,16 +63,10 @@ const UpdateAlbumSchema = z
})
.meta({ id: 'UpdateAlbumDto' });
const UpdateSharingOptionsSchema = z
.object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) })
.meta({ id: 'UpdateSharingOptionsDto' });
const SharingOptionsResponseSchema = z
.object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) })
.meta({ id: 'SharingOptionsResponseDto' });
const GetAlbumsSchema = z
.object({
id: z.uuidv4().optional().describe('Album ID'),
name: z.string().optional().describe('Album name (exact match)'),
isOwned: stringToBool
.optional()
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
@@ -155,8 +149,6 @@ export class UpdateAlbumDto extends createZodDto(UpdateAlbumSchema) {}
export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {}
export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {}
export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {}
export class UpdateSharingPermissionsDto extends createZodDto(UpdateSharingOptionsSchema) {}
export class SharingPermissionsResponseDto extends createZodDto(SharingOptionsResponseSchema) {}
export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {}
class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {}
+1 -16
View File
@@ -15,8 +15,6 @@ import {
AssetVisibility,
AssetVisibilitySchema,
ChecksumAlgorithm,
SharingPermission,
SharingPermissionSchema,
} from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
@@ -47,7 +45,6 @@ const SanitizedAssetResponseSchema = z
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'),
permissions: z.array(SharingPermissionSchema),
})
.meta({ id: 'SanitizedAssetResponseDto' });
@@ -116,7 +113,6 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
.boolean()
.describe('Is edited')
.meta(new HistoryBuilder().added('v2.5.0').beta('v2.5.0').getExtensions()),
permissions: z.array(SharingPermissionSchema),
}).shape,
).meta({ id: 'AssetResponseDto' });
@@ -158,7 +154,6 @@ export type MapAsset = {
width: number | null;
height: number | null;
isEdited: boolean;
permissions?: { permission: SharingPermission }[];
};
export type AssetMapOptions = {
@@ -197,16 +192,8 @@ const mapStack = (entity: { stack?: Stack | null }) => {
export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
const permissions =
options.auth?.user.id === entity.ownerId
? [SharingPermission.All]
: (entity.permissions?.map(({ permission }) => permission) ?? []);
if (
stripMetadata ||
(entity.permissions &&
!(permissions.includes(SharingPermission.All) || permissions.includes(SharingPermission.ExifRead)))
) {
if (stripMetadata) {
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
@@ -218,7 +205,6 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
hasMetadata: false,
width: entity.width,
height: entity.height,
permissions,
};
return sanitizedAssetResponse as AssetResponseDto;
}
@@ -256,6 +242,5 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
width: entity.width,
height: entity.height,
isEdited: entity.isEdited,
permissions,
};
}
+7 -7
View File
@@ -2,6 +2,7 @@ import { Selectable } from 'kysely';
import { createZodDto } from 'nestjs-zod';
import { AssetFace, Person } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SourceTypeSchema } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
@@ -39,11 +40,11 @@ const PeopleUpdateSchema = z
})
.meta({ id: 'PeopleUpdateDto' });
const MergeFaceClusterSchema = z
const MergePersonSchema = z
.object({
ids: z.array(z.uuidv4()).describe('Face cluster IDs to merge'),
ids: z.array(z.uuidv4()).describe('Person IDs to merge'),
})
.meta({ id: 'MergeFaceClusterDto' });
.meta({ id: 'MergePersonDto' });
const PersonSearchSchema = z
.object({
@@ -80,14 +81,13 @@ export const PersonResponseSchema = z
.optional()
.describe('Person color (hex)')
.meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()),
faceClusterId: z.string().nullable().describe('Face cluster ID'),
})
.meta({ id: 'PersonResponseDto' });
export class PersonCreateDto extends createZodDto(PersonCreateSchema) {}
export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {}
export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {}
export class MergeFaceClusterDto extends createZodDto(MergeFaceClusterSchema) {}
export class MergePersonDto extends createZodDto(MergePersonSchema) {}
export class PersonSearchDto extends createZodDto(PersonSearchSchema) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema) {}
@@ -179,7 +179,6 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: asDateString(person.updatedAt),
faceClusterId: person.faceClusterId,
};
}
@@ -208,11 +207,12 @@ function mapFacesWithoutPerson(
export function mapFaces(
face: AssetFace,
auth: AuthDto,
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): AssetFaceResponseDto {
return {
...mapFacesWithoutPerson(face, edits, assetDimensions),
person: face.person ? mapPerson(face.person) : null,
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
};
}
+1
View File
@@ -38,6 +38,7 @@ const PluginManifestTemplateSchema = z
description: z.string().min(1).describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).optional().default([]).describe('Ui hints, for example "smart-album"'),
})
.meta({ id: 'PluginManifestTemplateDto' });
+3
View File
@@ -58,6 +58,7 @@ const PluginTemplateResponseSchema = z
description: z.string().describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).describe('Ui hints, for example "smart-album"'),
})
.meta({ id: 'PluginTemplateResponseDto' });
@@ -91,6 +92,7 @@ export type PluginTemplate = {
config?: Record<string, unknown> | null;
enabled?: boolean;
}>;
uiHints: string[];
};
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
@@ -104,6 +106,7 @@ export const mapTemplate = (plugin: { name: string }, template: PluginTemplate):
config: step.config ?? null,
enabled: step.enabled,
})),
uiHints: template.uiHints ?? [],
};
};
+4 -7
View File
@@ -374,13 +374,10 @@ const SyncAssetFaceV1Schema = z
})
.meta({ id: 'SyncAssetFaceV1' });
const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.omit({ personId: true })
.extend({
deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'),
isVisible: z.boolean().describe('Is the face visible in the asset'),
faceClusterId: z.string().nullable().describe('Person ID'),
})
.meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.extend({
deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'),
isVisible: z.boolean().describe('Is the face visible in the asset'),
}).meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceDeleteV1Schema = z
.object({ assetFaceId: z.string().describe('Asset face ID') })
+1 -25
View File
@@ -306,28 +306,6 @@ export enum Permission {
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export enum SharingPermission {
All = 'all',
AssetRead = 'asset.read',
AssetUpdate = 'asset.update',
AssetEdit = 'asset.edit',
AssetDelete = 'asset.delete',
AssetShare = 'asset.share',
ExifRead = 'exif.read',
PersonRead = 'person.read',
PersonUpdate = 'person.update',
PersonMerge = 'person.merge',
PersonDelete = 'person.delete',
}
export const SharingPermissionSchema = z
.enum(SharingPermission)
.describe('Sharing permission schema')
.meta({ id: 'SharingPermission' });
export enum SharedLinkType {
Album = 'ALBUM',
@@ -426,7 +404,6 @@ export enum ManualJobName {
MemoryCleanup = 'memory-cleanup',
MemoryCreate = 'memory-create',
BackupDatabase = 'backup-database',
PersonGroupMerge = 'person-group-merge',
}
export const ManualJobNameSchema = z.enum(ManualJobName).describe('Manual job name').meta({ id: 'ManualJobName' });
@@ -836,7 +813,6 @@ export enum JobName {
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
FacialRecognition = 'FacialRecognition',
FacialRecognitionMerge = 'FacialRecognitionMerge',
FileDelete = 'FileDelete',
FileMigrationQueueAll = 'FileMigrationQueueAll',
@@ -890,7 +866,7 @@ export enum JobName {
Ocr = 'Ocr',
// Workflow
WorkflowAssetCreate = 'WorkflowAssetCreate',
WorkflowAssetTrigger = 'WorkflowAssetTrigger',
}
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
-34
View File
@@ -149,40 +149,6 @@ where
"albumAssets"."livePhotoVideoId"
] && array[$2]::uuid[]
-- AccessRepository.asset.checkSharedAccess
select
"album_asset"."assetId"
from
"album_asset"
inner join "album_user" on "album_asset"."albumId" = "album_user"."albumId"
and "album_user"."userId" = $1
where
"album_asset"."assetId" in ($2)
and "album_asset"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
(
"album_user"."permissions" @> $3::sharing_permission_enum[]
or $4 = any ("album_user"."permissions")
)
)
union
select
"asset"."id" as "assetId"
from
"partner"
inner join "asset" on "asset"."ownerId" = "partner"."sharedById"
and "asset"."id" in ($5)
where
"partner"."sharedWithId" = $6
and (
"partner"."permissions" @> $7::sharing_permission_enum[]
or $8 = any ("partner"."permissions")
)
-- AccessRepository.authDevice.checkOwnerAccess
select
"session"."id"
+14 -52
View File
@@ -182,25 +182,18 @@ select
from
(
select
(
select
to_json(obj)
from
(
select
"person".*
from
"face_cluster"
inner join "person" on "person"."faceClusterId" = "face_cluster"."id"
where
"face_cluster"."id" = "asset_face"."faceClusterId"
limit
$1
) as obj
) as "person",
"asset_face".*
"asset_face".*,
"person" as "person"
from
"asset_face"
left join lateral (
select
"person".*
from
"person"
where
"asset_face"."personId" = "person"."id"
) as "person" on true
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
@@ -231,7 +224,7 @@ from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."id" = any ($2::uuid[])
"asset"."id" = any ($1::uuid[])
-- AssetRepository.deleteAll
delete from "asset"
@@ -297,44 +290,13 @@ limit
-- AssetRepository.getById
select
"asset".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select distinct
unnest("album_user"."permissions") as "permission"
from
"album_user"
inner join "album_asset" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."userId" = "asset"."ownerId"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $1
)
union
select distinct
unnest("partner"."permissions") as "permission"
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $2
) as agg
) as "permissions"
"asset".*
from
"asset"
where
"asset"."id" = $3::uuid
"asset"."id" = $1::uuid
limit
$4
$2
-- AssetRepository.updateAll
update "asset"
+2 -2
View File
@@ -47,7 +47,7 @@ select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
inner join "person" on "person"."id" = "asset_face"."personId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
@@ -86,7 +86,7 @@ select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
inner join "person" on "person"."id" = "asset_face"."personId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
+33 -225
View File
@@ -3,6 +3,9 @@
-- PersonRepository.reassignFaces
update "asset_face"
set
"personId" = $1
where
"asset_face"."personId" = $2
-- PersonRepository.delete
delete from "person"
@@ -21,64 +24,27 @@ limit
3
-- PersonRepository.getAllForUser
select distinct
on ("person"."faceClusterId") "person".*
select
"person".*
from
"person"
inner join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId"
inner join "asset_face" on "asset_face"."personId" = "person"."id"
inner join "asset" on "asset_face"."assetId" = "asset"."id"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
(
"person"."ownerId" = $1
or (
exists (
select
from
"partner"
where
"partner"."sharedById" = "person"."ownerId"
and "partner"."sharedWithId" = $2
and (
$3 = any ("partner"."permissions")
or "partner"."permissions" @> $4
)
)
or exists (
select
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $5
)
and "album_user"."userId" = "person"."ownerId"
and (
$6 = any ("album_user"."permissions")
or "album_user"."permissions" @> $7
)
)
)
)
"person"."ownerId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "person"."isHidden" = $8
and "person"."isHidden" = $2
group by
"person"."id"
having
(
"person"."name" != $9
or count("asset_face"."assetId") >= $10
"person"."name" != $3
or count("asset_face"."assetId") >= $4
)
order by
"person"."faceClusterId",
"person"."ownerId" = $11 desc,
"person"."isHidden" asc,
"person"."isFavorite" desc,
NULLIF(person.name, '') is null asc,
@@ -86,16 +52,16 @@ order by
NULLIF(person.name, '') asc nulls last,
"person"."createdAt"
limit
$12
$5
offset
$13
$6
-- PersonRepository.getAllWithoutFaces
select
"person".*
from
"person"
left join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId"
left join "asset_face" on "asset_face"."personId" = "person"."id"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
@@ -117,26 +83,15 @@ select
from
"person"
where
"person"."faceClusterId" = "asset_face"."faceClusterId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
"person"."id" = "asset_face"."personId"
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."assetId" = $2
"asset_face"."assetId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $3
and "asset_face"."isVisible" = $2
order by
"asset_face"."boundingBoxX1" asc
@@ -153,30 +108,19 @@ select
from
"person"
where
"person"."faceClusterId" = "asset_face"."faceClusterId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
"person"."id" = "asset_face"."personId"
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."id" = $2
"asset_face"."id" = $1
and "asset_face"."deletedAt" is null
-- PersonRepository.getFaceForFacialRecognitionJob
select
"asset_face"."id",
"asset_face"."faceClusterId",
"asset_face"."personId",
"asset_face"."sourceType",
(
select
@@ -246,7 +190,7 @@ where
-- PersonRepository.reassignFace
update "asset_face"
set
"faceClusterId" = $1
"personId" = $1
where
"asset_face"."id" = $2
@@ -265,10 +209,9 @@ where
"person"."ownerId" = $1
and f_unaccent ("person"."name") %> f_unaccent ($2)
order by
f_unaccent ("person"."name") <->>> f_unaccent ($3),
"person"."ownerId" = $4 desc
f_unaccent ("person"."name") <->>> f_unaccent ($3)
limit
$5
$4
-- PersonRepository.getDistinctNames
select distinct
@@ -291,52 +234,9 @@ from
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
(
"asset"."ownerId" = $1
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $2
and (
$3 = any ("partner"."permissions")
or "partner"."permissions" @> $4
)
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $5
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$6 = any ("album_user"."permissions")
or "album_user"."permissions" @> $7
)
)
)
)
and "asset_face"."deletedAt" is null
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "asset_face"."faceClusterId" = (
select
"person"."faceClusterId"
from
"person"
where
"person"."id" = $8
)
and "asset_face"."personId" = $1
-- PersonRepository.getNumberOfPeople
select
@@ -356,7 +256,7 @@ where
from
"asset_face"
where
"asset_face"."faceClusterId" = "person"."faceClusterId"
"asset_face"."personId" = "person"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and exists (
@@ -369,42 +269,7 @@ where
and "asset"."deletedAt" is null
)
)
and (
"person"."ownerId" = $3
or (
exists (
select
from
"partner"
where
"partner"."sharedById" = "person"."ownerId"
and "partner"."sharedWithId" = $4
and (
$5 = any ("partner"."permissions")
or "partner"."permissions" @> $6
)
)
or exists (
select
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $7
)
and "album_user"."userId" = "person"."ownerId"
and (
$8 = any ("album_user"."permissions")
or "album_user"."permissions" @> $9
)
)
)
)
and "person"."ownerId" = $3
-- PersonRepository.refreshFaces
with
@@ -434,26 +299,14 @@ select
from
"person"
where
"person"."faceClusterId" = "asset_face"."faceClusterId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
"person"."id" = "asset_face"."personId"
) as obj
) as "person"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
where
"person"."id" in ($2)
and "asset_face"."assetId" in ($3)
"asset_face"."assetId" in ($1)
and "asset_face"."personId" in ($2)
and "asset_face"."deletedAt" is null
-- PersonRepository.getRandomFace
@@ -461,52 +314,8 @@ select
"asset_face".*
from
"asset_face"
inner join "person" on "asset_face"."faceClusterId" = "person"."faceClusterId"
and "person"."id" = $1
where
"asset_face"."assetId" in (
select
"asset"."id"
from
"asset"
where
(
"asset"."ownerId" = "person"."ownerId"
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = "person"."ownerId"
and (
$2 = any ("partner"."permissions")
or "partner"."permissions" @> $3
)
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = "person"."ownerId"
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$4 = any ("album_user"."permissions")
or "album_user"."permissions" @> $5
)
)
)
)
)
"asset_face"."personId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
@@ -542,9 +351,8 @@ select
"asset_face"."id"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
and "person"."id" = $1
inner join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."isOffline" = $2
and "asset"."isOffline" = $1
where
"asset_face"."assetId" = $3
"asset_face"."assetId" = $2
and "asset_face"."personId" = $3
+22 -222
View File
@@ -10,52 +10,15 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" desc
limit
$14
$6
offset
$15
$7
-- SearchRepository.searchStatistics
select
@@ -67,45 +30,8 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
-- SearchRepository.searchRandom
@@ -118,50 +44,13 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
random()
limit
$14
$6
-- SearchRepository.searchLargeAssets
select
@@ -174,51 +63,14 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $14
and "asset_exif"."fileSizeInByte" > $6
order by
"asset_exif"."fileSizeInByte" desc
limit
$15
$7
-- SearchRepository.searchSmart
begin
@@ -234,52 +86,15 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
smart_search.embedding <=> $14
smart_search.embedding <=> $6
limit
$15
$7
offset
$16
$8
commit
-- SearchRepository.getEmbedding
@@ -298,30 +113,15 @@ with
"cte" as (
select
"asset_face"."id",
"asset_face"."faceClusterId",
face_search.embedding <=> $1 as "distance",
"asset"."ownerId"
"asset_face"."personId",
face_search.embedding <=> $1 as "distance"
from
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
inner join "face_search" on "face_search"."faceId" = "asset_face"."id"
left join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
left join "person" on "person"."id" = "asset_face"."personId"
where
"asset"."ownerId" in (
select
"user"."id"
from
"user"
where
"user"."trustedGroupId" in (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = any ($2::uuid[])
)
)
"asset"."ownerId" = any ($2::uuid[])
and "asset"."deletedAt" is null
order by
"distance"
+1 -1
View File
@@ -527,7 +527,7 @@ order by
select
"asset_face"."id",
"assetId",
"faceClusterId",
"personId",
"imageWidth",
"imageHeight",
"boundingBoxX1",
-70
View File
@@ -397,73 +397,3 @@ set
where
"user"."deletedAt" is null
and "user"."id" = $2::uuid
-- UserRepository.getInSameTrustedGroup
select
"user"."id"
from
"user"
where
"user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $1
)
-- UserRepository.mergeTrustedGroups
update "user"
set
"trustedGroupId" = "u"."trustedGroupId"
from
"user" as "u"
where
"u"."id" = $1
and "user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $2
and "user"."trustedGroupId" != "u"."trustedGroupId"
)
-- UserRepository.updateTrustedGroups
update "user"
set
"trustedGroupId" = uuid_generate_v4 ()
where
"user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $1
)
and "user"."id" != $2
and "user"."id" not in (
select
"partner"."sharedById" as "userId"
from
"partner"
where
"sharedWithId" = $3
union
select
"album_user"."userId"
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $4
)
)
+1 -74
View File
@@ -2,9 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole, AssetVisibility, SharingPermission } from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { hasPermissions } from 'src/repositories/person.repository';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { asUuid } from 'src/utils/database';
@@ -275,46 +273,6 @@ class AssetAccess {
return allowedIds;
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET, [SharingPermission.All]] })
async checkSharedAccess(userId: string, assetIds: Set<string>, permissions: SharingPermission[]) {
const ids = await this.db
.selectFrom('album_asset')
.select('album_asset.assetId')
.where('album_asset.assetId', 'in', [...assetIds])
.where('album_asset.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.where((eb) =>
eb.or([
eb('album_user.permissions', '@>', sql<SharingPermission[]>`${permissions}::sharing_permission_enum[]`),
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
]),
),
)
.innerJoin('album_user', (join) =>
join.onRef('album_asset.albumId', '=', 'album_user.albumId').on('album_user.userId', '=', userId),
)
.union((eb) =>
eb
.selectFrom('partner')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb('partner.permissions', '@>', sql<SharingPermission[]>`${permissions}::sharing_permission_enum[]`),
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
]),
)
.innerJoin('asset', (join) =>
join.onRef('asset.ownerId', '=', 'partner.sharedById').on('asset.id', 'in', [...assetIds]),
)
.select('asset.id as assetId'),
)
.execute();
return new Set(ids.map(({ assetId }) => assetId));
}
}
class AuthDeviceAccess {
@@ -494,37 +452,6 @@ class PersonAccess {
.execute()
.then((faces) => new Set(faces.map((face) => face.id)));
}
async checkSharedAccess(userId: string, personIds: Set<string>, permissions: SharingPermission[]) {
if (personIds.size === 0) {
return new Set<string>();
}
const ids = await this.db
.selectFrom('person')
.select('person.id')
.where('person.id', 'in', [...personIds])
.where(hasPermissions(userId, permissions))
.execute();
return new Set(ids.map(({ id }) => id));
}
async checkSharedFaceAccess(userId: string, faceIds: Set<string>, permissions: SharingPermission[]) {
if (faceIds.size === 0) {
return new Set<string>();
}
const ids = await this.db
.selectFrom('asset_face')
.select('asset_face.id')
.leftJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId'))
.where('asset_face.id', 'in', [...faceIds])
.where(hasAssetPermissions(userId, permissions))
.execute();
return new Set(ids.map(({ id }) => id));
}
}
class PartnerAccess {
@@ -38,13 +38,4 @@ export class AlbumUserRepository {
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
}
get({ userId, albumId }: AlbumPermissionId) {
return this.db
.selectFrom('album_user')
.select(['permissions', 'inTimeline'])
.where('userId', '=', userId)
.where('albumId', '=', albumId)
.executeTakeFirstOrThrow();
}
}
+6 -1
View File
@@ -209,11 +209,16 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> {
getAll(
ownerId: string,
options: { id?: string; isOwned?: boolean; isShared?: boolean; name?: string } = {},
): Promise<MapAlbumDto[]> {
return this.buildAlbumBaseQuery(ownerId, options)
.selectAll('album')
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.$if(!!options.id, (qb) => qb.where('album.id', '=', options.id!))
.$if(!!options.name, (qb) => qb.where('album.albumName', '=', options.name!))
.orderBy('album.createdAt', 'desc')
.execute();
}
+5 -112
View File
@@ -8,7 +8,6 @@ import {
SelectQueryBuilder,
ShallowDehydrateObject,
sql,
StringReference,
Updateable,
UpdateResult,
} from 'kysely';
@@ -18,15 +17,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFileType,
AssetOrder,
AssetOrderBy,
AssetStatus,
AssetType,
AssetVisibility,
SharingPermission,
} from 'src/enum';
import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -50,7 +41,6 @@ import {
withFiles,
withLibrary,
withOwner,
withPermissions,
withSmartSearch,
withTagId,
withTags,
@@ -175,93 +165,6 @@ const withBoundingBox = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T
);
};
export const hasAssetPermissions =
(userId: string, permissions: SharingPermission[], ignoreTimelineVisibility: boolean = false) =>
(eb: ExpressionBuilder<DB, 'asset'>) =>
eb.or([
eb('asset.ownerId', '=', userId),
eb.exists(
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('partner.inTimeline', '=', true)),
),
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album_asset.albumId').on('album_user.userId', '=', userId),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('album_user.inTimeline', '=', true))
.where('album_user.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
),
]);
export const hasAssetPermissionsRef = <T extends keyof DB>(
eb: ExpressionBuilder<DB, 'asset'>,
userIdRef: StringReference<DB, 'asset' | T>,
permissions: SharingPermission[],
ignoreTimelineVisibility: boolean = false,
) =>
eb.or([
eb('asset.ownerId', '=', eb.ref(userIdRef as never)),
eb.exists(
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.whereRef('partner.sharedWithId', '=', userIdRef as never)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('partner.inTimeline', '=', true)),
),
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album_asset.albumId')
.onRef('album_user.userId', '=', userIdRef as never),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('album_user.inTimeline', '=', true))
.where('album_user.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
),
]);
@Injectable()
export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -653,22 +556,17 @@ export class AssetRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, {}, DummyValue.UUID] })
@GenerateSql({ params: [DummyValue.UUID] })
getById(
id: string,
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {},
userId?: string,
) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.where('asset.id', '=', asUuid(id))
.$if(!!exifInfo, withExif)
.$if(!!faces, (qb) =>
qb
.select(faces?.person ? (eb) => withFacesAndPeople(eb, { userId }) : withFaces)
.$narrowType<{ faces: NotNull }>(),
)
.$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>())
.$if(!!library, (qb) => qb.select(withLibrary))
.$if(!!owner, (qb) => qb.select(withOwner))
.$if(!!smartSearch, withSmartSearch)
@@ -704,7 +602,6 @@ export class AssetRepository {
.$if(!!files, (qb) => qb.select(withFiles))
.$if(!!tags, (qb) => qb.select(withTags))
.$if(!!edits, (qb) => qb.select(withEdits))
.$if(!!userId, (qb) => qb.select(withPermissions(userId!)))
.limit(1)
.executeTakeFirst();
}
@@ -847,9 +744,7 @@ export class AssetRepository {
)
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
)
.$if(!!options.userIds, (qb) =>
qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)),
)
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) =>
@@ -935,9 +830,7 @@ export class AssetRepository {
),
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) =>
qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)),
)
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) =>
qb
+6 -9
View File
@@ -15,7 +15,7 @@ import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/mis
type JobMapItem = {
jobName: JobName;
queueName: QueueName;
handler: (job?: JobOf<any>) => Promise<JobStatus>;
handler: (job: JobOf<any>) => Promise<JobStatus>;
label: string;
};
@@ -95,17 +95,14 @@ export class JobRepository {
}
}
async run(job: JobItem) {
const item = this.handlers[job.name];
async run({ name, data }: JobItem) {
const item = this.handlers[name as JobName];
if (!item) {
this.logger.warn(`Skipping unknown job: "${job.name}"`);
this.logger.warn(`Skipping unknown job: "${name}"`);
return JobStatus.Skipped;
}
if ('data' in job) {
return item.handler(job.data);
}
return item.handler();
return item.handler(data);
}
setConcurrency(queueName: QueueName, concurrency: number) {
@@ -170,7 +167,7 @@ export class JobRepository {
const queueName = this.getQueueName(item.name);
const job = {
name: item.name,
data: ('data' in item ? item.data : undefined) || {},
data: item.data || {},
options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined };
+1 -1
View File
@@ -73,7 +73,7 @@ export class MemoryRepository implements IBulkAsset {
eb.exists(
eb
.selectFrom('asset_face')
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.innerJoin('person', 'person.id', 'asset_face.personId')
.select((eb) => eb.val(1).as('one'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('person.isHidden', '=', true),
+29 -110
View File
@@ -4,8 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SharingPermission, SourceType } from 'src/enum';
import { hasAssetPermissions, hasAssetPermissionsRef } from 'src/repositories/asset.repository';
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
@@ -34,9 +33,9 @@ export interface AssetFaceId {
}
export interface UpdateFacesData {
oldFaceClusterId?: string;
oldPersonId?: string;
faceIds?: string[];
newFaceClusterId: string;
newPersonId: string;
}
export interface PersonStatistics {
@@ -55,7 +54,7 @@ export interface GetAllPeopleOptions {
}
export interface GetAllFacesOptions {
faceClusterId?: string | null;
personId?: string | null;
assetId?: string;
sourceType?: SourceType;
}
@@ -64,27 +63,9 @@ export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>, userId?: string) => {
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom(
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId')
.$if(!!userId, (qb) =>
qb.where((eb) =>
eb.or([eb('person.ownerId', '=', userId!), hasPermissions(userId!, [SharingPermission.PersonRead])(eb)]),
),
)
.orderBy(
(eb) =>
eb(
'person.ownerId',
'=',
eb.selectFrom('asset').select('asset.ownerId').whereRef('asset.id', '=', 'asset_face.assetId'),
),
'desc',
)
.limit(1),
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
).as('person');
};
@@ -94,47 +75,16 @@ const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
).as('faceSearch');
};
export const hasPermissions =
(userId: string, permissions: SharingPermission[]) => (eb: ExpressionBuilder<DB, 'person'>) =>
eb.or([
eb.exists((eb) =>
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'person.ownerId')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
),
),
eb.exists((eb) =>
eb
.selectFrom('album_user')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
)
.whereRef('album_user.userId', '=', 'person.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
]);
@Injectable()
export class PersonRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise<number> {
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ faceClusterId: newFaceClusterId })
.$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!))
.set({ personId: newPersonId })
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!))
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
.executeTakeFirst();
@@ -144,7 +94,7 @@ export class PersonRepository {
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.db
.updateTable('asset_face')
.set({ faceClusterId: null })
.set({ personId: null })
.where('asset_face.sourceType', '=', sourceType)
.execute();
}
@@ -167,8 +117,8 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null))
.$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!))
.$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null))
.$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
.where('asset_face.deletedAt', 'is', null)
@@ -203,20 +153,16 @@ export class PersonRepository {
const items = await this.db
.selectFrom('person')
.selectAll('person')
.innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.innerJoin('asset_face', 'asset_face.personId', 'person.id')
.innerJoin('asset', (join) =>
join
.onRef('asset_face.assetId', '=', 'asset.id')
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.where((eb) =>
eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]),
)
.where('person.ownerId', '=', userId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.orderBy('person.faceClusterId')
.orderBy((eb) => eb('person.ownerId', '=', userId), 'desc')
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.having((eb) =>
@@ -225,7 +171,6 @@ export class PersonRepository {
eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1),
]),
)
.distinctOn('person.faceClusterId')
.groupBy('person.id')
.$if(!!options?.closestFaceAssetId, (qb) =>
qb.orderBy((eb) =>
@@ -264,7 +209,7 @@ export class PersonRepository {
return this.db
.selectFrom('person')
.selectAll('person')
.leftJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.leftJoin('asset_face', 'asset_face.personId', 'person.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
@@ -273,13 +218,13 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string, options: { isVisible?: boolean; userId?: string } = {}) {
const { isVisible = true, userId } = options;
getFaces(assetId: string, options?: { isVisible?: boolean }) {
const isVisible = options === undefined ? true : options.isVisible;
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select((eb) => withPerson(eb, userId))
.select(withPerson)
.where('asset_face.assetId', '=', assetId)
.where('asset_face.deletedAt', 'is', null)
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
@@ -303,7 +248,7 @@ export class PersonRepository {
getFaceForFacialRecognitionJob(id: string) {
return this.db
.selectFrom('asset_face')
.select(['asset_face.id', 'asset_face.faceClusterId', 'asset_face.sourceType'])
.select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType'])
.select((eb) =>
jsonObjectFrom(
eb
@@ -344,10 +289,10 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise<number> {
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ faceClusterId: newFaceClusterId })
.set({ personId: newPersonId })
.where('asset_face.id', '=', assetFaceId)
.executeTakeFirst();
@@ -373,7 +318,6 @@ export class PersonRepository {
.where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
.orderBy((eb) => eb('person.ownerId', '=', userId), 'desc')
.limit(100)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute();
@@ -391,7 +335,7 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(userId: string, personId: string): Promise<PersonStatistics> {
async getStatistics(personId: string): Promise<PersonStatistics> {
const result = await this.db
.selectFrom('asset_face')
.leftJoin('asset', (join) =>
@@ -400,13 +344,10 @@ export class PersonRepository {
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.where(hasAssetPermissions(userId, [SharingPermission.AssetRead], true))
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.where('asset_face.faceClusterId', '=', (eb) =>
eb.selectFrom('person').select('person.faceClusterId').where('person.id', '=', personId),
)
.where('asset_face.personId', '=', personId)
.executeTakeFirst();
return {
@@ -423,7 +364,7 @@ export class PersonRepository {
eb.exists((eb) =>
eb
.selectFrom('asset_face')
.whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId')
.whereRef('asset_face.personId', '=', 'person.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true)
.where((eb) =>
@@ -437,20 +378,13 @@ export class PersonRepository {
),
),
)
.where((eb) =>
eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]),
)
.where('person.ownerId', '=', userId)
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total'))
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('isHidden', '=', true), zero).as('hidden'))
.executeTakeFirstOrThrow();
}
async create(person: Insertable<PersonTable>) {
if (!person.faceClusterId) {
const { id } = await this.db.insertInto('face_cluster').defaultValues().returning('id').executeTakeFirstOrThrow();
person.faceClusterId = id;
}
create(person: Insertable<PersonTable>) {
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
}
@@ -541,9 +475,8 @@ export class PersonRepository {
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson)
.innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId'))
.where('person.id', 'in', personIds)
.where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personId', 'in', personIds)
.where('asset_face.deletedAt', 'is', null)
.execute();
}
@@ -553,15 +486,7 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.innerJoin('person', (join) =>
join.onRef('asset_face.faceClusterId', '=', 'person.faceClusterId').on('person.id', '=', personId),
)
.where('asset_face.assetId', 'in', (eb) =>
eb
.selectFrom('asset')
.select('asset.id')
.where((eb) => hasAssetPermissionsRef(eb, 'person.ownerId', [SharingPermission.AssetRead], true)),
)
.where('asset_face.personId', '=', personId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.executeTakeFirst();
@@ -648,14 +573,8 @@ export class PersonRepository {
.selectFrom('asset_face')
.select('asset_face.id')
.where('asset_face.assetId', '=', assetId)
.innerJoin('person', (join) =>
join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId').on('person.id', '=', personId),
)
.where('asset_face.personId', '=', personId)
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
.executeTakeFirst();
}
getByFaceClusterId(faceClusterId: string) {
return this.db.selectFrom('person').selectAll().where('person.faceClusterId', '=', faceClusterId).execute();
}
}
+4 -12
View File
@@ -325,23 +325,15 @@ export class SearchRepository {
.selectFrom('asset_face')
.select([
'asset_face.id',
'asset_face.faceClusterId',
'asset_face.personId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
])
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.select('asset.ownerId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('asset.ownerId', 'in', (eb) =>
eb
.selectFrom('user')
.select('user.id')
.where('user.trustedGroupId', 'in', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', anyUuid(userIds)),
),
)
.leftJoin('person', 'person.id', 'asset_face.personId')
.where('asset.ownerId', '=', anyUuid(userIds))
.where('asset.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_face.faceClusterId', 'is not', null))
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null))
.$if(!!minBirthDate, (qb) =>
qb.where((eb) =>
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
+1 -1
View File
@@ -443,7 +443,7 @@ class AssetFaceSync extends BaseSync {
.select([
'asset_face.id',
'assetId',
'faceClusterId',
'personId',
'imageWidth',
'imageHeight',
'boundingBoxX1',
@@ -325,61 +325,4 @@ export class UserRepository {
await query.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getInSameTrustedGroup(userId: string) {
return this.db
.selectFrom('user')
.select('user.id')
.where('user.trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId),
)
.execute()
.then((result) => result.map(({ id }) => id));
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, userIdToMerge: DummyValue.UUID }] })
async mergeTrustedGroups({ userId, userIdToMerge }: { userId: string; userIdToMerge: string }) {
return this.db
.updateTable('user')
.from('user as u')
.where('u.id', '=', userId)
.where('user.trustedGroupId', '=', (eb) =>
eb
.selectFrom('user')
.select('user.trustedGroupId')
.where('user.id', '=', userIdToMerge)
.whereRef('user.trustedGroupId', '!=', 'u.trustedGroupId'),
)
.set((eb) => ({
trustedGroupId: eb.ref('u.trustedGroupId'),
}))
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async updateTrustedGroups(userId: string) {
return this.db
.updateTable('user')
.set((eb) => ({ trustedGroupId: eb.fn('uuid_generate_v4') }))
.where('user.trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId),
)
.where('user.id', '!=', userId)
.where('user.id', 'not in', (eb) =>
eb
.selectFrom('partner')
.select('partner.sharedById as userId')
.where('sharedWithId', '=', userId)
.union((eb) =>
eb
.selectFrom('album_user')
.select('album_user.userId')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
),
),
)
.executeTakeFirst();
}
}
@@ -45,10 +45,10 @@ export class WorkflowRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
search(dto: WorkflowSearchDto & { ownerId?: string }) {
search(dto: WorkflowSearchDto & { userId?: string }) {
return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
.$if(!!dto.userId, (qb) => qb.where('ownerId', '=', dto.userId!))
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
.orderBy('createdAt', 'desc')
-6
View File
@@ -4,7 +4,6 @@ import {
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SharingPermission,
SourceType,
VideoSegmentCodec,
} from 'src/enum';
@@ -38,8 +37,3 @@ export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: Object.values(VideoSegmentCodec),
});
export const sharing_permission_enum = registerEnum({
name: 'sharing_permission_enum',
values: Object.values(SharingPermission),
});
+1 -11
View File
@@ -4,7 +4,6 @@ import {
asset_face_source_type,
asset_visibility_enum,
assets_status_enum,
sharing_permission_enum,
} from 'src/schema/enums';
import {
album_user_after_insert,
@@ -46,7 +45,6 @@ import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
@@ -112,7 +110,6 @@ export class ImmichDatabase {
AssetTable,
AssetFileTable,
AssetExifTable,
FaceClusterTable,
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
@@ -173,13 +170,7 @@ export class ImmichDatabase {
asset_face_audit,
];
enum = [
album_user_role_enum,
assets_status_enum,
asset_face_source_type,
asset_visibility_enum,
sharing_permission_enum,
];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
}
export interface Migrations {
@@ -220,7 +211,6 @@ export interface DB {
ocr_search: OcrSearchTable;
face_search: FaceSearchTable;
face_cluster: FaceClusterTable;
geodata_places: GeodataPlacesTable;
@@ -1,17 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "sharing_permission_enum" AS ENUM ('all','asset.read','asset.update','asset.edit','asset.delete','asset.share','exif.read','person.read','person.update','person.merge','person.delete');`.execute(db);
await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
await sql`ALTER TABLE "album_user" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{asset.read,exif.read}';`.execute(db);
await sql`ALTER TABLE "album_user" ADD "inTimeline" boolean NOT NULL DEFAULT false;`.execute(db);
await sql`ALTER TABLE "partner" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{all}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TYPE "sharing_permission_enum";`.execute(db);
await sql`ALTER TABLE "partner" DROP COLUMN "permissions";`.execute(db);
await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "permissions";`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "inTimeline";`.execute(db);
}
@@ -1,51 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "faceClusterId";`.execute(db);
await sql`CREATE INDEX "asset_face_faceClusterId_assetId_idx" ON "asset_face" ("faceClusterId", "assetId");`.execute(db);
await sql`CREATE INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("faceClusterId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(db);
await sql`CREATE INDEX "asset_face_assetId_faceClusterId_idx" ON "asset_face" ("assetId", "faceClusterId");`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db);
await sql`CREATE TABLE "face_cluster" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "face_cluster_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db);
await sql`ALTER TABLE "person" ADD "faceClusterId" uuid;`.execute(db);
await sql`CREATE INDEX "person_faceClusterId_idx" ON "person" ("faceClusterId");`.execute(db);
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "face_cluster_updateId_idx" ON "face_cluster" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "face_cluster_updatedAt"
BEFORE UPDATE ON "face_cluster"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_face_cluster_updatedAt', '{"type":"trigger","name":"face_cluster_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"face_cluster\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"faceClusterId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "person" DROP COLUMN "faceClusterId";`.execute(db);
await sql`DROP INDEX "person_faceClusterId_idx";`.execute(db);
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_faceClusterId_fkey";`.execute(db);
await sql`ALTER TABLE "asset_face" RENAME COLUMN "faceClusterId" TO "personId";`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ((("deletedAt" IS NULL) AND ("isVisible" IS TRUE)));`.execute(db);
await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db);
await sql`DROP INDEX "asset_face_faceClusterId_assetId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_assetId_faceClusterId_idx";`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_faceClusterId_fkey";`.execute(db);
await sql`DROP TABLE "face_cluster";`.execute(db);
await sql`DROP TRIGGER "face_cluster_updatedAt" ON "face_cluster";`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","type":"index"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_face_cluster_updatedAt';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
+2 -12
View File
@@ -11,8 +11,8 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole, SharingPermission } from 'src/enum';
import { album_user_role_enum, sharing_permission_enum } from 'src/schema/enums';
import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@@ -69,14 +69,4 @@ export class AlbumUserTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({
array: true,
enum: sharing_permission_enum,
default: [SharingPermission.AssetRead, SharingPermission.ExifRead],
})
permissions!: Generated<SharingPermission[]>;
@Column({ type: 'boolean', default: false })
inTimeline!: Generated<boolean>;
}
+8 -8
View File
@@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { PersonTable } from 'src/schema/tables/person.table';
@Table({ name: 'asset_face' })
@UpdatedAtTrigger('asset_face_updatedAt')
@@ -26,13 +26,13 @@ import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
when: 'pg_trigger_depth() = 0',
})
// schemaFromDatabase does not preserve column order
@Index({ name: 'asset_face_assetId_faceClusterId_idx', columns: ['assetId', 'faceClusterId'] })
@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] })
@Index({
name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx',
columns: ['faceClusterId', 'assetId'],
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx',
columns: ['personId', 'assetId'],
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
})
@Index({ columns: ['faceClusterId', 'assetId'] })
@Index({ columns: ['personId', 'assetId'] })
export class AssetFaceTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -45,14 +45,14 @@ export class AssetFaceTable {
})
assetId!: string;
@ForeignKeyColumn(() => FaceClusterTable, {
@ForeignKeyColumn(() => PersonTable, {
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
nullable: true,
// [faceClusterId, assetId] makes this redundant
// [personId, assetId] makes this redundant
index: false,
})
faceClusterId!: string | null;
personId!: string | null;
@Column({ default: 0, type: 'integer' })
imageWidth!: Generated<number>;
@@ -1,25 +0,0 @@
import {
CreateDateColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
@Table('face_cluster')
@UpdatedAtTrigger('face_cluster_updatedAt')
export class FaceClusterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
@@ -9,8 +9,6 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SharingPermission } from 'src/enum';
import { sharing_permission_enum } from 'src/schema/enums';
import { partner_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
@@ -48,7 +46,4 @@ export class PartnerTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@Column({ array: true, enum: sharing_permission_enum, default: [SharingPermission.All] })
permissions!: Generated<SharingPermission[]>;
}
+3 -7
View File
@@ -14,7 +14,6 @@ import {
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@@ -44,6 +43,9 @@ export class PersonTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: '' })
name!: Generated<string>;
@Column({ default: '' })
thumbnailPath!: Generated<string>;
@@ -53,9 +55,6 @@ export class PersonTable {
@Column({ type: 'date', nullable: true })
birthDate!: Timestamp | null;
@Column({ default: '' })
name!: Generated<string>;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@@ -67,7 +66,4 @@ export class PersonTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true })
faceClusterId!: string | null;
}
-4
View File
@@ -4,7 +4,6 @@ import {
CreateDateColumn,
DeleteDateColumn,
Generated,
GeneratedColumn,
Index,
PrimaryGeneratedColumn,
Table,
@@ -83,7 +82,4 @@ export class UserTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@GeneratedColumn('uuid')
trustedGroupId!: Generated<string>;
}
+4 -37
View File
@@ -8,15 +8,13 @@ import {
CreateAlbumDto,
GetAlbumsDto,
mapAlbum,
SharingPermissionsResponseDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
UpdateSharingPermissionsDto,
} from 'src/dtos/album.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { AlbumUserRole, Permission, SharingPermission } from 'src/enum';
import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -39,15 +37,12 @@ export class AlbumService extends BaseService {
};
}
async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, isOwned, isShared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, ...rest }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
const albums = assetId
? await this.albumRepository.getByAssetId(ownerId, assetId)
: await this.albumRepository.getAll(ownerId, { isOwned, isShared });
: await this.albumRepository.getAll(ownerId, rest);
if (albums.length === 0) {
return [];
@@ -139,11 +134,6 @@ export class AlbumService extends BaseService {
);
for (const { userId } of albumUsers) {
await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: userId,
});
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
@@ -313,17 +303,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid user');
}
await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: userId,
});
await this.albumUserRepository.create({
userId,
albumId: id,
role,
permissions: [SharingPermission.AssetRead, SharingPermission.ExifRead],
});
await this.albumUserRepository.create({ userId, albumId: id, role });
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
@@ -362,19 +342,6 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
async updateSelf(auth: AuthDto, albumId: string, dto: UpdateSharingPermissionsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] });
await this.albumUserRepository.update(
{ albumId, userId: auth.user.id },
{ permissions: dto.permissions, inTimeline: dto.inTimeline },
);
}
async getSelf(auth: AuthDto, albumId: string): Promise<SharingPermissionsResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] });
return this.albumUserRepository.get({ userId: auth.user.id, albumId });
}
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options, authUserId);
if (!album) {
+10 -15
View File
@@ -32,11 +32,10 @@ import {
JobStatus,
Permission,
QueueName,
SharingPermission,
} from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { hasPermissions, requireElevatedPermission } from 'src/utils/access';
import { requireElevatedPermission } from 'src/utils/access';
import {
getAssetFiles,
getDimensions,
@@ -63,18 +62,14 @@ export class AssetService extends BaseService {
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const asset = await this.assetRepository.getById(
id,
{
exifInfo: true,
owner: true,
faces: { person: true },
stack: { assets: true },
edits: true,
tags: true,
},
auth.user.id,
);
const asset = await this.assetRepository.getById(id, {
exifInfo: true,
owner: true,
faces: { person: true },
stack: { assets: true },
edits: true,
tags: true,
});
if (!asset) {
throw new BadRequestException('Asset not found');
@@ -90,7 +85,7 @@ export class AssetService extends BaseService {
delete data.owner;
}
if (!hasPermissions(data, SharingPermission.PersonRead)) {
if (data.ownerId !== auth.user.id || auth.sharedLink) {
data.people = [];
}
+1 -5
View File
@@ -85,11 +85,7 @@ export class NotificationService extends BaseService {
return;
}
this.logger.error(
`Unable to run job handler (${job.name}): ${error}`,
error?.stack,
'data' in job ? JSON.stringify(job.data) : {},
);
this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data));
switch (job.name) {
case JobName.DatabaseBackup: {
+2 -14
View File
@@ -3,7 +3,7 @@ import { Partner } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto';
import { JobName, Permission, SharingPermission } from 'src/enum';
import { Permission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository';
import { BaseService } from 'src/services/base.service';
@@ -16,15 +16,7 @@ export class PartnerService extends BaseService {
throw new BadRequestException(`Partner already exists`);
}
const { numUpdatedRows } = await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: sharedWithId,
});
const partner = await this.partnerRepository.create({ ...partnerId, permissions: [SharingPermission.All] });
if (numUpdatedRows > 0) {
await this.jobRepository.queue({ name: JobName.FacialRecognitionMerge, data: { id: sharedWithId } });
}
const partner = await this.partnerRepository.create(partnerId);
return this.mapPartner(partner, PartnerDirection.SharedBy);
}
@@ -36,10 +28,6 @@ export class PartnerService extends BaseService {
}
await this.partnerRepository.remove(partnerId);
const { numUpdatedRows } = await this.userRepository.updateTrustedGroups(auth.user.id);
if (numUpdatedRows > 0) {
await this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force: true } });
}
}
async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> {
+18 -112
View File
@@ -13,7 +13,7 @@ import {
FaceDto,
mapFaces,
mapPerson,
MergeFaceClusterDto,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
@@ -127,11 +127,11 @@ export class PersonService extends BaseService {
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] });
const faces = await this.personRepository.getFaces(dto.id, { userId: auth.user.id });
const faces = await this.personRepository.getFaces(dto.id);
const asset = await this.assetRepository.getForFaces(dto.id);
const assetDimensions = getDimensions(asset);
return faces.map((face) => mapFaces(face, asset.edits, assetDimensions));
return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
}
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
@@ -159,7 +159,7 @@ export class PersonService extends BaseService {
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
return this.personRepository.getStatistics(auth.user.id, id);
return this.personRepository.getStatistics(id);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
@@ -438,7 +438,7 @@ export class PersonService extends BaseService {
const lastRun = new Date().toISOString();
const facePagination = this.personRepository.getAllFaces(
force ? undefined : { faceClusterId: null, sourceType: SourceType.MachineLearning },
force ? undefined : { personId: null, sourceType: SourceType.MachineLearning },
);
let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
@@ -481,8 +481,8 @@ export class PersonService extends BaseService {
return JobStatus.Failed;
}
if (face.faceClusterId) {
this.logger.debug(`Face ${id} already belongs to a face cluster`);
if (face.personId) {
this.logger.debug(`Face ${id} already has a person assigned`);
return JobStatus.Skipped;
}
@@ -511,8 +511,8 @@ export class PersonService extends BaseService {
return JobStatus.Skipped;
}
let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId;
if (!faceClusterId) {
let personId = matches.find((match) => match.personId)?.personId;
if (!personId) {
const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
@@ -523,109 +523,20 @@ export class PersonService extends BaseService {
});
if (matchWithPerson.length > 0) {
faceClusterId = matchWithPerson[0].faceClusterId;
personId = matchWithPerson[0].personId;
}
}
if (isCore && !faceClusterId) {
if (isCore && !personId) {
this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
faceClusterId = newPerson.faceClusterId;
personId = newPerson.id;
}
if (faceClusterId) {
this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
}
return JobStatus.Success;
}
@OnJob({ name: JobName.FacialRecognitionMerge, queue: QueueName.FacialRecognition })
async mergeClusters({ id: userId }: JobOf<JobName.FacialRecognitionMerge>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.Skipped;
}
const faces = this.personRepository.getAllFaces({ sourceType: SourceType.MachineLearning });
for await (const { id } of faces) {
const face = await this.personRepository.getFaceForFacialRecognitionJob(id);
if (!face?.faceSearch || !face.asset) {
this.logger.warn(`Face ${id} does not have an embedding`);
continue;
}
let faceClusterId: string | null = null;
let personId: string | null = null;
const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: 100,
hasPerson: true,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
if (matchWithPerson.length > 0) {
// favor a person that's not owned by us to merge people with a newly shared with user
// probably do smarter stuff here like pick the person with a name, if both have a name set aliases or whatever
const match = matchWithPerson.find((match) => match.ownerId !== userId) ?? matchWithPerson[0];
if (match.faceClusterId && face.asset.ownerId !== match.ownerId) {
// TODO should probably be a DB constraint?
const people = await this.personRepository.getByFaceClusterId(match.faceClusterId);
if (!people.some((person) => person.ownerId === face.asset?.ownerId)) {
const { id } = await this.personRepository.create({
ownerId: face.asset.ownerId,
faceClusterId: match.faceClusterId,
});
personId = id;
}
}
faceClusterId = match.faceClusterId;
}
if (!faceClusterId) {
const matches = await this.searchRepository.searchFaces({
userIds: [userId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: machineLearning.facialRecognition.minFaces,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
const match = matches.find((match) => match.faceClusterId);
if (
match &&
match.faceClusterId &&
face.asset.ownerId !== match.ownerId &&
matches.length >= machineLearning.facialRecognition.minFaces
) {
// TODO should probably be a DB constraint?
const people = await this.personRepository.getByFaceClusterId(match.faceClusterId);
if (!people.some((person) => person.ownerId === face.asset?.ownerId)) {
const { id } = await this.personRepository.create({
ownerId: face.asset.ownerId,
faceClusterId: match.faceClusterId,
});
personId = id;
}
}
faceClusterId = match?.faceClusterId ?? null;
}
if (faceClusterId) {
this.logger.log(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
}
if (personId) {
await this.createNewFeaturePhoto([personId]);
}
if (personId) {
this.logger.debug(`Assigning face ${id} to person ${personId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
}
return JobStatus.Success;
@@ -643,7 +554,7 @@ export class PersonService extends BaseService {
return JobStatus.Success;
}
async mergePerson(auth: AuthDto, id: string, dto: MergeFaceClusterDto): Promise<BulkIdResponseDto[]> {
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids;
if (mergeIds.includes(id)) {
throw new BadRequestException('Cannot merge a person into themselves');
@@ -689,7 +600,7 @@ export class PersonService extends BaseService {
}
const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldFaceClusterId: mergeId, newFaceClusterId: id };
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData);
@@ -702,7 +613,6 @@ export class PersonService extends BaseService {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
@@ -772,12 +682,8 @@ export class PersonService extends BaseService {
dto.imageHeight = originalDimensions.height;
}
if (!person?.faceClusterId) {
throw new Error('Person must already have some recognized faces and belong to a face cluster');
}
await this.personRepository.createAssetFace({
faceClusterId: person.faceClusterId,
personId: dto.personId,
assetId: dto.assetId,
imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth,
-1
View File
@@ -212,7 +212,6 @@ export class SearchService extends BaseService {
repository: this.partnerRepository,
timelineEnabled: true,
});
console.log(auth.user.id, partnerIds);
return [auth.user.id, ...partnerIds];
}
@@ -1,9 +1,8 @@
import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { join } from 'node:path';
import { OnEvent, OnJob } from 'src/decorators';
import { DummyValue, OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -21,6 +20,7 @@ import {
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
@@ -32,9 +32,11 @@ const dummy = () => {
type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (changes: WorkflowChanges<T>) => Promise<void>;
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
};
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@@ -62,7 +64,6 @@ export class WorkflowExecutionService extends BaseService {
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
@@ -247,20 +248,25 @@ export class WorkflowExecutionService extends BaseService {
}
@OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
const items = await this.workflowRepository.search(dto);
onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
items.map((workflow) => ({
name: JobName.WorkflowAssetCreate,
data: { workflowId: workflow.id, assetId: asset.id },
name: JobName.WorkflowAssetTrigger,
data: { workflowId: workflow.id, assetId, trigger },
})),
);
}
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
switch (type) {
case WorkflowType.AssetV1: {
return {
@@ -271,19 +277,16 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId,
};
},
write: async (changes) => {
if (changes.asset) {
await this.assetRepository.update({
id: assetId,
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
write: async (auth, changes) => {
const asset = changes.asset;
if (!asset) {
return;
}
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
});
},
} satisfies ExecuteOptions<typeof type>;
}
@@ -301,7 +304,19 @@ export class WorkflowExecutionService extends BaseService {
}
// TODO infer from steps
const type = 'AssetV1' as T;
let type: T | undefined;
for (const targetType of Object.values(WorkflowType)) {
const missing = workflow.steps.some((step) => !step.types.includes(targetType));
if (!missing) {
type = targetType as unknown as T;
break;
}
}
if (!type) {
throw new Error('Unable to infer workflow event type from steps');
}
const handler = getHandler(type);
if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
@@ -337,7 +352,18 @@ export class WorkflowExecutionService extends BaseService {
payload,
);
if (result?.changes) {
await write(result.changes);
await write(
{
user: {
id: readResult.authUserId,
},
session: {
id: DummyValue.UUID,
hasElevatedPermission: true,
},
} as AuthDto,
result.changes,
);
({ data } = await read(type));
}
+1 -1
View File
@@ -23,7 +23,7 @@ export class WorkflowService extends BaseService {
}
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
const workflows = await this.workflowRepository.search({ ...dto, userId: auth.user.id });
return workflows.map((workflow) => mapWorkflow(workflow));
}
+2 -5
View File
@@ -204,9 +204,7 @@ export type ConcurrentQueueName = Exclude<
| QueueName.BackupDatabase
>;
export type Jobs = {
[K in JobItem['name']]: 'data' extends keyof (JobItem & { name: K }) ? (JobItem & { name: K })['data'] : never;
};
export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] };
export type JobOf<T extends JobName> = Jobs[T];
export interface IBaseJob {
@@ -353,7 +351,6 @@ export type JobItem =
| { name: JobName.AssetDetectFaces; data: IEntityJob }
| { name: JobName.FacialRecognitionQueueAll; data: INightlyJob }
| { name: JobName.FacialRecognition; data: IDeferrableJob }
| { name: JobName.FacialRecognitionMerge; data: IEntityJob }
| { name: JobName.PersonGenerateThumbnail; data: IEntityJob }
// Smart Search
@@ -407,7 +404,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } }
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
+22 -82
View File
@@ -1,7 +1,7 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthSharedLink } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserRole, Permission, SharingPermission } from 'src/enum';
import { AlbumUserRole, Permission } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set';
@@ -115,41 +115,37 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
case Permission.AssetRead: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]);
return setUnion(isOwner, isShared);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.AssetShare: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetShare]);
return setUnion(isOwner, isShared);
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.AssetView: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]);
return setUnion(isOwner, isShared);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.AssetDownload: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [
SharingPermission.AssetRead,
SharingPermission.ExifRead,
]);
return setUnion(isOwner, isShared);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.AssetUpdate: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetUpdate]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetDelete: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetDelete]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetCopy: {
@@ -157,21 +153,15 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
case Permission.AssetEditGet: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetEditCreate: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetEditDelete: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AlbumRead: {
@@ -256,11 +246,7 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
case Permission.FaceDelete: {
const isOwner = await access.person.checkFaceOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedFaceAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.AssetUpdate,
]);
return setUnion(isOwner, isShared);
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.NotificationRead:
@@ -302,40 +288,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PersonRead: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonRead,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonRead:
case Permission.PersonUpdate:
case Permission.PersonDelete:
case Permission.PersonMerge: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonMerge,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonUpdate: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonUpdate,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonDelete: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonDelete,
]);
return setUnion(isOwner, isShared);
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PersonReassign: {
@@ -382,20 +339,3 @@ export const requireElevatedPermission = (auth: AuthDto) => {
throw new UnauthorizedException('Elevated permission is required');
}
};
export const hasPermissions = (
assetLike: { permissions: SharingPermission[] },
...permissions: SharingPermission[]
) => {
if (assetLike.permissions.includes(SharingPermission.All)) {
return true;
}
for (const permission of permissions) {
if (!assetLike.permissions.includes(permission)) {
return false;
}
}
return true;
};
+1 -6
View File
@@ -4,7 +4,7 @@ import { AssetFile } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetType, AssetVisibility, Permission, SharingPermission } from 'src/enum';
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
@@ -134,11 +134,6 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
continue;
}
const permissions = [SharingPermission.All, SharingPermission.AssetRead];
if (!permissions.some((permission) => partner.permissions.includes(permission))) {
continue;
}
partnerIds.add(partner.sharedById);
}
+12 -48
View File
@@ -15,17 +15,9 @@ import {
import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres';
import { columns, lockableProperties, LockableProperty } from 'src/database';
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import {
AssetFileType,
AssetOrderBy,
AssetVisibility,
DatabaseExtension,
ExifOrientation,
SharingPermission,
} from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -220,22 +212,19 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
export function withFacesAndPeople(
eb: ExpressionBuilder<DB, 'asset'>,
{ withHidden, withDeletedFace, userId: _ }: { withHidden?: boolean; withDeletedFace?: boolean; userId?: string } = {},
withHidden?: boolean,
withDeletedFace?: boolean,
) {
return jsonArrayFrom(
eb
.selectFrom('asset_face')
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('face_cluster')
.whereRef('face_cluster.id', '=', 'asset_face.faceClusterId')
.innerJoin('person', 'person.faceClusterId', 'face_cluster.id')
.selectAll('person')
.limit(1),
).as('person'),
.leftJoinLateral(
(eb) =>
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
(join) => join.onTrue(),
)
.selectAll('asset_face')
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
@@ -248,12 +237,11 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds:
eb
.selectFrom('asset_face')
.select('assetId')
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('person.id', '=', anyUuid(personIds!))
.where('personId', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null)
.where('isVisible', 'is', true)
.groupBy('assetId')
.having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length)
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
);
@@ -314,30 +302,6 @@ export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
}
export function withPermissions(userId: string) {
return (eb: ExpressionBuilder<DB, 'asset'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.select((eb) => eb.fn<SharingPermission>('unnest', ['album_user.permissions']).as('permission'))
.distinct()
.innerJoin('album_asset', 'album_user.albumId', 'album_asset.albumId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
)
.union(
eb
.selectFrom('partner')
.select((eb) => eb.fn<SharingPermission>('unnest', ['partner.permissions']).as('permission'))
.distinct()
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId),
),
).as('permissions');
}
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
return qb.where((eb) =>
eb.exists(
@@ -464,7 +428,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!))
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
.$if(!!options.userIds, (qb) => qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead])))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(!!options.encodedVideoPath, (qb) =>
qb
.innerJoin('asset_file', (join) =>
-1
View File
@@ -38,7 +38,6 @@ const createAsset = (
fileSizeInByte !== null || Object.keys(exifFields).length > 0
? ExifResponseSchema.parse({ fileSizeInByte, ...exifFields })
: undefined,
permissions: [],
});
describe('duplicate utils', () => {
+4 -13
View File
@@ -1,3 +1,4 @@
import { AssetFace } from 'src/database';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ImageDimensions } from 'src/types';
@@ -30,21 +31,11 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio
};
};
export const checkFaceVisibility = <
T extends {
isVisible: boolean;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
},
>(
faces: T[],
export const checkFaceVisibility = (
faces: AssetFace[],
originalAssetDimensions: ImageDimensions,
crop?: BoundingBox,
): { visible: T[]; hidden: T[] } => {
): { visible: AssetFace[]; hidden: AssetFace[] } => {
if (!crop) {
return {
visible: faces.filter((face) => !face.isVisible),
@@ -28,8 +28,6 @@ export class AlbumUserFactory {
createdAt: newDate(),
updateId: newUuidV7(),
updatedAt: newDate(),
permissions: [],
inTimeline: false,
...dto,
});
}
-1
View File
@@ -26,7 +26,6 @@ export class PartnerFactory {
sharedWithId,
updatedAt: newDate(),
updateId: newUuidV7(),
permissions: [],
...dto,
})
.sharedBy({ id: sharedById })
-1
View File
@@ -35,7 +35,6 @@ export class UserFactory {
status: UserStatus.Active,
profileChangedAt: newDate(),
updateId: newUuidV7(),
trustedGroupId: newUuid(),
...dto,
});
}
@@ -137,7 +137,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Archive,
@@ -154,7 +154,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
@@ -173,7 +173,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Locked,
@@ -190,7 +190,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
@@ -209,7 +209,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
@@ -224,7 +224,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -242,7 +242,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
@@ -261,7 +261,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id);
await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id);
@@ -279,7 +279,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});
@@ -21,7 +21,6 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()),
checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedAccess: vitest.fn().mockResolvedValue(new Set()),
},
album: {
@@ -49,8 +48,6 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
person: {
checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedFaceAccess: vitest.fn().mockResolvedValue(new Set()),
},
partner: {
@@ -0,0 +1,65 @@
import type { Attachment } from 'svelte/attachments';
const EDGE_ZONE = 72;
const MAX_SCROLL_SPEED = 22;
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let node = element.parentElement;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
};
export function dragAutoScroll(isActive: () => boolean): Attachment {
return (node) => {
const element = node as HTMLElement;
let scrollContainer: HTMLElement | null = null;
let pointerY = -1;
let frame: number | null = null;
const trackPointer = (event: DragEvent) => {
pointerY = event.clientY;
};
const tick = () => {
if (scrollContainer && pointerY >= 0) {
const { top, bottom } = scrollContainer.getBoundingClientRect();
let delta = 0;
if (pointerY < top + EDGE_ZONE) {
delta = -MAX_SCROLL_SPEED * Math.min(1, (top + EDGE_ZONE - pointerY) / EDGE_ZONE);
} else if (pointerY > bottom - EDGE_ZONE) {
delta = MAX_SCROLL_SPEED * Math.min(1, (pointerY - (bottom - EDGE_ZONE)) / EDGE_ZONE);
}
if (delta !== 0) {
scrollContainer.scrollBy(0, delta);
}
}
frame = requestAnimationFrame(tick);
};
$effect(() => {
if (!isActive()) {
return;
}
scrollContainer = findScrollContainer(element);
pointerY = -1;
globalThis.addEventListener('dragover', trackPointer);
frame = requestAnimationFrame(tick);
return () => {
globalThis.removeEventListener('dragover', trackPointer);
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
scrollContainer = null;
};
});
};
}
@@ -64,7 +64,7 @@
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each}
</div>
{:else if schema.uiHint === 'albumId'}
{:else if schema.uiHint === 'AlbumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array}
<Field {label} {description}>
@@ -26,13 +26,12 @@
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, hasPermissions, withoutIcons } from '$lib/utils';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
AssetVisibility,
SharingPermission,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
@@ -142,7 +141,7 @@
<ActionButton action={Actions.Edit} />
{#if hasPermissions(asset, SharingPermission.AssetDelete)}
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
@@ -160,7 +159,7 @@
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (hasPermissions(asset, SharingPermission.AssetShare) || isAlbumOwner)}
{#if album && (isOwner || isAlbumOwner)}
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
@@ -188,7 +187,7 @@
{/if}
{#if !isLocked}
{#if hasPermissions(asset, SharingPermission.AssetUpdate)}
{#if isOwner}
<ArchiveAction {asset} {onAction} {preAction} />
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
@@ -218,7 +217,7 @@
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
{#if hasPermissions(asset, SharingPermission.AssetUpdate)}
{#if isOwner}
<hr />
<ActionMenuItem action={Actions.RefreshFacesJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} />
@@ -3,7 +3,6 @@
import DetailPanelDate from '$lib/components/asset-viewer/DetailPanelDate.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/DetailPanelDescription.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/DetailPanelLocation.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/DetailPanelStarRating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/DetailPanelTags.svelte';
import { timeToLoadTheMap } from '$lib/constants';
@@ -12,7 +11,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, hasPermissions } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
@@ -21,7 +20,6 @@
AssetMediaSize,
getAllAlbums,
getAssetInfo,
SharingPermission,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
@@ -34,6 +32,7 @@
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
interface Props {
asset: AssetResponseDto;
@@ -43,7 +42,6 @@
let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
const allowExifUpdate = $derived(hasPermissions(asset, SharingPermission.AssetUpdate, SharingPermission.ExifRead));
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -149,9 +147,9 @@
</section>
{/if}
<DetailPanelDescription {asset} {allowExifUpdate} />
<DetailPanelRating {asset} {allowExifUpdate} />
<DetailPanelPeople {asset} {previousRoute} />
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
<DetailPanelPeople {asset} {isOwner} {previousRoute} />
<div class="p-4">
{#if asset.exifInfo}
@@ -162,7 +160,7 @@
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if}
<DetailPanelDate {asset} {allowExifUpdate} />
<DetailPanelDate {asset} />
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>
@@ -170,7 +168,7 @@
<div>
<p class="flex place-items-center gap-2 break-all whitespace-pre-wrap">
{asset.originalFileName}
{#if allowExifUpdate}
{#if isOwner}
<IconButton
icon={mdiInformationOutline}
aria-label={$t('show_file_location')}
@@ -273,7 +271,7 @@
</div>
{/if}
<DetailPanelLocation {allowExifUpdate} {asset} />
<DetailPanelLocation {isOwner} {asset} />
</div>
</section>

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