mirror of
https://github.com/immich-app/immich.git
synced 2026-01-21 17:13:17 -08:00
Compare commits
1 Commits
fix/map-pe
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f90d38f93a |
@@ -8,7 +8,7 @@ dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
||||
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
workers: 1,
|
||||
},
|
||||
// {
|
||||
// name: 'parallel tests',
|
||||
// use: { ...devices['Desktop Chrome'] },
|
||||
// testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
// fullyParallel: true,
|
||||
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
// },
|
||||
{
|
||||
name: 'parallel tests',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils';
|
||||
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
|
||||
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
@@ -49,6 +49,7 @@ test.describe('asset-viewer', () => {
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
|
||||
@@ -18,6 +18,7 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
|
||||
import { utils } from 'src/utils';
|
||||
import {
|
||||
assetViewerUtils,
|
||||
cancelAllPollers,
|
||||
padYearMonth,
|
||||
pageUtils,
|
||||
poll,
|
||||
@@ -63,6 +64,7 @@ test.describe('Timeline', () => {
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
|
||||
@@ -23,6 +23,13 @@ export async function throttlePage(context: BrowserContext, page: Page) {
|
||||
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
||||
}
|
||||
|
||||
let activePollsAbortController = new AbortController();
|
||||
|
||||
export const cancelAllPollers = () => {
|
||||
activePollsAbortController.abort();
|
||||
activePollsAbortController = new AbortController();
|
||||
};
|
||||
|
||||
export const poll = async <T>(
|
||||
page: Page,
|
||||
query: () => Promise<T>,
|
||||
@@ -30,14 +37,21 @@ export const poll = async <T>(
|
||||
) => {
|
||||
let result;
|
||||
const timeout = Date.now() + 10_000;
|
||||
const signal = activePollsAbortController.signal;
|
||||
|
||||
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
||||
while (!terminate(result) && Date.now() < timeout) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
result = await query();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
|
||||
|
||||
@@ -116,4 +117,12 @@ class AssetService {
|
||||
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
|
||||
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
|
||||
}
|
||||
|
||||
Future<AssetEditsDto?> getAssetEdits(String assetId) {
|
||||
return _remoteAssetRepository.getAssetEdits(assetId);
|
||||
}
|
||||
|
||||
Future<AssetEditsDto?> editAsset(String assetId, AssetEditActionListDto edits) {
|
||||
return _remoteAssetRepository.editAsset(assetId, edits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
final AssetsApi _api;
|
||||
|
||||
const RemoteAssetRepository(this._db) : super(_db);
|
||||
const RemoteAssetRepository(this._db, this._api) : super(_db);
|
||||
|
||||
/// For testing purposes
|
||||
Future<List<RemoteAsset>> getSome(String userId) {
|
||||
@@ -258,4 +260,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
Future<int> getCount() {
|
||||
return _db.managers.remoteAssetEntity.count();
|
||||
}
|
||||
|
||||
Future<AssetEditsDto?> getAssetEdits(String assetId) async {
|
||||
return _api.getAssetEdits(assetId);
|
||||
}
|
||||
|
||||
Future<AssetEditsDto?> editAsset(String assetId, AssetEditActionListDto edits) {
|
||||
return _api.editAsset(assetId, edits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -50,7 +49,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
|
||||
if (accessToken != null && serverUrl != null && endpoint != null) {
|
||||
final infoProvider = ref.read(serverInfoProvider.notifier);
|
||||
final wsProvider = ref.read(websocketProvider.notifier);
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
final backupProvider = ref.read(driftBackupProvider.notifier);
|
||||
@@ -60,7 +58,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
(_) async {
|
||||
try {
|
||||
wsProvider.connect();
|
||||
unawaited(infoProvider.getServerInfo());
|
||||
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
bool syncSuccess = false;
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
@@ -91,6 +92,8 @@ class DriftEditImagePage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final edits = ref.read(assetServiceProvider).getAssetEdits(asset.remoteId!);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("edit".tr()),
|
||||
@@ -139,6 +142,12 @@ class DriftEditImagePage extends ConsumerWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
FutureBuilder(
|
||||
future: edits,
|
||||
builder: (ctx, data) {
|
||||
return Text(data.hasData ? data.data?.edits.length.toString() ?? "" : "...");
|
||||
},
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
@@ -48,7 +47,6 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
Widget build(BuildContext context) {
|
||||
final asset = widget.asset;
|
||||
final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
final isCurrentAsset = ref.watch(assetViewerProvider.select((current) => current.currentAsset == asset));
|
||||
|
||||
final assetContainerColor = context.isDarkTheme
|
||||
? context.primaryColor.darken(amount: 0.4)
|
||||
@@ -61,10 +59,6 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
final bool storageIndicator =
|
||||
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator;
|
||||
|
||||
if (!isCurrentAsset) {
|
||||
_hideIndicators = false;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
_showSelectionContainer = true;
|
||||
}
|
||||
@@ -102,11 +96,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Hero(
|
||||
// This key resets the hero animation when the asset is changed in the asset viewer.
|
||||
// It doesn't seem like the best solution, and only works to reset the hero, not prime the hero of the new active asset for animation,
|
||||
// but other solutions have failed thus far.
|
||||
key: ValueKey(isCurrentAsset),
|
||||
tag: '${asset?.heroTag}_$heroIndex',
|
||||
tag: '${asset?.heroTag ?? ''}_$heroIndex',
|
||||
child: Thumbnail.fromAsset(asset: asset, size: widget.size),
|
||||
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
|
||||
placeholderBuilder: (context, heroSize, child) {
|
||||
|
||||
@@ -160,7 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_resumeBackup();
|
||||
}),
|
||||
_resumeBackup(),
|
||||
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
backgroundManager.syncCloudIds(),
|
||||
]);
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@@ -11,7 +12,7 @@ final localAssetRepository = Provider<DriftLocalAssetRepository>(
|
||||
);
|
||||
|
||||
final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider), ref.watch(apiServiceProvider).assetsApi),
|
||||
);
|
||||
|
||||
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
|
||||
@@ -307,10 +307,10 @@ class BackgroundUploadService {
|
||||
priority: priority,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: requiresWiFi,
|
||||
cloudId: entity.isLivePhoto ? null : asset.cloudId,
|
||||
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
|
||||
longitude: entity.isLivePhoto ? null : asset.longitude?.toString(),
|
||||
cloudId: asset.cloudId,
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
@@ -41,7 +40,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(connectivityApiProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,7 +55,6 @@ class ForegroundUploadService {
|
||||
this._backupRepository,
|
||||
this._connectivityApi,
|
||||
this._appSettingsService,
|
||||
this._assetMediaRepository,
|
||||
);
|
||||
|
||||
final UploadRepository _uploadRepository;
|
||||
@@ -65,7 +62,6 @@ class ForegroundUploadService {
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final ConnectivityApi _connectivityApi;
|
||||
final AppSettingsService _appSettingsService;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final Logger _logger = Logger('ForegroundUploadService');
|
||||
|
||||
bool shouldAbortUpload = false;
|
||||
@@ -315,8 +311,7 @@ class ForegroundUploadService {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name;
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
@@ -327,6 +322,19 @@ class ForegroundUploadService {
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': asset.duration.toString(),
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null)
|
||||
'metadata': jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
// Upload live photo video first if available
|
||||
@@ -355,22 +363,6 @@ class ForegroundUploadService {
|
||||
fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||
fields['metadata'] = jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: file,
|
||||
originalFileName: originalFileName,
|
||||
|
||||
@@ -19,7 +19,7 @@ class AssetEditActionListDtoEditsInner {
|
||||
|
||||
AssetEditAction action;
|
||||
|
||||
MirrorParameters parameters;
|
||||
Map<String, dynamic> parameters;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner &&
|
||||
@@ -52,7 +52,7 @@ class AssetEditActionListDtoEditsInner {
|
||||
|
||||
return AssetEditActionListDtoEditsInner(
|
||||
action: AssetEditAction.fromJson(json[r'action'])!,
|
||||
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
|
||||
parameters: json[r'parameters'],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
6
mobile/openapi/lib/model/permission.dart
generated
6
mobile/openapi/lib/model/permission.dart
generated
@@ -82,8 +82,6 @@ class Permission {
|
||||
static const timelinePeriodRead = Permission._(r'timeline.read');
|
||||
static const timelinePeriodDownload = Permission._(r'timeline.download');
|
||||
static const maintenance = Permission._(r'maintenance');
|
||||
static const mapPeriodGeocode = Permission._(r'map.geocode');
|
||||
static const mapPeriodRead = Permission._(r'map.read');
|
||||
static const memoryPeriodCreate = Permission._(r'memory.create');
|
||||
static const memoryPeriodRead = Permission._(r'memory.read');
|
||||
static const memoryPeriodUpdate = Permission._(r'memory.update');
|
||||
@@ -240,8 +238,6 @@ class Permission {
|
||||
timelinePeriodRead,
|
||||
timelinePeriodDownload,
|
||||
maintenance,
|
||||
mapPeriodGeocode,
|
||||
mapPeriodRead,
|
||||
memoryPeriodCreate,
|
||||
memoryPeriodRead,
|
||||
memoryPeriodUpdate,
|
||||
@@ -433,8 +429,6 @@ class PermissionTypeTransformer {
|
||||
case r'timeline.read': return Permission.timelinePeriodRead;
|
||||
case r'timeline.download': return Permission.timelinePeriodDownload;
|
||||
case r'maintenance': return Permission.maintenance;
|
||||
case r'map.geocode': return Permission.mapPeriodGeocode;
|
||||
case r'map.read': return Permission.mapPeriodRead;
|
||||
case r'memory.create': return Permission.memoryPeriodCreate;
|
||||
case r'memory.read': return Permission.memoryPeriodRead;
|
||||
case r'memory.update': return Permission.memoryPeriodUpdate;
|
||||
|
||||
@@ -21,6 +21,7 @@ function dart {
|
||||
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch
|
||||
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api.dart <./patch/api.dart.patch
|
||||
patch --no-backup-if-mismatch -u ../mobile/openapi/pubspec.yaml <./patch/pubspec_immich_mobile.yaml.patch
|
||||
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart <./patch/asset_edit_action_list_dto_edits_inner.dart.patch
|
||||
# Don't include analysis_options.yaml for the generated openapi files
|
||||
# so that language servers can properly exclude the mobile/openapi directory
|
||||
rm ../mobile/openapi/analysis_options.yaml
|
||||
|
||||
@@ -6305,7 +6305,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "map.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -6377,7 +6376,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "map.geocode",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -18968,8 +18966,6 @@
|
||||
"timeline.read",
|
||||
"timeline.download",
|
||||
"maintenance",
|
||||
"map.geocode",
|
||||
"map.read",
|
||||
"memory.create",
|
||||
"memory.read",
|
||||
"memory.update",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
--- /tmp/asset_edit_orig.dart 2026-01-20 10:38:05
|
||||
+++ /tmp/asset_edit_final.dart 2026-01-20 10:40:33
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
AssetEditAction action;
|
||||
|
||||
- MirrorParameters parameters;
|
||||
+ Map<String, dynamic> parameters;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner &&
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
return AssetEditActionListDtoEditsInner(
|
||||
action: AssetEditAction.fromJson(json[r'action'])!,
|
||||
- parameters: MirrorParameters.fromJson(json[r'parameters'])!,
|
||||
+ parameters: json[r'parameters'],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -5534,8 +5534,6 @@ export enum Permission {
|
||||
TimelineRead = "timeline.read",
|
||||
TimelineDownload = "timeline.download",
|
||||
Maintenance = "maintenance",
|
||||
MapGeocode = "map.geocode",
|
||||
MapRead = "map.read",
|
||||
MemoryCreate = "memory.create",
|
||||
MemoryRead = "memory.read",
|
||||
MemoryUpdate = "memory.update",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MapReverseGeocodeDto,
|
||||
MapReverseGeocodeResponseDto,
|
||||
} from 'src/dtos/map.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { ApiTag } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { MapService } from 'src/services/map.service';
|
||||
|
||||
@@ -18,7 +18,7 @@ export class MapController {
|
||||
constructor(private service: MapService) {}
|
||||
|
||||
@Get('markers')
|
||||
@Authenticated({ permission: Permission.MapRead })
|
||||
@Authenticated()
|
||||
@Endpoint({
|
||||
summary: 'Retrieve map markers',
|
||||
description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.',
|
||||
@@ -28,8 +28,8 @@ export class MapController {
|
||||
return this.service.getMapMarkers(auth, options);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Get('reverse-geocode')
|
||||
@Authenticated({ permission: Permission.MapGeocode })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Endpoint({
|
||||
summary: 'Reverse geocode coordinates',
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { StorageAsset } from 'src/database';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
ImageFormat,
|
||||
PathType,
|
||||
PersonPathType,
|
||||
RawExtractedFormat,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
@@ -32,6 +24,15 @@ export interface MoveRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export type GeneratedImageType =
|
||||
| AssetPathType.Preview
|
||||
| AssetPathType.Thumbnail
|
||||
| AssetPathType.FullSize
|
||||
| AssetPathType.EditedPreview
|
||||
| AssetPathType.EditedThumbnail
|
||||
| AssetPathType.EditedFullSize;
|
||||
export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo;
|
||||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
@@ -110,19 +111,8 @@ export class StorageCore {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getImagePath(
|
||||
asset: ThumbnailPathEntity,
|
||||
{
|
||||
fileType,
|
||||
format,
|
||||
isEdited,
|
||||
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
|
||||
) {
|
||||
return StorageCore.getNestedPath(
|
||||
StorageFolder.Thumbnails,
|
||||
asset.ownerId,
|
||||
`${asset.id}_${fileType}${isEdited ? '_edited' : ''}.${format}`,
|
||||
);
|
||||
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
@@ -147,14 +137,14 @@ export class StorageCore {
|
||||
return normalizedPath.startsWith(normalizedAppMediaLocation);
|
||||
}
|
||||
|
||||
async moveAssetImage(asset: StorageAsset, fileType: AssetFileType, format: ImageFormat) {
|
||||
async moveAssetImage(asset: StorageAsset, pathType: GeneratedImageType, format: ImageFormat) {
|
||||
const { id: entityId, files } = asset;
|
||||
const oldFile = getAssetFile(files, fileType, { isEdited: false });
|
||||
const oldFile = getAssetFile(files, pathType);
|
||||
return this.moveFile({
|
||||
entityId,
|
||||
pathType: fileType,
|
||||
pathType,
|
||||
oldPath: oldFile?.path || null,
|
||||
newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }),
|
||||
newPath: StorageCore.getImagePath(asset, pathType, format),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -308,19 +298,19 @@ export class StorageCore {
|
||||
case AssetPathType.Original: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
}
|
||||
case AssetFileType.FullSize: {
|
||||
case AssetPathType.FullSize: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
|
||||
}
|
||||
case AssetFileType.Preview: {
|
||||
case AssetPathType.Preview: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
|
||||
}
|
||||
case AssetFileType.Thumbnail: {
|
||||
case AssetPathType.Thumbnail: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
|
||||
}
|
||||
case AssetPathType.EncodedVideo: {
|
||||
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
||||
}
|
||||
case AssetFileType.Sidecar: {
|
||||
case AssetPathType.Sidecar: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
|
||||
}
|
||||
case PersonPathType.Face: {
|
||||
|
||||
@@ -39,7 +39,6 @@ export type AssetFile = {
|
||||
id: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
};
|
||||
|
||||
export type Library = {
|
||||
@@ -345,7 +344,7 @@ export const columns = {
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
|
||||
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'],
|
||||
@@ -458,7 +457,6 @@ export const columns = {
|
||||
'asset_exif.projectionType',
|
||||
'asset_exif.rating',
|
||||
'asset_exif.state',
|
||||
'asset_exif.tags',
|
||||
'asset_exif.timeZone',
|
||||
],
|
||||
plugin: [
|
||||
@@ -482,5 +480,4 @@ export const lockableProperties = [
|
||||
'longitude',
|
||||
'rating',
|
||||
'timeZone',
|
||||
'tags',
|
||||
] as const;
|
||||
|
||||
@@ -45,6 +45,9 @@ export enum AssetFileType {
|
||||
Preview = 'preview',
|
||||
Thumbnail = 'thumbnail',
|
||||
Sidecar = 'sidecar',
|
||||
FullSizeEdited = 'fullsize_edited',
|
||||
PreviewEdited = 'preview_edited',
|
||||
ThumbnailEdited = 'thumbnail_edited',
|
||||
}
|
||||
|
||||
export enum AlbumUserRole {
|
||||
@@ -160,9 +163,6 @@ export enum Permission {
|
||||
|
||||
Maintenance = 'maintenance',
|
||||
|
||||
MapGeocode = 'map.geocode',
|
||||
MapRead = 'map.read',
|
||||
|
||||
MemoryCreate = 'memory.create',
|
||||
MemoryRead = 'memory.read',
|
||||
MemoryUpdate = 'memory.update',
|
||||
@@ -369,7 +369,14 @@ export enum ManualJobName {
|
||||
|
||||
export enum AssetPathType {
|
||||
Original = 'original',
|
||||
FullSize = 'fullsize',
|
||||
Preview = 'preview',
|
||||
EditedFullSize = 'edited_fullsize',
|
||||
EditedPreview = 'edited_preview',
|
||||
EditedThumbnail = 'edited_thumbnail',
|
||||
Thumbnail = 'thumbnail',
|
||||
EncodedVideo = 'encoded_video',
|
||||
Sidecar = 'sidecar',
|
||||
}
|
||||
|
||||
export enum PersonPathType {
|
||||
@@ -380,7 +387,7 @@ export enum UserPathType {
|
||||
Profile = 'profile',
|
||||
}
|
||||
|
||||
export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
|
||||
export type PathType = AssetPathType | PersonPathType | UserPathType;
|
||||
|
||||
export enum TranscodePolicy {
|
||||
All = 'all',
|
||||
|
||||
@@ -29,8 +29,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -38,6 +37,20 @@ select
|
||||
and "asset_file"."type" = $1
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
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",
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
from
|
||||
"asset"
|
||||
@@ -59,8 +72,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -87,8 +99,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -134,8 +145,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -164,8 +174,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -235,8 +244,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -261,8 +269,7 @@ where
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -311,8 +318,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -351,8 +357,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -439,8 +444,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -532,8 +536,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -572,8 +575,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
|
||||
@@ -286,8 +286,7 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
|
||||
@@ -43,7 +43,6 @@ select
|
||||
"asset_exif"."projectionType",
|
||||
"asset_exif"."rating",
|
||||
"asset_exif"."state",
|
||||
"asset_exif"."tags",
|
||||
"asset_exif"."timeZone"
|
||||
from
|
||||
"asset_exif"
|
||||
@@ -128,7 +127,6 @@ select
|
||||
"asset_exif"."projectionType",
|
||||
"asset_exif"."rating",
|
||||
"asset_exif"."state",
|
||||
"asset_exif"."tags",
|
||||
"asset_exif"."timeZone"
|
||||
from
|
||||
"asset_exif"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -41,6 +42,15 @@ export class AssetJobRepository {
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.select(['id', 'originalPath'])
|
||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||
.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'),
|
||||
)
|
||||
.$call(withExifInner)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -178,7 +178,6 @@ export class AssetRepository {
|
||||
bitsPerSample: ref('bitsPerSample'),
|
||||
rating: ref('rating'),
|
||||
fps: ref('fps'),
|
||||
tags: ref('tags'),
|
||||
lockedProperties:
|
||||
lockedPropertiesBehavior === 'append'
|
||||
? distinctLocked(eb, exif.lockedProperties ?? null)
|
||||
@@ -904,22 +903,20 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
|
||||
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>): Promise<void> {
|
||||
const value = { ...file, assetId: asUuid(file.assetId) };
|
||||
await this.db
|
||||
.insertInto('asset_file')
|
||||
.values(value)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFiles(
|
||||
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
|
||||
): Promise<void> {
|
||||
async upsertFiles(files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>[]): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -929,7 +926,7 @@ export class AssetRepository {
|
||||
.insertInto('asset_file')
|
||||
.values(values)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" DROP CONSTRAINT "asset_file_assetId_type_uq";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_file" ADD "isEdited" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||
await sql`ALTER TABLE "asset_file" ADD CONSTRAINT "asset_file_assetId_type_isEdited_uq" UNIQUE ("assetId", "type", "isEdited");`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" DROP CONSTRAINT "asset_file_assetId_type_isEdited_uq";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_file" ADD CONSTRAINT "asset_file_assetId_type_uq" UNIQUE ("assetId", "type");`.execute(db);
|
||||
await sql`ALTER TABLE "asset_file" DROP COLUMN "isEdited";`.execute(db);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_exif" ADD "tags" character varying[];`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_exif" DROP COLUMN "tags";`.execute(db);
|
||||
}
|
||||
@@ -93,9 +93,6 @@ export class AssetExifTable {
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
rating!: number | null;
|
||||
|
||||
@Column({ type: 'character varying', array: true, nullable: true })
|
||||
tags!: string[] | null;
|
||||
|
||||
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
|
||||
updatedAt!: Generated<Date>;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'src/sql-tools';
|
||||
|
||||
@Table('asset_file')
|
||||
@Unique({ columns: ['assetId', 'type', 'isEdited'] })
|
||||
@Unique({ columns: ['assetId', 'type'] })
|
||||
@UpdatedAtTrigger('asset_file_updatedAt')
|
||||
export class AssetFileTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
@@ -37,7 +37,4 @@ export class AssetFileTable {
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isEdited!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -529,10 +529,9 @@ describe(AssetMediaService.name, () => {
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSize,
|
||||
type: AssetFileType.FullSizeEdited,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
isEdited: true,
|
||||
},
|
||||
} as AssetFile,
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
@@ -555,10 +554,9 @@ describe(AssetMediaService.name, () => {
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSize,
|
||||
type: AssetFileType.FullSizeEdited,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
isEdited: true,
|
||||
},
|
||||
} as AssetFile,
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
@@ -581,10 +579,9 @@ describe(AssetMediaService.name, () => {
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSize,
|
||||
type: AssetFileType.FullSizeEdited,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
isEdited: true,
|
||||
},
|
||||
} as AssetFile,
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
@@ -659,7 +656,6 @@ describe(AssetMediaService.name, () => {
|
||||
id: '42',
|
||||
path: '/path/to/preview',
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -677,7 +673,6 @@ describe(AssetMediaService.name, () => {
|
||||
id: '42',
|
||||
path: '/path/to/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DateTime, Duration } from 'luxon';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AssetBulkDeleteDto,
|
||||
AssetBulkUpdateDto,
|
||||
@@ -112,7 +112,7 @@ export class AssetService extends BaseService {
|
||||
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
|
||||
const repos = { asset: this.assetRepository, event: this.eventRepository };
|
||||
|
||||
let previousMotion: { id: string } | null = null;
|
||||
let previousMotion: MapAsset | null = null;
|
||||
if (rest.livePhotoVideoId) {
|
||||
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
|
||||
} else if (rest.livePhotoVideoId === null) {
|
||||
|
||||
@@ -241,21 +241,21 @@ describe(MediaService.name, () => {
|
||||
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||
entityId: assetStub.image.id,
|
||||
pathType: AssetFileType.FullSize,
|
||||
pathType: AssetPathType.FullSize,
|
||||
oldPath: '/uploads/user-id/fullsize/path.webp',
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_fullsize.jpeg'),
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-fullsize.jpeg'),
|
||||
});
|
||||
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||
entityId: assetStub.image.id,
|
||||
pathType: AssetFileType.Preview,
|
||||
pathType: AssetPathType.Preview,
|
||||
oldPath: '/uploads/user-id/thumbs/path.jpg',
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_preview.jpeg'),
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-preview.jpeg'),
|
||||
});
|
||||
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||
entityId: assetStub.image.id,
|
||||
pathType: AssetFileType.Thumbnail,
|
||||
pathType: AssetPathType.Thumbnail,
|
||||
oldPath: '/uploads/user-id/webp/path.ext',
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_thumbnail.webp'),
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-thumbnail.webp'),
|
||||
});
|
||||
expect(mocks.move.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@@ -385,13 +385,11 @@ describe(MediaService.name, () => {
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
|
||||
@@ -423,13 +421,11 @@ describe(MediaService.name, () => {
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -460,13 +456,11 @@ describe(MediaService.name, () => {
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -554,8 +548,8 @@ describe(MediaService.name, () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const previewPath = `/data/thumbs/user-id/as/se/asset-id_preview.${format}`;
|
||||
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`;
|
||||
const previewPath = `/data/thumbs/user-id/as/se/asset-id-preview.${format}`;
|
||||
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id-thumbnail.webp`;
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@@ -601,8 +595,8 @@ describe(MediaService.name, () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_preview.jpeg`);
|
||||
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_thumbnail.${format}`);
|
||||
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-preview.jpeg`);
|
||||
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-thumbnail.${format}`);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@@ -1032,9 +1026,9 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: AssetFileType.FullSize, isEdited: true }),
|
||||
expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
|
||||
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
|
||||
expect.objectContaining({ type: AssetFileType.FullSizeEdited }),
|
||||
expect.objectContaining({ type: AssetFileType.PreviewEdited }),
|
||||
expect.objectContaining({ type: AssetFileType.ThumbnailEdited }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1104,17 +1098,17 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.anything(),
|
||||
expect.stringContaining('preview_edited.jpeg'),
|
||||
expect.stringContaining('edited_preview.jpeg'),
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.anything(),
|
||||
expect.stringContaining('thumbnail_edited.webp'),
|
||||
expect.stringContaining('edited_thumbnail.webp'),
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.anything(),
|
||||
expect.stringContaining('fullsize_edited.jpeg'),
|
||||
expect.stringContaining('edited_fullsize.jpeg'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3260,13 +3254,13 @@ describe(MediaService.name, () => {
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
@@ -3276,31 +3270,19 @@ describe(MediaService.name, () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3313,38 +3295,17 @@ describe(MediaService.name, () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false },
|
||||
]);
|
||||
await sut['syncFiles'](asset, [{ type: AssetFileType.Preview }, { type: AssetFileType.Thumbnail }]);
|
||||
|
||||
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 },
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
@@ -3356,26 +3317,14 @@ describe(MediaService.name, () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/same/preview.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/same/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/same/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/same/thumbnail.jpg' },
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg' },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg' },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
@@ -3387,41 +3336,23 @@ describe(MediaService.name, () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
],
|
||||
};
|
||||
|
||||
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
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, // replace
|
||||
{ type: AssetFileType.Thumbnail }, // delete
|
||||
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg' }, // new
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
|
||||
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
@@ -3445,19 +3376,11 @@ describe(MediaService.name, () => {
|
||||
it('should delete non-existent file types when newPath is not provided', async () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
files: [{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
|
||||
{ type: AssetFileType.Thumbnail }, // file doesn't exist, newPath not provided
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
AudioCodec,
|
||||
@@ -49,7 +50,6 @@ interface UpsertFileOptions {
|
||||
assetId: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
}
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
@@ -160,9 +160,9 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.FullSize, image.fullsize.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.format);
|
||||
await this.storageCore.moveAssetVideo(asset);
|
||||
|
||||
return JobStatus.Success;
|
||||
@@ -236,9 +236,9 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
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 },
|
||||
{ type: AssetFileType.Preview, newPath: generated.previewPath },
|
||||
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath },
|
||||
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath },
|
||||
]);
|
||||
|
||||
const editiedGenerated = await this.generateEditedThumbnails(asset);
|
||||
@@ -307,16 +307,16 @@ export class MediaService extends BaseService {
|
||||
|
||||
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,
|
||||
});
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
isEdited: useEdits,
|
||||
format: image.thumbnail.format,
|
||||
});
|
||||
const previewPath = StorageCore.getImagePath(
|
||||
asset,
|
||||
useEdits ? AssetPathType.EditedPreview : AssetPathType.Preview,
|
||||
image.preview.format,
|
||||
);
|
||||
const thumbnailPath = StorageCore.getImagePath(
|
||||
asset,
|
||||
useEdits ? AssetPathType.EditedThumbnail : AssetPathType.Thumbnail,
|
||||
image.thumbnail.format,
|
||||
);
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
// Handle embedded preview extraction for RAW files
|
||||
@@ -343,11 +343,11 @@ export class MediaService extends BaseService {
|
||||
|
||||
if (convertFullsize) {
|
||||
// convert a new fullsize image from the same source as the thumbnail
|
||||
fullsizePath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
isEdited: useEdits,
|
||||
format: image.fullsize.format,
|
||||
});
|
||||
fullsizePath = StorageCore.getImagePath(
|
||||
asset,
|
||||
useEdits ? AssetPathType.EditedFullSize : AssetPathType.FullSize,
|
||||
image.fullsize.format,
|
||||
);
|
||||
const fullsizeOptions = {
|
||||
format: image.fullsize.format,
|
||||
quality: image.fullsize.quality,
|
||||
@@ -355,11 +355,7 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
||||
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||
fullsizePath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
format: extracted.format,
|
||||
isEdited: false,
|
||||
});
|
||||
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format);
|
||||
this.storageCore.ensureFolders(fullsizePath);
|
||||
|
||||
// Write the buffer to disk with essential EXIF data
|
||||
@@ -493,16 +489,8 @@ export class MediaService extends BaseService {
|
||||
|
||||
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,
|
||||
});
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: false,
|
||||
});
|
||||
const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format);
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format);
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
@@ -791,18 +779,18 @@ export class MediaService extends BaseService {
|
||||
|
||||
private async syncFiles(
|
||||
asset: { id: string; files: AssetFile[] },
|
||||
files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
|
||||
files: { type: AssetFileType; newPath?: string }[],
|
||||
) {
|
||||
const toUpsert: UpsertFileOptions[] = [];
|
||||
const pathsToDelete: string[] = [];
|
||||
const toDelete: AssetFile[] = [];
|
||||
|
||||
for (const { type, newPath, isEdited } of files) {
|
||||
const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
|
||||
for (const { type, newPath } of files) {
|
||||
const existingFile = asset.files.find((file) => file.type === type);
|
||||
|
||||
// upsert new file path
|
||||
if (newPath && existingFile?.path !== newPath) {
|
||||
toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
|
||||
toUpsert.push({ assetId: asset.id, path: newPath, type });
|
||||
|
||||
// delete old file from disk
|
||||
if (existingFile) {
|
||||
@@ -841,9 +829,9 @@ export class MediaService extends BaseService {
|
||||
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 },
|
||||
{ type: AssetFileType.PreviewEdited, newPath: generated?.previewPath },
|
||||
{ type: AssetFileType.ThumbnailEdited, newPath: generated?.thumbnailPath },
|
||||
{ type: AssetFileType.FullSizeEdited, newPath: generated?.fullsizePath },
|
||||
]);
|
||||
|
||||
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
|
||||
|
||||
@@ -35,7 +35,7 @@ const forSidecarJob = (
|
||||
asset: {
|
||||
id?: string;
|
||||
originalPath?: string;
|
||||
files?: { id: string; type: AssetFileType; path: string; isEdited: boolean }[];
|
||||
files?: { id: string; type: AssetFileType; path: string }[];
|
||||
} = {},
|
||||
) => {
|
||||
return {
|
||||
@@ -387,7 +387,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -398,7 +397,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -419,7 +417,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -430,7 +427,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -441,7 +437,6 @@ 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.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -453,7 +448,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -473,7 +467,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any);
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -493,7 +486,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -515,7 +507,6 @@ 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.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -527,7 +518,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
|
||||
@@ -542,7 +532,6 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Parent2/Child2'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -907,7 +896,6 @@ describe(MetadataService.name, () => {
|
||||
ProfileDescription: 'extensive description',
|
||||
ProjectionType: 'equirectangular',
|
||||
tz: 'UTC-11:30',
|
||||
TagsList: ['parent/child'],
|
||||
Rating: 3,
|
||||
};
|
||||
|
||||
@@ -947,7 +935,6 @@ describe(MetadataService.name, () => {
|
||||
country: null,
|
||||
state: null,
|
||||
city: null,
|
||||
tags: ['parent/child'],
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
@@ -1097,7 +1084,6 @@ describe(MetadataService.name, () => {
|
||||
id: 'some-id',
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/path/to/something',
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1705,7 +1691,7 @@ describe(MetadataService.name, () => {
|
||||
it('should unset sidecar path if file no longer exist', async () => {
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
|
||||
});
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(false);
|
||||
@@ -1718,7 +1704,7 @@ describe(MetadataService.name, () => {
|
||||
it('should do nothing if the sidecar file still exists', async () => {
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
|
||||
});
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
|
||||
@@ -254,8 +254,6 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const tags = this.getTagList(exifTags);
|
||||
|
||||
const exifData: Insertable<AssetExifTable> = {
|
||||
assetId: asset.id,
|
||||
|
||||
@@ -298,8 +296,6 @@ export class MetadataService extends BaseService {
|
||||
// grouping
|
||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||
autoStackId: this.getAutoStackId(exifTags),
|
||||
|
||||
tags: tags.length > 0 ? tags : null,
|
||||
};
|
||||
|
||||
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
|
||||
@@ -320,10 +316,9 @@ export class MetadataService extends BaseService {
|
||||
width: asset.width == null ? assetWidth : undefined,
|
||||
height: asset.height == null ? assetHeight : undefined,
|
||||
}),
|
||||
this.applyTagList(asset, exifTags),
|
||||
];
|
||||
|
||||
await this.applyTagList(asset);
|
||||
|
||||
if (this.isMotionPhoto(asset, exifTags)) {
|
||||
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
|
||||
}
|
||||
@@ -410,35 +405,35 @@ export class MetadataService extends BaseService {
|
||||
|
||||
@OnEvent({ name: 'AssetTag' })
|
||||
async handleTagAsset({ assetId }: ArgOf<'AssetTag'>) {
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId } });
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } });
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetUntag' })
|
||||
async handleUntagAsset({ assetId }: ArgOf<'AssetUntag'>) {
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId } });
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } });
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
|
||||
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
|
||||
const { id } = job;
|
||||
const { id, tags } = job;
|
||||
const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
|
||||
if (!asset) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
|
||||
const tagsList = (asset.tags || []).map((tag) => tag.value);
|
||||
|
||||
const { sidecarFile } = getAssetFiles(asset.files);
|
||||
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
|
||||
|
||||
const { description, dateTimeOriginal, latitude, longitude, rating, tags } = _.pick(
|
||||
const { description, dateTimeOriginal, latitude, longitude, rating } = _.pick(
|
||||
{
|
||||
description: asset.exifInfo.description,
|
||||
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
|
||||
latitude: asset.exifInfo.latitude,
|
||||
longitude: asset.exifInfo.longitude,
|
||||
rating: asset.exifInfo.rating,
|
||||
tags: asset.exifInfo.tags,
|
||||
},
|
||||
lockedProperties,
|
||||
);
|
||||
@@ -451,7 +446,7 @@ export class MetadataService extends BaseService {
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
Rating: rating,
|
||||
TagsList: tags?.length ? tags : undefined,
|
||||
TagsList: tags ? tagsList : undefined,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
@@ -565,14 +560,11 @@ export class MetadataService extends BaseService {
|
||||
return tags;
|
||||
}
|
||||
|
||||
private async applyTagList({ id, ownerId }: { id: string; ownerId: string }) {
|
||||
const asset = await this.assetRepository.getById(id, { exifInfo: true });
|
||||
const results = await upsertTags(this.tagRepository, {
|
||||
userId: ownerId,
|
||||
tags: asset?.exifInfo?.tags ?? [],
|
||||
});
|
||||
private async applyTagList(asset: { id: string; ownerId: string }, exifTags: ImmichTags) {
|
||||
const tags = this.getTagList(exifTags);
|
||||
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
||||
await this.tagRepository.replaceAssetTags(
|
||||
id,
|
||||
asset.id,
|
||||
results.map((tag) => tag.id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ describe(NotificationService.name, () => {
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([
|
||||
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg', isEdited: false },
|
||||
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' },
|
||||
]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
|
||||
@@ -403,7 +403,7 @@ describe(NotificationService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ server: {} });
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([{ ...assetStub.image.files[2], isEdited: false }]);
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
|
||||
|
||||
@@ -240,11 +240,11 @@ export class StorageTemplateService extends BaseService {
|
||||
assetInfo: { sizeInBytes: fileSizeInByte, checksum },
|
||||
});
|
||||
|
||||
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar, { isEdited: false })?.path;
|
||||
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path;
|
||||
if (sidecarPath) {
|
||||
await this.storageCore.moveFile({
|
||||
entityId: id,
|
||||
pathType: AssetFileType.Sidecar,
|
||||
pathType: AssetPathType.Sidecar,
|
||||
oldPath: sidecarPath,
|
||||
newPath: `${newPath}.xmp`,
|
||||
});
|
||||
|
||||
@@ -191,7 +191,6 @@ 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.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] } as any);
|
||||
mocks.tag.upsertAssetIds.mockResolvedValue([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
@@ -205,18 +204,6 @@ describe(TagService.name, () => {
|
||||
).resolves.toEqual({
|
||||
count: 6,
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-1', tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-2', tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-3', tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
@@ -242,7 +229,6 @@ describe(TagService.name, () => {
|
||||
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.tag.addAssetIds.mockResolvedValue();
|
||||
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
||||
|
||||
await expect(
|
||||
@@ -254,14 +240,6 @@ describe(TagService.name, () => {
|
||||
{ id: 'asset-2', success: true },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-1', tags: ['tag-1'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-2', tags: ['tag-1'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
|
||||
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
|
||||
});
|
||||
|
||||
@@ -90,7 +90,6 @@ export class TagService extends BaseService {
|
||||
|
||||
const results = await this.tagRepository.upsertAssetIds(items);
|
||||
for (const assetId of new Set(results.map((item) => item.assetId))) {
|
||||
await this.updateTags(assetId);
|
||||
await this.eventRepository.emit('AssetTag', { assetId });
|
||||
}
|
||||
|
||||
@@ -108,7 +107,6 @@ export class TagService extends BaseService {
|
||||
|
||||
for (const { id: assetId, success } of results) {
|
||||
if (success) {
|
||||
await this.updateTags(assetId);
|
||||
await this.eventRepository.emit('AssetTag', { assetId });
|
||||
}
|
||||
}
|
||||
@@ -127,7 +125,6 @@ export class TagService extends BaseService {
|
||||
|
||||
for (const { id: assetId, success } of results) {
|
||||
if (success) {
|
||||
await this.updateTags(assetId);
|
||||
await this.eventRepository.emit('AssetUntag', { assetId });
|
||||
}
|
||||
}
|
||||
@@ -148,12 +145,4 @@ export class TagService extends BaseService {
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
private async updateTags(assetId: string) {
|
||||
const asset = await this.assetRepository.getById(assetId, { tags: true });
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ export type JobItem =
|
||||
// Sidecar Scanning
|
||||
| { name: JobName.SidecarQueueAll; data: IBaseJob }
|
||||
| { name: JobName.SidecarCheck; data: IEntityJob }
|
||||
| { name: JobName.SidecarWrite; data: IEntityJob }
|
||||
| { name: JobName.SidecarWrite; data: ISidecarWriteJob }
|
||||
|
||||
// Facial Recognition
|
||||
| { name: JobName.AssetDetectFacesQueueAll; data: IBaseJob }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
@@ -14,19 +14,19 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
|
||||
import { checkAccess } from 'src/utils/access';
|
||||
|
||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
|
||||
return files.find((file) => file.type === type && file.isEdited === isEdited);
|
||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
|
||||
return files.find((file) => file.type === type);
|
||||
};
|
||||
|
||||
export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
|
||||
previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
|
||||
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
|
||||
sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
|
||||
fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
|
||||
previewFile: getAssetFile(files, AssetFileType.Preview),
|
||||
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
|
||||
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
|
||||
|
||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSizeEdited),
|
||||
editedPreviewFile: getAssetFile(files, AssetFileType.PreviewEdited),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.ThumbnailEdited),
|
||||
});
|
||||
|
||||
export const addAssets = async (
|
||||
|
||||
9
server/test/fixtures/asset.stub.ts
vendored
9
server/test/fixtures/asset.stub.ts
vendored
@@ -31,21 +31,18 @@ const sidecarFileWithoutExt = factory.assetFile({
|
||||
});
|
||||
|
||||
const editedPreviewFile = factory.assetFile({
|
||||
type: AssetFileType.Preview,
|
||||
type: AssetFileType.PreviewEdited,
|
||||
path: '/uploads/user-id/preview/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const editedThumbnailFile = factory.assetFile({
|
||||
type: AssetFileType.Thumbnail,
|
||||
type: AssetFileType.ThumbnailEdited,
|
||||
path: '/uploads/user-id/thumbnail/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const editedFullsizeFile = factory.assetFile({
|
||||
type: AssetFileType.FullSize,
|
||||
type: AssetFileType.FullSizeEdited,
|
||||
path: '/uploads/user-id/fullsize/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
||||
|
||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@@ -147,7 +147,6 @@ export const sharedLinkStub = {
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: 500,
|
||||
height: 500,
|
||||
tags: [],
|
||||
},
|
||||
sharedLinks: [],
|
||||
faces: [],
|
||||
|
||||
@@ -335,7 +335,6 @@ const assetSidecarWriteFactory = () => {
|
||||
id: newUuid(),
|
||||
path: '/path/to/original-path.jpg.xmp',
|
||||
type: AssetFileType.Sidecar,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
exifInfo: {
|
||||
@@ -387,7 +386,6 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
|
||||
id: newUuid(),
|
||||
type: AssetFileType.Preview,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
isEdited: false,
|
||||
...file,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
@@ -322,11 +321,6 @@
|
||||
await handleGetAllAlbums();
|
||||
break;
|
||||
}
|
||||
case AssetAction.DELETE:
|
||||
case AssetAction.TRASH: {
|
||||
eventManager.emit('AssetsDelete', [asset.id]);
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
stack = action.stack;
|
||||
if (stack) {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
@@ -293,14 +292,8 @@
|
||||
|
||||
untrack(() => map?.jumpTo({ center, zoom }));
|
||||
});
|
||||
|
||||
const onAssetsDelete = async () => {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onAssetsDelete} />
|
||||
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
{hash}
|
||||
|
||||
@@ -4,13 +4,7 @@ import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { updatePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendarEditOutline,
|
||||
mdiEyeOffOutline,
|
||||
mdiEyeOutline,
|
||||
mdiHeartMinusOutline,
|
||||
mdiHeartOutline,
|
||||
} from '@mdi/js';
|
||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto) => {
|
||||
@@ -20,83 +14,7 @@ export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto
|
||||
onAction: () => modalManager.show(PersonEditBirthDateModal, { person }),
|
||||
};
|
||||
|
||||
const Favorite: ActionItem = {
|
||||
title: $t('to_favorite'),
|
||||
icon: mdiHeartOutline,
|
||||
$if: () => !person.isFavorite,
|
||||
onAction: () => handleFavoritePerson(person),
|
||||
};
|
||||
|
||||
const Unfavorite: ActionItem = {
|
||||
title: $t('unfavorite'),
|
||||
icon: mdiHeartMinusOutline,
|
||||
$if: () => !!person.isFavorite,
|
||||
onAction: () => handleUnfavoritePerson(person),
|
||||
};
|
||||
|
||||
const HidePerson: ActionItem = {
|
||||
title: $t('hide_person'),
|
||||
icon: mdiEyeOffOutline,
|
||||
$if: () => !person.isHidden,
|
||||
onAction: () => handleHidePerson(person),
|
||||
};
|
||||
|
||||
const ShowPerson: ActionItem = {
|
||||
title: $t('unhide_person'),
|
||||
icon: mdiEyeOutline,
|
||||
$if: () => !!person.isHidden,
|
||||
onAction: () => handleShowPerson(person),
|
||||
};
|
||||
|
||||
return { SetDateOfBirth, Favorite, Unfavorite, HidePerson, ShowPerson };
|
||||
};
|
||||
|
||||
const handleFavoritePerson = async (person: { id: string }) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: true } });
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
toastManager.success($t('added_to_favorites'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnfavoritePerson = async (person: { id: string }) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: false } });
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
toastManager.success($t('removed_from_favorites'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHidePerson = async (person: { id: string }) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: true } });
|
||||
toastManager.success($t('changed_visibility_successfully'));
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_hide_person'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowPerson = async (person: { id: string }) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: false } });
|
||||
toastManager.success($t('changed_visibility_successfully'));
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
return { SetDateOfBirth };
|
||||
};
|
||||
|
||||
export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string) => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { listNavigation } from '$lib/actions/list-navigation';
|
||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
|
||||
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
|
||||
@@ -41,12 +42,16 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isExternalUrl } from '$lib/utils/navigation';
|
||||
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { ContextMenuButton, LoadingSpinner, modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { LoadingSpinner, modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountBoxOutline,
|
||||
mdiAccountMultipleCheckOutline,
|
||||
mdiArrowLeft,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiEyeOutline,
|
||||
mdiHeartMinusOutline,
|
||||
mdiHeartOutline,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -139,6 +144,37 @@
|
||||
viewMode = PersonPageViewMode.UNASSIGN_ASSETS;
|
||||
};
|
||||
|
||||
const toggleHidePerson = async () => {
|
||||
try {
|
||||
await updatePerson({
|
||||
id: person.id,
|
||||
personUpdateDto: { isHidden: !person.isHidden },
|
||||
});
|
||||
|
||||
toastManager.success($t('changed_visibility_successfully'));
|
||||
|
||||
await goto(previousRoute);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_hide_person'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
try {
|
||||
const updatedPerson = await updatePerson({
|
||||
id: person.id,
|
||||
personUpdateDto: { isFavorite: !person.isFavorite },
|
||||
});
|
||||
|
||||
// Invalidate to reload the page data and have the favorite status updated
|
||||
await invalidateAll();
|
||||
|
||||
toastManager.success(updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: person.isFavorite } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async (person: PersonResponseDto) => {
|
||||
await updateAssetCount();
|
||||
await handleGoBack();
|
||||
@@ -289,35 +325,13 @@
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
const onPersonUpdate = async (response: PersonResponseDto) => {
|
||||
if (response.id !== person.id) {
|
||||
return;
|
||||
const onPersonUpdate = (response: PersonResponseDto) => {
|
||||
if (person.id === response.id) {
|
||||
return (person = response);
|
||||
}
|
||||
|
||||
if (response.isHidden) {
|
||||
await goto(previousRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
person = response;
|
||||
};
|
||||
|
||||
const { SetDateOfBirth, Favorite, Unfavorite, HidePerson, ShowPerson } = $derived(getPersonActions($t, person));
|
||||
const SelectFeaturePhoto: ActionItem = {
|
||||
title: $t('select_featured_photo'),
|
||||
icon: mdiAccountBoxOutline,
|
||||
onAction: () => {
|
||||
viewMode = PersonPageViewMode.SELECT_PERSON;
|
||||
},
|
||||
};
|
||||
|
||||
const Merge: ActionItem = {
|
||||
title: $t('merge_people'),
|
||||
icon: mdiAccountMultipleCheckOutline,
|
||||
onAction: () => {
|
||||
viewMode = PersonPageViewMode.MERGE_PEOPLE;
|
||||
},
|
||||
};
|
||||
const { SetDateOfBirth } = $derived(getPersonActions($t, person));
|
||||
</script>
|
||||
|
||||
<OnEvents {onPersonUpdate} onAssetsDelete={updateAssetCount} onAssetsArchive={updateAssetCount} />
|
||||
@@ -493,10 +507,29 @@
|
||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
||||
{#snippet trailing()}
|
||||
<ContextMenuButton
|
||||
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
|
||||
aria-label={$t('open')}
|
||||
/>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<MenuOption
|
||||
text={$t('select_featured_photo')}
|
||||
icon={mdiAccountBoxOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.SELECT_PERSON)}
|
||||
/>
|
||||
<MenuOption
|
||||
text={person.isHidden ? $t('unhide_person') : $t('hide_person')}
|
||||
icon={person.isHidden ? mdiEyeOutline : mdiEyeOffOutline}
|
||||
onClick={() => toggleHidePerson()}
|
||||
/>
|
||||
<ActionMenuItem action={SetDateOfBirth} />
|
||||
<MenuOption
|
||||
text={$t('merge_people')}
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
|
||||
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
onClick={handleToggleFavorite}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
</script>
|
||||
|
||||
<AuthPageLayout withHeader={false}>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-96 flex flex-col gap-6 items-center justify-center">
|
||||
{#if hasPinCode}
|
||||
{#if hasPinCode}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-96 flex flex-col gap-6 items-center justify-center">
|
||||
{#if isVerified}
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<Icon icon={mdiLockOpenVariantOutline} size="64" class="text-success/90" />
|
||||
@@ -64,7 +64,13 @@
|
||||
pinLength={6}
|
||||
onFilled={handleUnlockSession}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
<Button type="button" color="secondary" onclick={() => goto(Route.photos())}>{$t('cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-96 flex flex-col gap-6 items-center justify-center">
|
||||
<div class="text-primary">
|
||||
<Icon icon={mdiLockSmart} size="64" />
|
||||
</div>
|
||||
@@ -72,11 +78,7 @@
|
||||
{$t('new_pin_code_subtitle')}
|
||||
</p>
|
||||
<PinCodeCreateForm showLabel={false} onCreated={() => (hasPinCode = true)} />
|
||||
{/if}
|
||||
|
||||
<div class={hasPinCode ? '' : 'flex w-full items-start'}>
|
||||
<Button type="button" color="secondary" onclick={() => goto(Route.photos())}>{$t('cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</AuthPageLayout>
|
||||
|
||||
Reference in New Issue
Block a user