Compare commits

..

1 Commits

Author SHA1 Message Date
bwees
f90d38f93a feat: fix discriminated type parsing 2026-01-20 10:42:45 -06:00
52 changed files with 371 additions and 580 deletions

View File

@@ -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',

View File

@@ -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 = [];

View File

@@ -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 = [];

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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>[

View File

@@ -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) {

View File

@@ -160,7 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup();
}),
_resumeBackup(),
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
backgroundManager.syncCloudIds(),
]);
} else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View File

@@ -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>(

View File

@@ -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(),
);
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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",

View File

@@ -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;

View File

@@ -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",

View File

@@ -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',

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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();

View File

@@ -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'),
})),
)

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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>;

View File

@@ -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>;
}

View File

@@ -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,
},
],
});

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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),
);
}

View File

@@ -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(

View File

@@ -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`,
});

View File

@@ -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']);
});

View File

@@ -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' },
);
}
}

View File

@@ -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 }

View File

@@ -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 (

View File

@@ -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];

View File

@@ -147,7 +147,6 @@ export const sharedLinkStub = {
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
tags: [],
},
sharedLinks: [],
faces: [],

View File

@@ -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,
});

View 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) {

View File

@@ -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}

View File

@@ -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) => {

View File

@@ -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}

View File

@@ -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>