mirror of
https://github.com/immich-app/immich.git
synced 2026-01-30 00:34:48 -08:00
Compare commits
1 Commits
refactor/m
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea5c24ea7b |
5
.github/workflows/build-mobile.yml
vendored
5
.github/workflows/build-mobile.yml
vendored
@@ -178,12 +178,9 @@ jobs:
|
||||
contents: read
|
||||
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
runs-on: macos-15
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Select Xcode 26
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/node": "^24.10.8",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@@ -572,9 +572,6 @@
|
||||
"asset_list_layout_sub_title": "Layout",
|
||||
"asset_list_settings_subtitle": "Photo grid layout settings",
|
||||
"asset_list_settings_title": "Photo Grid",
|
||||
"asset_not_found_on_device_android": "Asset not found on device",
|
||||
"asset_not_found_on_device_ios": "Asset not found on device. If you are using iCloud, the asset may be inaccessible due to bad file stored on iCloud",
|
||||
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
|
||||
"asset_offline": "Asset Offline",
|
||||
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
|
||||
"asset_restored_successfully": "Asset restored successfully",
|
||||
@@ -2298,7 +2295,6 @@
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
"upload_dialog_title": "Upload Asset",
|
||||
"upload_error_with_count": "Upload error for {count, plural, one {# asset} other {# assets}}",
|
||||
"upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.",
|
||||
"upload_finished": "Upload finished",
|
||||
"upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}",
|
||||
|
||||
@@ -101,7 +101,7 @@ class ImmichAPI(cfg: ServerConfig) {
|
||||
}
|
||||
|
||||
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
|
||||
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
|
||||
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
|
||||
val connection = url.openConnection()
|
||||
val data = connection.getInputStream().readBytes()
|
||||
BitmapFactory.decodeByteArray(data, 0, data.size)
|
||||
|
||||
@@ -225,7 +225,7 @@ class ImmichAPI {
|
||||
}
|
||||
|
||||
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
|
||||
let thumbnailParams = [URLQueryItem(name: "size", value: "preview"), URLQueryItem(name: "edited", value: "true")]
|
||||
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
|
||||
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
|
||||
|
||||
guard
|
||||
|
||||
@@ -45,6 +45,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
}
|
||||
|
||||
void resumeSession() async {
|
||||
log.fine("Resuming previous session if possible...");
|
||||
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
||||
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
@@ -58,6 +59,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
unawaited(
|
||||
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
||||
(_) async {
|
||||
log.fine("Successfully updated auth info with stored access token");
|
||||
try {
|
||||
wsProvider.connect();
|
||||
unawaited(infoProvider.getServerInfo());
|
||||
@@ -114,6 +116,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
log.fine("Navigating to main application...");
|
||||
unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()));
|
||||
}
|
||||
|
||||
|
||||
@@ -298,8 +298,11 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'rating'.t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
'rating'.t(context: context).toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
RatingBar(
|
||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||
|
||||
@@ -62,8 +62,6 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
|
||||
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||
|
||||
final errorCount = ref.watch(driftBackupProvider.select((state) => state.errorCount));
|
||||
|
||||
final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty;
|
||||
|
||||
return AnimatedBuilder(
|
||||
@@ -151,14 +149,6 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
),
|
||||
],
|
||||
),
|
||||
if (errorCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
|
||||
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,9 +11,9 @@ import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
@@ -123,6 +123,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<bool> saveAuthInfo({required String accessToken}) async {
|
||||
_log.fine("Saving authentication information...");
|
||||
await _apiService.setAccessToken(accessToken);
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
@@ -132,13 +133,16 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
||||
|
||||
_log.fine("Fetching stored user information");
|
||||
UserDto? user = _userService.tryGetMyUser();
|
||||
|
||||
try {
|
||||
_log.fine("Fetching user information from server");
|
||||
final serverUser = await _userService.refreshMyUser().timeout(_timeoutDuration);
|
||||
if (serverUser == null) {
|
||||
_log.severe("Unable to get user information from the server.");
|
||||
} else {
|
||||
_log.fine("Successfully fetched user information from server");
|
||||
// If the user information is successfully retrieved, update the store
|
||||
// Due to the flow of the code, this will always happen on first login
|
||||
user = serverUser;
|
||||
|
||||
@@ -149,8 +149,6 @@ class DriftBackupState {
|
||||
);
|
||||
}
|
||||
|
||||
int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
|
||||
|
||||
@@ -260,7 +260,6 @@ class BackgroundUploadService {
|
||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
_logger.warning("Asset entity not found for ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -283,7 +282,6 @@ class BackgroundUploadService {
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
@@ -267,10 +266,6 @@ class ForegroundUploadService {
|
||||
try {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -303,11 +298,6 @@ class ForegroundUploadService {
|
||||
// Get files locally
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -316,17 +306,12 @@ class ForegroundUploadService {
|
||||
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
|
||||
if (livePhotoFile == null) {
|
||||
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
|
||||
_logger.warning("Failed to obtain file for asset ${asset.id} - ${asset.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||
@@ -28,8 +27,6 @@ class SyncStatusAndActions extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverVersion = ref.watch(serverInfoProvider.select((value) => value.serverVersion));
|
||||
|
||||
Future<void> exportDatabase() async {
|
||||
try {
|
||||
// WAL Checkpoint to ensure all changes are written to the database
|
||||
@@ -138,14 +135,6 @@ class SyncStatusAndActions extends HookConsumerWidget {
|
||||
ref.read(backgroundSyncProvider).syncRemote();
|
||||
},
|
||||
),
|
||||
if (CurrentPlatform.isIOS && serverVersion.isAtLeast(major: 2, minor: 5))
|
||||
SettingListTile(
|
||||
title: "Sync Cloud Ids".t(context: context),
|
||||
leading: const Icon(Icons.cloud_circle_rounded),
|
||||
subtitle: "tap_to_run_job".t(context: context),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).cloudIdSyncStatus),
|
||||
onTap: ref.read(backgroundSyncProvider).syncCloudIds,
|
||||
),
|
||||
SettingListTile(
|
||||
title: "hash_asset".t(context: context),
|
||||
leading: const Icon(Icons.tag),
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/node": "^24.10.8",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -63,7 +63,7 @@ importers:
|
||||
specifier: ^4.13.1
|
||||
version: 4.13.4
|
||||
'@types/node':
|
||||
specifier: ^24.10.9
|
||||
specifier: ^24.10.8
|
||||
version: 24.10.9
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.0.0
|
||||
@@ -220,7 +220,7 @@ importers:
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.1
|
||||
'@types/node':
|
||||
specifier: ^24.10.9
|
||||
specifier: ^24.10.8
|
||||
version: 24.10.9
|
||||
'@types/pg':
|
||||
specifier: ^8.15.1
|
||||
@@ -320,7 +320,7 @@ importers:
|
||||
version: 1.1.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.10.9
|
||||
specifier: ^24.10.8
|
||||
version: 24.10.9
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
@@ -639,7 +639,7 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^24.10.9
|
||||
specifier: ^24.10.8
|
||||
version: 24.10.9
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.0
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -34,8 +34,6 @@ export interface MoveRequest {
|
||||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
|
||||
let mediaLocation: string | undefined;
|
||||
@@ -112,7 +110,14 @@ export class StorageCore {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
|
||||
static getImagePath(
|
||||
asset: ThumbnailPathEntity,
|
||||
{
|
||||
fileType,
|
||||
format,
|
||||
isEdited,
|
||||
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
|
||||
) {
|
||||
return StorageCore.getNestedPath(
|
||||
StorageFolder.Thumbnails,
|
||||
asset.ownerId,
|
||||
|
||||
@@ -346,13 +346,6 @@ export const columns = {
|
||||
'asset.height',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||
assetFilesForThumbnail: [
|
||||
'asset_file.id',
|
||||
'asset_file.path',
|
||||
'asset_file.type',
|
||||
'asset_file.isEdited',
|
||||
'asset_file.isProgressive',
|
||||
],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
|
||||
@@ -165,13 +165,11 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" in ($1, $2, $3)
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
@@ -193,7 +191,7 @@ from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $4
|
||||
"asset"."id" = $1
|
||||
|
||||
-- AssetJobRepository.getForMetadataExtraction
|
||||
select
|
||||
|
||||
@@ -622,98 +622,3 @@ from
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
|
||||
-- AssetRepository.getForOcr
|
||||
select
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."orientation"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
-- AssetRepository.getForEdit
|
||||
select
|
||||
"asset"."type",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."originalPath",
|
||||
"asset"."originalFileName",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."orientation",
|
||||
"asset_exif"."projectionType"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
-- AssetRepository.getForMetadataExtractionTags
|
||||
select
|
||||
"asset_exif"."tags"
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
"asset_exif"."assetId" = $1
|
||||
|
||||
-- AssetRepository.getForFaces
|
||||
select
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."orientation",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
-- AssetRepository.getForUpdateTags
|
||||
select
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"tag"."value"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"asset"."id" = "tag_asset"."assetId"
|
||||
) as agg
|
||||
) as "tags"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Asset, columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
@@ -105,15 +104,7 @@ export class AssetJobRepository {
|
||||
'asset.thumbhash',
|
||||
'asset.type',
|
||||
])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select(columns.assetFilesForThumbnail)
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
|
||||
).as('files'),
|
||||
)
|
||||
.select(withFiles)
|
||||
.select(withEdits)
|
||||
.$call(withExifInner)
|
||||
.where('asset.id', '=', id)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { LockableProperty, Stack } from 'src/database';
|
||||
@@ -905,12 +904,11 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFile(
|
||||
file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>,
|
||||
): Promise<void> {
|
||||
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
|
||||
const value = { ...file, assetId: asUuid(file.assetId) };
|
||||
await this.db
|
||||
.insertInto('asset_file')
|
||||
.values(file)
|
||||
.values(value)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
@@ -920,19 +918,19 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
async upsertFiles(
|
||||
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[],
|
||||
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = files.map((row) => ({ ...row, assetId: asUuid(row.assetId) }));
|
||||
await this.db
|
||||
.insertInto('asset_file')
|
||||
.values(files)
|
||||
.values(values)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
isProgressive: eb.ref('excluded.isProgressive'),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
@@ -1054,68 +1052,4 @@ export class AssetRepository {
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForOcr(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', id)
|
||||
.select(withEdits)
|
||||
.innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id'))
|
||||
.select(['asset_exif.exifImageWidth', 'asset_exif.exifImageHeight', 'asset_exif.orientation'])
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForEdit(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.type', 'asset.livePhotoVideoId', 'asset.originalPath', 'asset.originalFileName'])
|
||||
.where('asset.id', '=', id)
|
||||
.innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id'))
|
||||
.select([
|
||||
'asset_exif.exifImageWidth',
|
||||
'asset_exif.exifImageHeight',
|
||||
'asset_exif.orientation',
|
||||
'asset_exif.projectionType',
|
||||
])
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForMetadataExtractionTags(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset_exif')
|
||||
.select('asset_exif.tags')
|
||||
.where('asset_exif.assetId', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForFaces(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id'))
|
||||
.select(['asset_exif.exifImageHeight', 'asset_exif.exifImageWidth', 'asset_exif.orientation'])
|
||||
.select(withEdits)
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForUpdateTags(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('tag')
|
||||
.select('tag.value')
|
||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
|
||||
.whereRef('asset.id', '=', 'tag_asset.assetId'),
|
||||
).as('tags'),
|
||||
)
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" ADD "isProgressive" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" DROP COLUMN "isProgressive";`.execute(db);
|
||||
}
|
||||
@@ -40,7 +40,4 @@ export class AssetFileTable {
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isEdited!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isProgressive!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -572,35 +572,6 @@ describe(AssetMediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return the unedited version if requested using a shared link', async () => {
|
||||
const editedAsset = {
|
||||
...assetStub.withCropEdit,
|
||||
files: [
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
isEdited: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({
|
||||
...editedAsset,
|
||||
editedPath: '/uploads/user-id/fullsize/edited.jpg',
|
||||
});
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.adminSharedLink, 'asset-id', { edited: false })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
fileName: 'asset-id.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should download original file when edited=false', async () => {
|
||||
const editedAsset = {
|
||||
...assetStub.withCropEdit,
|
||||
@@ -740,28 +711,6 @@ describe(AssetMediaService.name, () => {
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
});
|
||||
|
||||
it('should not return the unedited version if requested using a shared link', async () => {
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.adminSharedLink, assetStub.image.id, {
|
||||
size: AssetMediaSize.THUMBNAIL,
|
||||
edited: true,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
|
||||
@@ -196,10 +196,6 @@ export class AssetMediaService extends BaseService {
|
||||
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
|
||||
|
||||
if (auth.sharedLink) {
|
||||
dto.edited = true;
|
||||
}
|
||||
|
||||
const { originalPath, originalFileName, editedPath } = await this.assetRepository.getForOriginal(
|
||||
id,
|
||||
dto.edited ?? false,
|
||||
@@ -226,10 +222,6 @@ export class AssetMediaService extends BaseService {
|
||||
throw new BadRequestException('May not request original file');
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
dto.edited = true;
|
||||
}
|
||||
|
||||
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
|
||||
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
|
||||
id,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
@@ -705,7 +704,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]);
|
||||
mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...factory.exif() });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([ocr1, ocr2]);
|
||||
|
||||
@@ -720,7 +719,7 @@ describe(AssetService.name, () => {
|
||||
it('should return empty array when no OCR data exists', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
mocks.asset.getForOcr.mockResolvedValue({ edits: [factory.assetEdit()], ...factory.exif() });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith('asset-1');
|
||||
@@ -814,25 +813,4 @@ describe(AssetService.name, () => {
|
||||
expect(mocks.asset.upsertBulkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('editAsset', () => {
|
||||
it('should enforce crop first', async () => {
|
||||
await expect(
|
||||
sut.editAsset(authStub.admin, 'asset-1', {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Rotate,
|
||||
parameters: { angle: 90 },
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: { x: 0, y: 0, width: 100, height: 100 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.assetEdit.replaceAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
mapStats,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditAction, AssetEditActionCrop, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -401,19 +401,15 @@ export class AssetService extends BaseService {
|
||||
async getOcr(auth: AuthDto, id: string): Promise<AssetOcrResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||
const ocr = await this.ocrRepository.getByAssetId(id);
|
||||
const asset = await this.assetRepository.getForOcr(id);
|
||||
const asset = await this.assetRepository.getById(id, { exifInfo: true, edits: true });
|
||||
|
||||
if (!asset) {
|
||||
if (!asset || !asset.exifInfo || !asset.edits) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
|
||||
const dimensions = getDimensions({
|
||||
exifImageHeight: asset.exifImageHeight,
|
||||
exifImageWidth: asset.exifImageWidth,
|
||||
orientation: asset.orientation,
|
||||
});
|
||||
const dimensions = getDimensions(asset.exifInfo);
|
||||
|
||||
return ocr.map((item) => transformOcrBoundingBox(item, asset.edits, dimensions));
|
||||
return ocr.map((item) => transformOcrBoundingBox(item, asset.edits!, dimensions));
|
||||
}
|
||||
|
||||
async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise<AssetMetadataBulkResponseDto[]> {
|
||||
@@ -553,7 +549,7 @@ export class AssetService extends BaseService {
|
||||
async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise<AssetEditsDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getForEdit(id);
|
||||
const asset = await this.assetRepository.getById(id, { exifInfo: true });
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
@@ -579,26 +575,15 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
// check that crop parameters will not go out of bounds
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset);
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!);
|
||||
|
||||
if (!assetWidth || !assetHeight) {
|
||||
throw new BadRequestException('Asset dimensions are not available for editing');
|
||||
}
|
||||
|
||||
const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop);
|
||||
if (cropIndex > 0) {
|
||||
throw new BadRequestException('Crop action must be the first edit action');
|
||||
}
|
||||
const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop);
|
||||
const crop = dto.edits.find((e) => e.action === AssetEditAction.Crop)?.parameters;
|
||||
if (crop) {
|
||||
// check that crop parameters will not go out of bounds
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset);
|
||||
|
||||
if (!assetWidth || !assetHeight) {
|
||||
throw new BadRequestException('Asset dimensions are not available for editing');
|
||||
}
|
||||
|
||||
const { x, y, width, height } = crop.parameters;
|
||||
const { x, y, width, height } = crop;
|
||||
if (x + width > assetWidth || y + height > assetHeight) {
|
||||
throw new BadRequestException('Crop parameters are out of bounds');
|
||||
}
|
||||
|
||||
@@ -388,14 +388,12 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
|
||||
@@ -428,14 +426,12 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -467,14 +463,12 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -679,16 +673,6 @@ describe(MediaService.name, () => {
|
||||
}),
|
||||
expect.stringContaining('thumbnail.webp'),
|
||||
);
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate progressive JPEG for thumbnail when enabled', async () => {
|
||||
@@ -715,37 +699,6 @@ describe(MediaService.name, () => {
|
||||
}),
|
||||
expect.stringContaining('thumbnail.jpeg'),
|
||||
);
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should never set isProgressive for videos', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: true }, thumbnail: { progressive: true } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should delete previous thumbnail if different path', async () => {
|
||||
@@ -3400,38 +3353,14 @@ describe(MediaService.name, () => {
|
||||
files: [],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/new/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/thumbnail.jpg',
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
@@ -3447,7 +3376,6 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3455,43 +3383,18 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/new/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/thumbnail.jpg',
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3510,7 +3413,6 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3518,30 +3420,24 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, []);
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false },
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3560,7 +3456,6 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/same/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3568,26 +3463,13 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/same/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/same/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/same/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
@@ -3605,7 +3487,6 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3613,43 +3494,19 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
}, // replace
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/new/fullsize.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
}, // new
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // delete
|
||||
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/fullsize.jpg',
|
||||
type: AssetFileType.FullSize,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -3658,7 +3515,6 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3673,7 +3529,7 @@ describe(MediaService.name, () => {
|
||||
files: [],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, []);
|
||||
await sut['syncFiles'](asset, []);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
@@ -3690,79 +3546,15 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, []);
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: ['/old/preview.jpg'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should update database when isProgressive changes', async () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: true,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/old/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: true,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
@@ -45,13 +45,11 @@ import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { getOutputDimensions } from 'src/utils/transform';
|
||||
|
||||
interface UpsertFileOptions {
|
||||
assetId: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
isProgressive: boolean;
|
||||
}
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
@@ -173,22 +171,18 @@ export class MediaService extends BaseService {
|
||||
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
|
||||
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
|
||||
if (!asset) {
|
||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const generated = await this.generateEditedThumbnails(asset, config);
|
||||
await this.syncFiles(
|
||||
asset.files.filter((asset) => asset.isEdited),
|
||||
generated?.files ?? [],
|
||||
);
|
||||
const generated = await this.generateEditedThumbnails(asset);
|
||||
|
||||
let thumbhash: Buffer | undefined = generated?.thumbhash;
|
||||
if (!thumbhash) {
|
||||
const extractedImage = await this.extractOriginalImage(asset, config.image);
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const extractedImage = await this.extractOriginalImage(asset, image);
|
||||
const { info, data, colorspace } = extractedImage;
|
||||
|
||||
thumbhash = await this.mediaRepository.generateThumbhash(data, {
|
||||
@@ -212,7 +206,6 @@ export class MediaService extends BaseService {
|
||||
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
|
||||
async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
|
||||
if (!asset) {
|
||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
||||
@@ -224,25 +217,32 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
|
||||
let generated: {
|
||||
previewPath: string;
|
||||
thumbnailPath: string;
|
||||
fullsizePath?: string;
|
||||
thumbhash: Buffer;
|
||||
fullsizeDimensions?: ImageDimensions;
|
||||
};
|
||||
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateVideoThumbnails(asset, config);
|
||||
generated = await this.generateVideoThumbnails(asset);
|
||||
} else if (asset.type === AssetType.Image) {
|
||||
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateImageThumbnails(asset, config);
|
||||
generated = await this.generateImageThumbnails(asset);
|
||||
} else {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const editedGenerated = await this.generateEditedThumbnails(asset, config);
|
||||
if (editedGenerated) {
|
||||
generated.files.push(...editedGenerated.files);
|
||||
}
|
||||
await this.syncFiles(asset, [
|
||||
{ type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false },
|
||||
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false },
|
||||
]);
|
||||
|
||||
await this.syncFiles(asset.files, generated.files);
|
||||
const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
|
||||
const editiedGenerated = await this.generateEditedThumbnails(asset);
|
||||
const thumbhash = editiedGenerated?.thumbhash || generated.thumbhash;
|
||||
|
||||
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
|
||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
||||
@@ -274,7 +274,11 @@ export class MediaService extends BaseService {
|
||||
return { info, data, colorspace };
|
||||
}
|
||||
|
||||
private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) {
|
||||
private async extractOriginalImage(
|
||||
asset: NonNullable<ThumbnailAsset>,
|
||||
image: SystemConfig['image'],
|
||||
useEdits = false,
|
||||
) {
|
||||
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
||||
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
||||
const generateFullsize =
|
||||
@@ -301,21 +305,19 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) {
|
||||
const previewFile = this.getImageFile(asset, {
|
||||
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) {
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
isEdited: useEdits,
|
||||
format: image.preview.format,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp,
|
||||
});
|
||||
previewFile.isProgressive = !!image.preview.progressive && image.preview.format !== ImageFormat.Webp;
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp,
|
||||
format: image.thumbnail.format,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewFile.path);
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
// Handle embedded preview extraction for RAW files
|
||||
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
|
||||
@@ -325,18 +327,26 @@ export class MediaService extends BaseService {
|
||||
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
|
||||
const promises = [
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path),
|
||||
this.mediaRepository.generateThumbnail(
|
||||
data,
|
||||
{ ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
|
||||
thumbnailPath,
|
||||
),
|
||||
this.mediaRepository.generateThumbnail(
|
||||
data,
|
||||
{ ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
|
||||
previewPath,
|
||||
),
|
||||
];
|
||||
|
||||
let fullsizeFile: UpsertFileOptions | undefined;
|
||||
let fullsizePath: string | undefined;
|
||||
|
||||
if (convertFullsize) {
|
||||
// convert a new fullsize image from the same source as the thumbnail
|
||||
fullsizeFile = this.getImageFile(asset, {
|
||||
fullsizePath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
format: image.fullsize.format,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||
format: image.fullsize.format,
|
||||
});
|
||||
const fullsizeOptions = {
|
||||
format: image.fullsize.format,
|
||||
@@ -344,25 +354,23 @@ export class MediaService extends BaseService {
|
||||
progressive: image.fullsize.progressive,
|
||||
...thumbnailOptions,
|
||||
};
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path));
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
||||
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||
fullsizeFile = this.getImageFile(asset, {
|
||||
fullsizePath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
format: extracted.format,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||
isEdited: false,
|
||||
});
|
||||
fullsizeFile.isProgressive = !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp;
|
||||
this.storageCore.ensureFolders(fullsizeFile.path);
|
||||
this.storageCore.ensureFolders(fullsizePath);
|
||||
|
||||
// Write the buffer to disk with essential EXIF data
|
||||
await this.storageRepository.createOrOverwriteFile(fullsizeFile.path, extracted.buffer);
|
||||
await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer);
|
||||
await this.mediaRepository.writeExif(
|
||||
{
|
||||
orientation: asset.exifInfo.orientation,
|
||||
colorspace: asset.exifInfo.colorspace,
|
||||
},
|
||||
fullsizeFile.path,
|
||||
fullsizePath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,9 +378,9 @@ export class MediaService extends BaseService {
|
||||
|
||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
||||
const promises = [
|
||||
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path),
|
||||
fullsizeFile
|
||||
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path)
|
||||
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath),
|
||||
fullsizePath
|
||||
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath)
|
||||
: Promise.resolve(),
|
||||
];
|
||||
await Promise.all(promises);
|
||||
@@ -381,11 +389,7 @@ export class MediaService extends BaseService {
|
||||
const decodedDimensions = { width: info.width, height: info.height };
|
||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||
|
||||
return {
|
||||
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
|
||||
thumbhash: outputs[0] as Buffer,
|
||||
fullsizeDimensions,
|
||||
};
|
||||
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer, fullsizeDimensions };
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||
@@ -489,23 +493,19 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateVideoThumbnails(
|
||||
asset: ThumbnailPathEntity & { originalPath: string },
|
||||
{ ffmpeg, image }: SystemConfig,
|
||||
) {
|
||||
const previewFile = this.getImageFile(asset, {
|
||||
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
|
||||
const { image, ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
format: image.preview.format,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
});
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewFile.path);
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
@@ -524,16 +524,17 @@ export class MediaService extends BaseService {
|
||||
format,
|
||||
);
|
||||
|
||||
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
|
||||
await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions);
|
||||
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
|
||||
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
|
||||
|
||||
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
|
||||
const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, {
|
||||
colorspace: image.colorspace,
|
||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||
});
|
||||
|
||||
return {
|
||||
files: [previewFile, thumbnailFile],
|
||||
previewPath,
|
||||
thumbnailPath,
|
||||
thumbhash,
|
||||
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
|
||||
};
|
||||
@@ -790,41 +791,43 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) {
|
||||
private async syncFiles(
|
||||
asset: { id: string; files: AssetFile[] },
|
||||
files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
|
||||
) {
|
||||
const toUpsert: UpsertFileOptions[] = [];
|
||||
const pathsToDelete: string[] = [];
|
||||
const toDelete = new Set(oldFiles);
|
||||
const toDelete: AssetFile[] = [];
|
||||
|
||||
for (const newFile of newFiles) {
|
||||
const existingFile = oldFiles.find((file) => file.type === newFile.type && file.isEdited === newFile.isEdited);
|
||||
if (existingFile) {
|
||||
toDelete.delete(existingFile);
|
||||
}
|
||||
for (const { type, newPath, isEdited } of files) {
|
||||
const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
|
||||
|
||||
// upsert new file path
|
||||
if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) {
|
||||
toUpsert.push(newFile);
|
||||
if (newPath && existingFile?.path !== newPath) {
|
||||
toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
|
||||
|
||||
// delete old file from disk
|
||||
if (existingFile && existingFile.path !== newFile.path) {
|
||||
this.logger.debug(
|
||||
`Deleting old ${newFile.type} image for asset ${newFile.assetId} in favor of a replacement`,
|
||||
);
|
||||
if (existingFile) {
|
||||
this.logger.debug(`Deleting old ${type} image for asset ${asset.id} in favor of a replacement`);
|
||||
pathsToDelete.push(existingFile.path);
|
||||
}
|
||||
}
|
||||
|
||||
// delete old file from disk and database
|
||||
if (!newPath && existingFile) {
|
||||
this.logger.debug(`Deleting old ${type} image for asset ${asset.id}`);
|
||||
|
||||
pathsToDelete.push(existingFile.path);
|
||||
toDelete.push(existingFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (toUpsert.length > 0) {
|
||||
await this.assetRepository.upsertFiles(toUpsert);
|
||||
}
|
||||
|
||||
if (toDelete.size > 0) {
|
||||
const toDeleteArray = [...toDelete];
|
||||
for (const file of toDeleteArray) {
|
||||
pathsToDelete.push(file.path);
|
||||
}
|
||||
await this.assetRepository.deleteFiles(toDeleteArray);
|
||||
if (toDelete.length > 0) {
|
||||
await this.assetRepository.deleteFiles(toDelete);
|
||||
}
|
||||
|
||||
if (pathsToDelete.length > 0) {
|
||||
@@ -832,12 +835,18 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
|
||||
private async generateEditedThumbnails(asset: ThumbnailAsset) {
|
||||
if (asset.type !== AssetType.Image) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
|
||||
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined;
|
||||
|
||||
await this.syncFiles(asset, [
|
||||
{ type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true },
|
||||
{ type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true },
|
||||
{ type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true },
|
||||
]);
|
||||
|
||||
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
|
||||
const cropBox = crop
|
||||
@@ -861,15 +870,4 @@ export class MediaService extends BaseService {
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) {
|
||||
const path = StorageCore.getImagePath(asset, options);
|
||||
return {
|
||||
assetId: asset.id,
|
||||
type: options.fileType,
|
||||
path,
|
||||
isEdited: options.isEdited,
|
||||
isProgressive: options.isProgressive,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -398,7 +398,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] });
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -419,7 +419,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -430,7 +430,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -441,7 +441,10 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
|
||||
});
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -453,7 +456,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] });
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -473,7 +476,10 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Child'] });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }),
|
||||
});
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -493,7 +499,10 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'TagA'] });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }),
|
||||
});
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -515,7 +524,10 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
|
||||
});
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -527,7 +539,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Mom|Dad'] });
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) });
|
||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
|
||||
@@ -542,7 +554,10 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Parent2/Child2'] });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }),
|
||||
});
|
||||
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
|
||||
@@ -566,10 +566,10 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
private async applyTagList({ id, ownerId }: { id: string; ownerId: string }) {
|
||||
const asset = await this.assetRepository.getForMetadataExtractionTags(id);
|
||||
const asset = await this.assetRepository.getById(id, { exifInfo: true });
|
||||
const results = await upsertTags(this.tagRepository, {
|
||||
userId: ownerId,
|
||||
tags: asset?.tags ?? [],
|
||||
tags: asset?.exifInfo?.tags ?? [],
|
||||
});
|
||||
await this.tagRepository.replaceAssetTags(
|
||||
id,
|
||||
|
||||
@@ -354,7 +354,7 @@ describe(PersonService.name, () => {
|
||||
it('should get the bounding boxes for an asset', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId]));
|
||||
mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]);
|
||||
mocks.asset.getForFaces.mockResolvedValue({ edits: [], ...factory.exif() });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([
|
||||
mapFaces(faceStub.primaryFace1, authStub.admin),
|
||||
]);
|
||||
|
||||
@@ -127,10 +127,10 @@ 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);
|
||||
const asset = await this.assetRepository.getForFaces(dto.id);
|
||||
const assetDimensions = getDimensions(asset);
|
||||
const asset = await this.assetRepository.getById(dto.id, { edits: true, exifInfo: true });
|
||||
const assetDimensions = getDimensions(asset!.exifInfo!);
|
||||
|
||||
return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
|
||||
return faces.map((face) => mapFaces(face, auth, asset!.edits!, assetDimensions));
|
||||
}
|
||||
|
||||
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JobStatus } from 'src/enum';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(TagService.name, () => {
|
||||
@@ -191,7 +192,10 @@ describe(TagService.name, () => {
|
||||
it('should upsert records', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
tags: [factory.tag({ value: 'tag-1' }), factory.tag({ value: 'tag-2' })],
|
||||
});
|
||||
mocks.tag.upsertAssetIds.mockResolvedValue([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
@@ -242,7 +246,10 @@ describe(TagService.name, () => {
|
||||
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.tag.addAssetIds.mockResolvedValue();
|
||||
mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [{ value: 'tag-1' }] });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
tags: [factory.tag({ value: 'tag-1' })],
|
||||
});
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
||||
|
||||
await expect(
|
||||
@@ -271,7 +278,6 @@ describe(TagService.name, () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set());
|
||||
mocks.tag.removeAssetIds.mockResolvedValue();
|
||||
mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [] });
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ id: 'asset-1', success: false, error: 'not_found' },
|
||||
@@ -282,7 +288,6 @@ describe(TagService.name, () => {
|
||||
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.tag.removeAssetIds.mockResolvedValue();
|
||||
mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [] });
|
||||
|
||||
await expect(
|
||||
sut.removeAssets(authStub.admin, 'tag-1', {
|
||||
|
||||
@@ -151,9 +151,10 @@ export class TagService extends BaseService {
|
||||
}
|
||||
|
||||
private async updateTags(assetId: string) {
|
||||
const { tags } = await this.assetRepository.getForUpdateTags(assetId);
|
||||
await this.assetRepository.upsertExif(updateLockedColumns({ assetId, tags: tags.map(({ value }) => value) }), {
|
||||
lockedPropertiesBehavior: 'append',
|
||||
});
|
||||
const asset = await this.assetRepository.getById(assetId, { tags: true });
|
||||
await this.assetRepository.upsertExif(
|
||||
updateLockedColumns({ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }),
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { AssetFile, Exif } 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 { ExifResponseDto } from 'src/dtos/exif.dto';
|
||||
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
@@ -209,26 +210,20 @@ const isFlipped = (orientation?: string | null) => {
|
||||
return value && [5, 6, 7, 8, -90, 90].includes(value);
|
||||
};
|
||||
|
||||
export const getDimensions = ({
|
||||
exifImageHeight: height,
|
||||
exifImageWidth: width,
|
||||
orientation,
|
||||
}: {
|
||||
exifImageHeight: number | null;
|
||||
exifImageWidth: number | null;
|
||||
orientation: string | null;
|
||||
}) => {
|
||||
export const getDimensions = (exifInfo: ExifResponseDto | Exif) => {
|
||||
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
|
||||
|
||||
if (!width || !height) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
if (isFlipped(orientation)) {
|
||||
if (isFlipped(exifInfo.orientation)) {
|
||||
return { width: height, height: width };
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const isPanorama = (asset: { projectionType: string | null; originalFileName: string }) => {
|
||||
return asset.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp');
|
||||
export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => {
|
||||
return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp');
|
||||
};
|
||||
|
||||
19
server/test/fixtures/asset.stub.ts
vendored
19
server/test/fixtures/asset.stub.ts
vendored
@@ -48,9 +48,9 @@ const editedFullsizeFile = factory.assetFile({
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const files = [fullsizeFile, previewFile, thumbnailFile];
|
||||
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
||||
|
||||
const editedFiles = [
|
||||
const editedFiles: AssetFile[] = [
|
||||
fullsizeFile,
|
||||
previewFile,
|
||||
thumbnailFile,
|
||||
@@ -624,19 +624,14 @@ export const assetStub = {
|
||||
fileSizeInByte: 100_000,
|
||||
timeZone: `America/New_York`,
|
||||
},
|
||||
files: [],
|
||||
files: [] as AssetFile[],
|
||||
libraryId: null,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [] as AssetEditActionItem[],
|
||||
isEdited: false,
|
||||
} as unknown as MapAsset & {
|
||||
faces: AssetFace[];
|
||||
files: (AssetFile & { isProgressive: boolean })[];
|
||||
exifInfo: Exif;
|
||||
edits: AssetEditActionItem[];
|
||||
}),
|
||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
|
||||
|
||||
livePhotoStillAsset: Object.freeze({
|
||||
id: 'live-photo-still-asset',
|
||||
@@ -658,11 +653,7 @@ export const assetStub = {
|
||||
height: null,
|
||||
edits: [] as AssetEditActionItem[],
|
||||
isEdited: false,
|
||||
} as unknown as MapAsset & {
|
||||
faces: AssetFace[];
|
||||
files: (AssetFile & { isProgressive: boolean })[];
|
||||
edits: AssetEditActionItem[];
|
||||
}),
|
||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
|
||||
|
||||
livePhotoWithOriginalFileName: Object.freeze({
|
||||
id: 'live-photo-still-asset',
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StackRepository } from 'src/repositories/stack.repository';
|
||||
@@ -28,7 +25,6 @@ const setup = (db?: Kysely<DB>) => {
|
||||
database: db || defaultDatabase,
|
||||
real: [
|
||||
AssetRepository,
|
||||
AssetEditRepository,
|
||||
AssetJobRepository,
|
||||
AlbumRepository,
|
||||
AccessRepository,
|
||||
@@ -36,7 +32,7 @@ const setup = (db?: Kysely<DB>) => {
|
||||
StackRepository,
|
||||
UserRepository,
|
||||
],
|
||||
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository, OcrRepository],
|
||||
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -435,57 +431,6 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOcr', () => {
|
||||
it('should require access', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id });
|
||||
|
||||
await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access');
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
|
||||
ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]);
|
||||
|
||||
await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([
|
||||
expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply rotation', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
|
||||
await ctx.database
|
||||
.insertInto('asset_edit')
|
||||
.values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 })
|
||||
.execute();
|
||||
ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]);
|
||||
|
||||
await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
x1: 0.6,
|
||||
x2: 0.8,
|
||||
x3: 0.8,
|
||||
x4: 0.6,
|
||||
y1: expect.any(Number),
|
||||
y2: expect.any(Number),
|
||||
y3: 0.3,
|
||||
y4: 0.3,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertBulkMetadata', () => {
|
||||
it('should work', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
@@ -658,38 +603,4 @@ describe(AssetService.name, () => {
|
||||
expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editAsset', () => {
|
||||
it('should require access', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id });
|
||||
|
||||
await expect(
|
||||
sut.editAsset(auth, asset.id, { edits: [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }] }),
|
||||
).rejects.toThrow('Not found or no asset.edit.create access');
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
|
||||
|
||||
const editAction = { action: AssetEditAction.Rotate, parameters: { angle: 90 } } as const;
|
||||
await expect(sut.editAsset(auth, asset.id, { edits: [editAction] })).resolves.toEqual({
|
||||
assetId: asset.id,
|
||||
edits: [editAction],
|
||||
});
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual(
|
||||
expect.objectContaining({ isEdited: true }),
|
||||
);
|
||||
await expect(ctx.get(AssetEditRepository).getAll(asset.id)).resolves.toEqual([editAction]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,10 +53,5 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
getForOriginal: vitest.fn(),
|
||||
getForThumbnail: vitest.fn(),
|
||||
getForVideo: vitest.fn(),
|
||||
getForEdit: vitest.fn(),
|
||||
getForOcr: vitest.fn(),
|
||||
getForMetadataExtractionTags: vitest.fn(),
|
||||
getForFaces: vitest.fn(),
|
||||
getForUpdateTags: vitest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -400,12 +400,11 @@ const assetOcrFactory = (
|
||||
...ocr,
|
||||
});
|
||||
|
||||
const assetFileFactory = (file: Partial<AssetFile> = {}) => ({
|
||||
const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
|
||||
id: newUuid(),
|
||||
type: AssetFileType.Preview,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
...file,
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
const axisOptions: Axis = {
|
||||
stroke: () => (isDark ? '#ccc' : 'black'),
|
||||
ticks: {
|
||||
show: false,
|
||||
show: true,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
grid: {
|
||||
@@ -116,8 +116,6 @@
|
||||
axes: [
|
||||
{
|
||||
...axisOptions,
|
||||
size: 40,
|
||||
ticks: { show: true },
|
||||
values: (plot, values) => {
|
||||
return values.map((value) => {
|
||||
if (!value) {
|
||||
@@ -127,10 +125,7 @@
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
...axisOptions,
|
||||
size: 60,
|
||||
},
|
||||
axisOptions,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
mdiCrosshairsGps,
|
||||
mdiImageSizeSelectLarge,
|
||||
mdiLinkEdit,
|
||||
mdiStateMachine,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -16,7 +17,7 @@
|
||||
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
||||
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
||||
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
||||
// { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
|
||||
{ href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
|
||||
@@ -36,13 +35,6 @@ class AssetCacheManager {
|
||||
#assetCache = new AsyncCache<AssetResponseDto>();
|
||||
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AssetEditsApplied', () => {
|
||||
this.#assetCache.clear();
|
||||
this.#ocrCache.clear();
|
||||
});
|
||||
}
|
||||
|
||||
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) {
|
||||
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { waitForWebsocketEvent } from '$lib/stores/websocket';
|
||||
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -111,29 +110,25 @@ export class EditManager {
|
||||
this.isApplyingEdits = true;
|
||||
|
||||
const edits = this.tools.flatMap((tool) => tool.manager.edits);
|
||||
if (!this.currentAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const assetId = this.currentAsset.id;
|
||||
|
||||
try {
|
||||
// Setup the websocket listener before sending the edit request
|
||||
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
|
||||
const editCompleted = waitForWebsocketEvent(
|
||||
'AssetEditReadyV1',
|
||||
(event) => event.asset.id === this.currentAsset!.id,
|
||||
10_000,
|
||||
);
|
||||
|
||||
await (edits.length === 0
|
||||
? removeAssetEdits({ id: assetId })
|
||||
? removeAssetEdits({ id: this.currentAsset!.id })
|
||||
: editAsset({
|
||||
id: assetId,
|
||||
id: this.currentAsset!.id,
|
||||
assetEditActionListDto: {
|
||||
edits,
|
||||
},
|
||||
}));
|
||||
|
||||
await editCompleted;
|
||||
|
||||
eventManager.emit('AssetEditsApplied', assetId);
|
||||
|
||||
toastManager.success('Edits applied successfully');
|
||||
this.hasAppliedEdits = true;
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export type Events = {
|
||||
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsDelete: [string[]];
|
||||
AssetEditsApplied: [string];
|
||||
|
||||
AlbumAddAssets: [];
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { asUrl } from '$lib/services/shared-link.service';
|
||||
import type { ServerConfigDto } from '@immich/sdk';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
|
||||
describe('SharedLinkService', () => {
|
||||
beforeAll(() => {
|
||||
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
|
||||
serverConfigManager: {
|
||||
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
|
||||
init: vi.fn(),
|
||||
loadServerConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('asUrl', () => {
|
||||
it('should properly encode characters in slug', () => {
|
||||
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,10 +60,8 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
|
||||
return { Edit, Delete, Copy, ViewQrCode };
|
||||
};
|
||||
|
||||
export const asUrl = (sharedLink: SharedLinkResponseDto) => {
|
||||
const path = sharedLink.slug
|
||||
? `s/${encodeURIComponent(sharedLink.slug)}`
|
||||
: `share/${encodeURIComponent(sharedLink.key)}`;
|
||||
const asUrl = (sharedLink: SharedLinkResponseDto) => {
|
||||
const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`;
|
||||
return new URL(path, serverConfigManager.value.externalDomain || globalThis.location.origin).href;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getPlugins, getWorkflows } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
|
||||
const isReady = false;
|
||||
if (!isReady) {
|
||||
redirect(307, '/utilities');
|
||||
}
|
||||
|
||||
const [workflows, plugins] = await Promise.all([getWorkflows(), getPlugins()]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user