Compare commits

...

13 Commits

Author SHA1 Message Date
shenlong-tanwen
dfd71f6379 auto layout on smaller tiles 2026-01-21 21:43:36 +05:30
shenlong-tanwen
ed9b4e795a auto dynamic mode on smaller column count 2026-01-21 21:39:48 +05:30
shenlong-tanwen
93c19d1b2e simplify _buildAssetRow 2026-01-21 21:39:48 +05:30
shenlong-tanwen
074cc1db73 feat(mobile): dynamic layout in new timeline 2026-01-21 21:39:48 +05:30
shenlong
fb94ee80aa fix: prevent cloud id sync on app pause (#25332)
* fix: sever version not populated post auto-login

* saferun syncCloudIds

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-21 09:54:08 -06:00
Mees Frensel
083ee0b5fe fix(web): allow exiting pin setup flow (#25413) 2026-01-21 09:53:15 -06:00
Jason Rasmussen
0bae88bef6 refactor(web): person service actions (#25402)
* refactor(web): person service actions

* fix: timeline e2e tests
2026-01-21 10:40:09 -05:00
Daniel Dietzler
184f1a6d32 fix: tag update race condition (#25371) 2026-01-21 16:30:45 +01:00
Jason Rasmussen
248cb86143 chore: disable broken e2e timeline tests (#25417) 2026-01-21 10:14:08 -05:00
Daniel Dietzler
1649d87360 refactor: extract isEdited into its own column in asset_file (#25358) 2026-01-21 16:08:21 +01:00
Mees Frensel
8970566865 fix(web): handle deletion from asset viewer on map page (#25393) 2026-01-21 14:08:01 +00:00
Alex
0b4a96140e fix: don't include metadata when upload motion part of LivePhotos (#25400)
* fix: don't include metadata when upload motion part of LivePhotos

* fix: get original file name
2026-01-21 13:58:32 +00:00
Noel S
72caf8983c fix(mobile): indicators not showing on thumbnail tile after asset change in viewer (#25297)
* fixed indicators staying hidden

* remove logs

* explanation comment

* move import to correct place

* revert accidental change in null handling
2026-01-20 14:02:54 -06:00
45 changed files with 692 additions and 363 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 playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_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 playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0'); export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER; export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1'; process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/, testMatch: /.*\.e2e-spec\.ts/,
workers: 1, workers: 1,
}, },
{ // {
name: 'parallel tests', // name: 'parallel tests',
use: { ...devices['Desktop Chrome'] }, // use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/, // testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true, // fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), // workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
}, // },
// { // {
// name: 'firefox', // name: 'firefox',

View File

@@ -12,7 +12,7 @@ import {
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network'; import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils'; import { assetViewerUtils } from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => { test.describe('asset-viewer', () => {
@@ -49,7 +49,6 @@ test.describe('asset-viewer', () => {
}); });
test.afterEach(() => { test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false; testContext.slowBucket = false;
changes.albumAdditions = []; changes.albumAdditions = [];
changes.assetDeletions = []; changes.assetDeletions = [];

View File

@@ -18,7 +18,6 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
import { utils } from 'src/utils'; import { utils } from 'src/utils';
import { import {
assetViewerUtils, assetViewerUtils,
cancelAllPollers,
padYearMonth, padYearMonth,
pageUtils, pageUtils,
poll, poll,
@@ -64,7 +63,6 @@ test.describe('Timeline', () => {
}); });
test.afterEach(() => { test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false; testContext.slowBucket = false;
changes.albumAdditions = []; changes.albumAdditions = [];
changes.assetDeletions = []; changes.assetDeletions = [];

View File

@@ -23,13 +23,6 @@ export async function throttlePage(context: BrowserContext, page: Page) {
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 }); await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
} }
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
export const poll = async <T>( export const poll = async <T>(
page: Page, page: Page,
query: () => Promise<T>, query: () => Promise<T>,
@@ -37,21 +30,14 @@ export const poll = async <T>(
) => { ) => {
let result; let result;
const timeout = Date.now() + 10_000; const timeout = Date.now() + 10_000;
const signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result); const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) { while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try { try {
result = await query(); result = await query();
} catch { } catch {
// ignore // ignore
} }
if (signal.aborted) {
return;
}
if (page.isClosed()) { if (page.isClosed()) {
return; return;
} }

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.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/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.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/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -49,6 +50,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final accessToken = Store.tryGet(StoreKey.accessToken); final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && serverUrl != null && endpoint != null) { if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier); final backupProvider = ref.read(driftBackupProvider.notifier);
@@ -58,6 +60,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
(_) async { (_) async {
try { try {
wsProvider.connect(); wsProvider.connect();
unawaited(infoProvider.getServerInfo());
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false; bool syncSuccess = false;

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_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/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
@@ -47,6 +48,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final asset = widget.asset; final asset = widget.asset;
final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final isCurrentAsset = ref.watch(assetViewerProvider.select((current) => current.currentAsset == asset));
final assetContainerColor = context.isDarkTheme final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.4) ? context.primaryColor.darken(amount: 0.4)
@@ -59,6 +61,10 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
final bool storageIndicator = final bool storageIndicator =
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator; ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator;
if (!isCurrentAsset) {
_hideIndicators = false;
}
if (isSelected) { if (isSelected) {
_showSelectionContainer = true; _showSelectionContainer = true;
} }
@@ -96,7 +102,11 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
children: [ children: [
Positioned.fill( Positioned.fill(
child: Hero( child: Hero(
tag: '${asset?.heroTag ?? ''}_$heroIndex', // 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',
child: Thumbnail.fromAsset(asset: asset, size: widget.size), 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 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) { placeholderBuilder: (context, heroSize, child) {

View File

@@ -1,27 +1,45 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
class FixedTimelineRow extends MultiChildRenderObjectWidget { class TimelineRow extends MultiChildRenderObjectWidget {
final double dimension; final double height;
final List<double> widths;
final double spacing; final double spacing;
final TextDirection textDirection; final TextDirection textDirection;
const FixedTimelineRow({ const TimelineRow({
super.key, super.key,
required this.dimension, required this.height,
required this.widths,
required this.spacing, required this.spacing,
required this.textDirection, required this.textDirection,
required super.children, required super.children,
}); });
factory TimelineRow.fixed({
required double dimension,
required double spacing,
required TextDirection textDirection,
required List<Widget> children,
}) => TimelineRow(
height: dimension,
widths: List.filled(children.length, dimension),
spacing: spacing,
textDirection: textDirection,
children: children,
);
@override @override
RenderObject createRenderObject(BuildContext context) { RenderObject createRenderObject(BuildContext context) {
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection); return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
} }
@override @override
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) { void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
renderObject.dimension = dimension; renderObject.height = height;
renderObject.widths = widths;
renderObject.spacing = spacing; renderObject.spacing = spacing;
renderObject.textDirection = textDirection; renderObject.textDirection = textDirection;
} }
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension)); properties.add(DoubleProperty('height', height));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing)); properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
} }
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> { RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({ RenderFixedRow({
List<RenderBox>? children, List<RenderBox>? children,
required double dimension, required double height,
required List<double> widths,
required double spacing, required double spacing,
required TextDirection textDirection, required TextDirection textDirection,
}) : _dimension = dimension, }) : _height = height,
_widths = widths,
_spacing = spacing, _spacing = spacing,
_textDirection = textDirection { _textDirection = textDirection {
addAll(children); addAll(children);
} }
double get dimension => _dimension; double get height => _height;
double _dimension; double _height;
set dimension(double value) { set height(double value) {
if (_dimension == value) return; if (_height == value) return;
_dimension = value; _height = value;
markNeedsLayout();
}
List<double> get widths => _widths;
List<double> _widths;
set widths(List<double> value) {
if (listEquals(_widths, value)) return;
_widths = value;
markNeedsLayout(); markNeedsLayout();
} }
@@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox
} }
} }
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1); double get intrinsicWidth => widths.sum + (spacing * (childCount - 1));
@override @override
double computeMinIntrinsicWidth(double height) => intrinsicWidth; double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
double computeMaxIntrinsicWidth(double height) => intrinsicWidth; double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override @override
double computeMinIntrinsicHeight(double width) => dimension; double computeMinIntrinsicHeight(double width) => height;
@override @override
double computeMaxIntrinsicHeight(double width) => dimension; double computeMaxIntrinsicHeight(double width) => height;
@override @override
double? computeDistanceToActualBaseline(TextBaseline baseline) { double? computeDistanceToActualBaseline(TextBaseline baseline) {
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension)); properties.add(DoubleProperty('height', height));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing)); properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
} }
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
return; return;
} }
// Use the entire width of the parent for the row. // Use the entire width of the parent for the row.
size = Size(constraints.maxWidth, dimension); size = Size(constraints.maxWidth, height);
// Each tile is forced to be dimension x dimension.
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
final flipMainAxis = textDirection == TextDirection.rtl; final flipMainAxis = textDirection == TextDirection.rtl;
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0); int childIndex = 0;
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing); double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
// Layout each child horizontally. // Layout each child horizontally.
while (child != null) { while (child != null && childIndex < widths.length) {
final width = widths[childIndex];
final childConstraints = BoxConstraints.tight(Size(width, height));
child.layout(childConstraints, parentUsesSize: false); child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _RowParentData; final childParentData = child.parentData! as _RowParentData;
childParentData.offset = offset; childParentData.offset = Offset(currentX, 0);
offset += Offset(dx, 0);
child = childParentData.nextSibling; child = childParentData.nextSibling;
childIndex++;
if (child != null && childIndex < widths.length) {
final nextWidth = widths[childIndex];
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
}
} }
} }
} }

View File

@@ -2,10 +2,12 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
@@ -78,6 +80,7 @@ class FixedSegment extends Segment {
assetCount: numberOfAssets, assetCount: numberOfAssets,
tileHeight: tileHeight, tileHeight: tileHeight,
spacing: spacing, spacing: spacing,
columnCount: columnCount,
); );
} }
} }
@@ -87,24 +90,32 @@ class _FixedSegmentRow extends ConsumerWidget {
final int assetCount; final int assetCount;
final double tileHeight; final double tileHeight;
final double spacing; final double spacing;
final int columnCount;
const _FixedSegmentRow({ const _FixedSegmentRow({
required this.assetIndex, required this.assetIndex,
required this.assetCount, required this.assetCount,
required this.tileHeight, required this.tileHeight,
required this.spacing, required this.spacing,
required this.columnCount,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
if (isScrubbing) { if (isScrubbing) {
return _buildPlaceholder(context); return _buildPlaceholder(context);
} }
if (timelineService.hasRange(assetIndex, assetCount)) { if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService); return _buildAssetRow(
context,
timelineService.getAssets(assetIndex, assetCount),
timelineService,
isDynamicLayout,
);
} }
return FutureBuilder<List<BaseAsset>>( return FutureBuilder<List<BaseAsset>>(
@@ -113,7 +124,7 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) { if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context); return _buildPlaceholder(context);
} }
return _buildAssetRow(context, snapshot.requireData, timelineService); return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
}, },
); );
} }
@@ -122,23 +133,58 @@ class _FixedSegmentRow extends ConsumerWidget {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing); return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
} }
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) { Widget _buildAssetRow(
return FixedTimelineRow( BuildContext context,
dimension: tileHeight, List<BaseAsset> assets,
spacing: spacing, TimelineService timelineService,
textDirection: Directionality.of(context), bool isDynamicLayout,
children: [ ) {
for (int i = 0; i < assets.length; i++) final children = [
TimelineAssetIndexWrapper( for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i, assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
), ),
], ),
];
final widths = List.filled(assets.length, tileHeight);
if (isDynamicLayout) {
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
final meanAspectRatio = aspectRatios.sum / assets.length;
// 1: mean width
// 0.5: width < mean - threshold
// 1.5: width > mean + threshold
final arConfiguration = aspectRatios.map((e) {
if (e - meanAspectRatio > 0.3) return 1.5;
if (e - meanAspectRatio < -0.3) return 0.5;
return 1.0;
});
// Normalize to get width distribution
final sum = arConfiguration.sum;
int index = 0;
for (final ratio in arConfiguration) {
// Distribute the available width proportionally based on aspect ratio configuration
widths[index++] = ((ratio * assets.length) / sum) * tileHeight;
}
}
return TimelineDragRegion(
child: TimelineRow(
height: tileHeight,
widths: widths,
spacing: spacing,
textDirection: Directionality.of(context),
children: children,
),
); );
} }
} }

View File

@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
Size size = kTimelineFixedTileExtent, Size size = kTimelineFixedTileExtent,
double spacing = kTimelineSpacing, double spacing = kTimelineSpacing,
}) => RepaintBoundary( }) => RepaintBoundary(
child: FixedTimelineRow( child: TimelineRow.fixed(
dimension: size.height, dimension: size.height,
spacing: spacing, spacing: spacing,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),

View File

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

View File

@@ -307,10 +307,10 @@ class BackgroundUploadService {
priority: priority, priority: priority,
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi, requiresWiFi: requiresWiFi,
cloudId: asset.cloudId, cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(), adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(), latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
longitude: asset.longitude?.toString(), longitude: entity.isLivePhoto ? null : asset.longitude?.toString(),
); );
} }

View File

@@ -16,6 +16,7 @@ import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.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/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.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/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
@@ -40,6 +41,7 @@ final foregroundUploadServiceProvider = Provider((ref) {
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider), ref.watch(connectivityApiProvider),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider),
); );
}); });
@@ -55,6 +57,7 @@ class ForegroundUploadService {
this._backupRepository, this._backupRepository,
this._connectivityApi, this._connectivityApi,
this._appSettingsService, this._appSettingsService,
this._assetMediaRepository,
); );
final UploadRepository _uploadRepository; final UploadRepository _uploadRepository;
@@ -62,6 +65,7 @@ class ForegroundUploadService {
final DriftBackupRepository _backupRepository; final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi; final ConnectivityApi _connectivityApi;
final AppSettingsService _appSettingsService; final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository;
final Logger _logger = Logger('ForegroundUploadService'); final Logger _logger = Logger('ForegroundUploadService');
bool shouldAbortUpload = false; bool shouldAbortUpload = false;
@@ -311,7 +315,8 @@ class ForegroundUploadService {
return; return;
} }
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId); final deviceId = Store.get(StoreKey.deviceId);
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
@@ -322,19 +327,6 @@ class ForegroundUploadService {
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(), 'isFavorite': asset.isFavorite.toString(),
'duration': asset.duration.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 // Upload live photo video first if available
@@ -363,6 +355,22 @@ class ForegroundUploadService {
fields['livePhotoVideoId'] = livePhotoVideoId; 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( final result = await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: originalFileName, originalFileName: originalFileName,

View File

@@ -1,6 +1,7 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
@@ -24,11 +25,12 @@ class LayoutSettings extends HookConsumerWidget {
title: "asset_list_layout_sub_title".t(context: context), title: "asset_list_layout_sub_title".t(context: context),
icon: Icons.view_module_outlined, icon: Icons.view_module_outlined,
), ),
SettingsSwitchListTile( if (!Store.isBetaTimelineEnabled)
valueNotifier: useDynamicLayout, SettingsSwitchListTile(
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context), valueNotifier: useDynamicLayout,
onChanged: (_) => ref.invalidate(appSettingsServiceProvider), title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
), onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSliderListTile( SettingsSliderListTile(
valueNotifier: tilesPerRow, valueNotifier: tilesPerRow,
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}), text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),

View File

@@ -1,7 +1,15 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { StorageAsset } from 'src/database'; import { StorageAsset } from 'src/database';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import {
AssetFileType,
AssetPathType,
ImageFormat,
PathType,
PersonPathType,
RawExtractedFormat,
StorageFolder,
} from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository';
@@ -24,15 +32,6 @@ 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 }; export type ThumbnailPathEntity = { id: string; ownerId: string };
let instance: StorageCore | null; let instance: StorageCore | null;
@@ -111,8 +110,19 @@ export class StorageCore {
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`); return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
} }
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') { static getImagePath(
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`); 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 getEncodedVideoPath(asset: ThumbnailPathEntity) { static getEncodedVideoPath(asset: ThumbnailPathEntity) {
@@ -137,14 +147,14 @@ export class StorageCore {
return normalizedPath.startsWith(normalizedAppMediaLocation); return normalizedPath.startsWith(normalizedAppMediaLocation);
} }
async moveAssetImage(asset: StorageAsset, pathType: GeneratedImageType, format: ImageFormat) { async moveAssetImage(asset: StorageAsset, fileType: AssetFileType, format: ImageFormat) {
const { id: entityId, files } = asset; const { id: entityId, files } = asset;
const oldFile = getAssetFile(files, pathType); const oldFile = getAssetFile(files, fileType, { isEdited: false });
return this.moveFile({ return this.moveFile({
entityId, entityId,
pathType, pathType: fileType,
oldPath: oldFile?.path || null, oldPath: oldFile?.path || null,
newPath: StorageCore.getImagePath(asset, pathType, format), newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }),
}); });
} }
@@ -298,19 +308,19 @@ export class StorageCore {
case AssetPathType.Original: { case AssetPathType.Original: {
return this.assetRepository.update({ id, originalPath: newPath }); return this.assetRepository.update({ id, originalPath: newPath });
} }
case AssetPathType.FullSize: { case AssetFileType.FullSize: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
} }
case AssetPathType.Preview: { case AssetFileType.Preview: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
} }
case AssetPathType.Thumbnail: { case AssetFileType.Thumbnail: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
} }
case AssetPathType.EncodedVideo: { case AssetPathType.EncodedVideo: {
return this.assetRepository.update({ id, encodedVideoPath: newPath }); return this.assetRepository.update({ id, encodedVideoPath: newPath });
} }
case AssetPathType.Sidecar: { case AssetFileType.Sidecar: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
} }
case PersonPathType.Face: { case PersonPathType.Face: {

View File

@@ -39,6 +39,7 @@ export type AssetFile = {
id: string; id: string;
type: AssetFileType; type: AssetFileType;
path: string; path: string;
isEdited: boolean;
}; };
export type Library = { export type Library = {
@@ -344,7 +345,7 @@ export const columns = {
'asset.width', 'asset.width',
'asset.height', 'asset.height',
], ],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'], authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
@@ -457,6 +458,7 @@ export const columns = {
'asset_exif.projectionType', 'asset_exif.projectionType',
'asset_exif.rating', 'asset_exif.rating',
'asset_exif.state', 'asset_exif.state',
'asset_exif.tags',
'asset_exif.timeZone', 'asset_exif.timeZone',
], ],
plugin: [ plugin: [
@@ -480,4 +482,5 @@ export const lockableProperties = [
'longitude', 'longitude',
'rating', 'rating',
'timeZone', 'timeZone',
'tags',
] as const; ] as const;

View File

@@ -45,9 +45,6 @@ export enum AssetFileType {
Preview = 'preview', Preview = 'preview',
Thumbnail = 'thumbnail', Thumbnail = 'thumbnail',
Sidecar = 'sidecar', Sidecar = 'sidecar',
FullSizeEdited = 'fullsize_edited',
PreviewEdited = 'preview_edited',
ThumbnailEdited = 'thumbnail_edited',
} }
export enum AlbumUserRole { export enum AlbumUserRole {
@@ -369,14 +366,7 @@ export enum ManualJobName {
export enum AssetPathType { export enum AssetPathType {
Original = 'original', Original = 'original',
FullSize = 'fullsize',
Preview = 'preview',
EditedFullSize = 'edited_fullsize',
EditedPreview = 'edited_preview',
EditedThumbnail = 'edited_thumbnail',
Thumbnail = 'thumbnail',
EncodedVideo = 'encoded_video', EncodedVideo = 'encoded_video',
Sidecar = 'sidecar',
} }
export enum PersonPathType { export enum PersonPathType {
@@ -387,7 +377,7 @@ export enum UserPathType {
Profile = 'profile', Profile = 'profile',
} }
export type PathType = AssetPathType | PersonPathType | UserPathType; export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
export enum TranscodePolicy { export enum TranscodePolicy {
All = 'all', All = 'all',

View File

@@ -29,7 +29,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -37,20 +38,6 @@ select
and "asset_file"."type" = $1 and "asset_file"."type" = $1
) as agg ) as agg
) as "files", ) 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" to_json("asset_exif") as "exifInfo"
from from
"asset" "asset"
@@ -72,7 +59,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -99,7 +87,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -145,7 +134,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -174,7 +164,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -244,7 +235,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -269,7 +261,8 @@ where
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -318,7 +311,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -357,7 +351,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -444,7 +439,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -536,7 +532,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -575,7 +572,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where

View File

@@ -286,7 +286,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where

View File

@@ -43,6 +43,7 @@ select
"asset_exif"."projectionType", "asset_exif"."projectionType",
"asset_exif"."rating", "asset_exif"."rating",
"asset_exif"."state", "asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone" "asset_exif"."timeZone"
from from
"asset_exif" "asset_exif"
@@ -127,6 +128,7 @@ select
"asset_exif"."projectionType", "asset_exif"."projectionType",
"asset_exif"."rating", "asset_exif"."rating",
"asset_exif"."state", "asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone" "asset_exif"."timeZone"
from from
"asset_exif" "asset_exif"

View File

@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Asset, columns } from 'src/database'; import { Asset, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
@@ -42,15 +41,6 @@ export class AssetJobRepository {
.where('asset.id', '=', asUuid(id)) .where('asset.id', '=', asUuid(id))
.select(['id', 'originalPath']) .select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .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) .$call(withExifInner)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();

View File

@@ -178,6 +178,7 @@ export class AssetRepository {
bitsPerSample: ref('bitsPerSample'), bitsPerSample: ref('bitsPerSample'),
rating: ref('rating'), rating: ref('rating'),
fps: ref('fps'), fps: ref('fps'),
tags: ref('tags'),
lockedProperties: lockedProperties:
lockedPropertiesBehavior === 'append' lockedPropertiesBehavior === 'append'
? distinctLocked(eb, exif.lockedProperties ?? null) ? distinctLocked(eb, exif.lockedProperties ?? null)
@@ -903,20 +904,22 @@ export class AssetRepository {
.execute(); .execute();
} }
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>): Promise<void> { async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
const value = { ...file, assetId: asUuid(file.assetId) }; const value = { ...file, assetId: asUuid(file.assetId) };
await this.db await this.db
.insertInto('asset_file') .insertInto('asset_file')
.values(value) .values(value)
.onConflict((oc) => .onConflict((oc) =>
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'), path: eb.ref('excluded.path'),
})), })),
) )
.execute(); .execute();
} }
async upsertFiles(files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>[]): Promise<void> { async upsertFiles(
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
): Promise<void> {
if (files.length === 0) { if (files.length === 0) {
return; return;
} }
@@ -926,7 +929,7 @@ export class AssetRepository {
.insertInto('asset_file') .insertInto('asset_file')
.values(values) .values(values)
.onConflict((oc) => .onConflict((oc) =>
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'), path: eb.ref('excluded.path'),
})), })),
) )

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,9 @@
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,6 +93,9 @@ export class AssetExifTable {
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
rating!: number | null; rating!: number | null;
@Column({ type: 'character varying', array: true, nullable: true })
tags!: string[] | null;
@UpdateDateColumn({ default: () => 'clock_timestamp()' }) @UpdateDateColumn({ default: () => 'clock_timestamp()' })
updatedAt!: Generated<Date>; updatedAt!: Generated<Date>;

View File

@@ -14,7 +14,7 @@ import {
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('asset_file') @Table('asset_file')
@Unique({ columns: ['assetId', 'type'] }) @Unique({ columns: ['assetId', 'type', 'isEdited'] })
@UpdatedAtTrigger('asset_file_updatedAt') @UpdatedAtTrigger('asset_file_updatedAt')
export class AssetFileTable { export class AssetFileTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@@ -37,4 +37,7 @@ export class AssetFileTable {
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
@Column({ type: 'boolean', default: false })
isEdited!: Generated<boolean>;
} }

View File

@@ -529,9 +529,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files, ...assetStub.withCropEdit.files,
{ {
id: 'edited-file', id: 'edited-file',
type: AssetFileType.FullSizeEdited, type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg', path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile, isEdited: true,
},
], ],
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -554,9 +555,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files, ...assetStub.withCropEdit.files,
{ {
id: 'edited-file', id: 'edited-file',
type: AssetFileType.FullSizeEdited, type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg', path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile, isEdited: true,
},
], ],
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -579,9 +581,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files, ...assetStub.withCropEdit.files,
{ {
id: 'edited-file', id: 'edited-file',
type: AssetFileType.FullSizeEdited, type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg', path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile, isEdited: true,
},
], ],
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -656,6 +659,7 @@ describe(AssetMediaService.name, () => {
id: '42', id: '42',
path: '/path/to/preview', path: '/path/to/preview',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
isEdited: false,
}, },
], ],
}); });
@@ -673,6 +677,7 @@ describe(AssetMediaService.name, () => {
id: '42', id: '42',
path: '/path/to/preview.jpg', path: '/path/to/preview.jpg',
type: AssetFileType.Preview, 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 { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { AssetFile } from 'src/database'; import { AssetFile } from 'src/database';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { import {
AssetBulkDeleteDto, AssetBulkDeleteDto,
AssetBulkUpdateDto, AssetBulkUpdateDto,
@@ -112,7 +112,7 @@ export class AssetService extends BaseService {
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository }; const repos = { asset: this.assetRepository, event: this.eventRepository };
let previousMotion: MapAsset | null = null; let previousMotion: { id: string } | null = null;
if (rest.livePhotoVideoId) { if (rest.livePhotoVideoId) {
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
} else if (rest.livePhotoVideoId === null) { } 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); await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.FullSize, pathType: AssetFileType.FullSize,
oldPath: '/uploads/user-id/fullsize/path.webp', 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({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.Preview, pathType: AssetFileType.Preview,
oldPath: '/uploads/user-id/thumbs/path.jpg', 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({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.Thumbnail, pathType: AssetFileType.Thumbnail,
oldPath: '/uploads/user-id/webp/path.ext', 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); expect(mocks.move.create).toHaveBeenCalledTimes(3);
}); });
@@ -385,11 +385,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
]); ]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
@@ -421,11 +423,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
]); ]);
}); });
@@ -456,11 +460,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
]); ]);
}); });
@@ -548,8 +554,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = `/data/thumbs/user-id/as/se/asset-id-preview.${format}`; 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 thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`;
await sut.handleGenerateThumbnails({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@@ -595,8 +601,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-preview.jpeg`); 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 thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_thumbnail.${format}`);
await sut.handleGenerateThumbnails({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@@ -1026,9 +1032,9 @@ describe(MediaService.name, () => {
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ type: AssetFileType.FullSizeEdited }), expect.objectContaining({ type: AssetFileType.FullSize, isEdited: true }),
expect.objectContaining({ type: AssetFileType.PreviewEdited }), expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
expect.objectContaining({ type: AssetFileType.ThumbnailEdited }), expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
]), ]),
); );
}); });
@@ -1098,17 +1104,17 @@ describe(MediaService.name, () => {
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
expect.anything(), expect.anything(),
expect.stringContaining('edited_preview.jpeg'), expect.stringContaining('preview_edited.jpeg'),
); );
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
expect.anything(), expect.anything(),
expect.stringContaining('edited_thumbnail.webp'), expect.stringContaining('thumbnail_edited.webp'),
); );
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
expect.anything(), expect.anything(),
expect.stringContaining('edited_fullsize.jpeg'), expect.stringContaining('fullsize_edited.jpeg'),
); );
}); });
@@ -3254,13 +3260,13 @@ describe(MediaService.name, () => {
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
]); ]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
@@ -3270,19 +3276,31 @@ describe(MediaService.name, () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [ files: [
{ 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' }, 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,
},
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
]); ]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3295,17 +3313,38 @@ describe(MediaService.name, () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [ files: [
{ 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' }, 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,
},
], ],
}; };
await sut['syncFiles'](asset, [{ type: AssetFileType.Preview }, { type: AssetFileType.Thumbnail }]); await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, isEdited: false },
{ type: AssetFileType.Thumbnail, isEdited: false },
]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, { 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' }, {
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
]); ]);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete, name: JobName.FileDelete,
@@ -3317,14 +3356,26 @@ describe(MediaService.name, () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [ files: [
{ 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' }, 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,
},
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg' }, { type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg' }, { type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
]); ]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
@@ -3336,23 +3387,41 @@ describe(MediaService.name, () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [ files: [
{ 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' }, 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,
},
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, // replace { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace
{ type: AssetFileType.Thumbnail }, // delete { type: AssetFileType.Thumbnail, isEdited: false }, // delete
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg' }, // new { type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize }, { assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
]); ]);
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, {
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
]); ]);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete, name: JobName.FileDelete,
@@ -3376,11 +3445,19 @@ describe(MediaService.name, () => {
it('should delete non-existent file types when newPath is not provided', async () => { it('should delete non-existent file types when newPath is not provided', async () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }], files: [
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Thumbnail }, // file doesn't exist, newPath not provided { type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
]); ]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();

View File

@@ -8,7 +8,6 @@ import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { import {
AssetFileType, AssetFileType,
AssetPathType,
AssetType, AssetType,
AssetVisibility, AssetVisibility,
AudioCodec, AudioCodec,
@@ -50,6 +49,7 @@ interface UpsertFileOptions {
assetId: string; assetId: string;
type: AssetFileType; type: AssetFileType;
path: string; path: string;
isEdited: boolean;
} }
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>; type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
@@ -160,9 +160,9 @@ export class MediaService extends BaseService {
return JobStatus.Failed; return JobStatus.Failed;
} }
await this.storageCore.moveAssetImage(asset, AssetPathType.FullSize, image.fullsize.format); await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format); await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.format); await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
await this.storageCore.moveAssetVideo(asset); await this.storageCore.moveAssetVideo(asset);
return JobStatus.Success; return JobStatus.Success;
@@ -236,9 +236,9 @@ export class MediaService extends BaseService {
} }
await this.syncFiles(asset, [ await this.syncFiles(asset, [
{ type: AssetFileType.Preview, newPath: generated.previewPath }, { type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath }, { type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false },
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath }, { type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false },
]); ]);
const editiedGenerated = await this.generateEditedThumbnails(asset); const editiedGenerated = await this.generateEditedThumbnails(asset);
@@ -307,16 +307,16 @@ export class MediaService extends BaseService {
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) { private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) {
const { image } = await this.getConfig({ withCache: true }); const { image } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath( const previewPath = StorageCore.getImagePath(asset, {
asset, fileType: AssetFileType.Preview,
useEdits ? AssetPathType.EditedPreview : AssetPathType.Preview, isEdited: useEdits,
image.preview.format, format: image.preview.format,
); });
const thumbnailPath = StorageCore.getImagePath( const thumbnailPath = StorageCore.getImagePath(asset, {
asset, fileType: AssetFileType.Thumbnail,
useEdits ? AssetPathType.EditedThumbnail : AssetPathType.Thumbnail, isEdited: useEdits,
image.thumbnail.format, format: image.thumbnail.format,
); });
this.storageCore.ensureFolders(previewPath); this.storageCore.ensureFolders(previewPath);
// Handle embedded preview extraction for RAW files // Handle embedded preview extraction for RAW files
@@ -343,11 +343,11 @@ export class MediaService extends BaseService {
if (convertFullsize) { if (convertFullsize) {
// convert a new fullsize image from the same source as the thumbnail // convert a new fullsize image from the same source as the thumbnail
fullsizePath = StorageCore.getImagePath( fullsizePath = StorageCore.getImagePath(asset, {
asset, fileType: AssetFileType.FullSize,
useEdits ? AssetPathType.EditedFullSize : AssetPathType.FullSize, isEdited: useEdits,
image.fullsize.format, format: image.fullsize.format,
); });
const fullsizeOptions = { const fullsizeOptions = {
format: image.fullsize.format, format: image.fullsize.format,
quality: image.fullsize.quality, quality: image.fullsize.quality,
@@ -355,7 +355,11 @@ export class MediaService extends BaseService {
}; };
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format); fullsizePath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.FullSize,
format: extracted.format,
isEdited: false,
});
this.storageCore.ensureFolders(fullsizePath); this.storageCore.ensureFolders(fullsizePath);
// Write the buffer to disk with essential EXIF data // Write the buffer to disk with essential EXIF data
@@ -489,8 +493,16 @@ export class MediaService extends BaseService {
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) { private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
const { image, ffmpeg } = await this.getConfig({ withCache: true }); const { image, ffmpeg } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); const previewPath = StorageCore.getImagePath(asset, {
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); fileType: AssetFileType.Preview,
format: image.preview.format,
isEdited: false,
});
const thumbnailPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
isEdited: false,
});
this.storageCore.ensureFolders(previewPath); this.storageCore.ensureFolders(previewPath);
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
@@ -779,18 +791,18 @@ export class MediaService extends BaseService {
private async syncFiles( private async syncFiles(
asset: { id: string; files: AssetFile[] }, asset: { id: string; files: AssetFile[] },
files: { type: AssetFileType; newPath?: string }[], files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
) { ) {
const toUpsert: UpsertFileOptions[] = []; const toUpsert: UpsertFileOptions[] = [];
const pathsToDelete: string[] = []; const pathsToDelete: string[] = [];
const toDelete: AssetFile[] = []; const toDelete: AssetFile[] = [];
for (const { type, newPath } of files) { for (const { type, newPath, isEdited } of files) {
const existingFile = asset.files.find((file) => file.type === type); const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
// upsert new file path // upsert new file path
if (newPath && existingFile?.path !== newPath) { if (newPath && existingFile?.path !== newPath) {
toUpsert.push({ assetId: asset.id, path: newPath, type }); toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
// delete old file from disk // delete old file from disk
if (existingFile) { if (existingFile) {
@@ -829,9 +841,9 @@ export class MediaService extends BaseService {
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined; const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined;
await this.syncFiles(asset, [ await this.syncFiles(asset, [
{ type: AssetFileType.PreviewEdited, newPath: generated?.previewPath }, { type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true },
{ type: AssetFileType.ThumbnailEdited, newPath: generated?.thumbnailPath }, { type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true },
{ type: AssetFileType.FullSizeEdited, newPath: generated?.fullsizePath }, { type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true },
]); ]);
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop); const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);

View File

@@ -35,7 +35,7 @@ const forSidecarJob = (
asset: { asset: {
id?: string; id?: string;
originalPath?: string; originalPath?: string;
files?: { id: string; type: AssetFileType; path: string }[]; files?: { id: string; type: AssetFileType; path: string; isEdited: boolean }[];
} = {}, } = {},
) => { ) => {
return { return {
@@ -387,6 +387,7 @@ describe(MetadataService.name, () => {
it('should extract tags from TagsList', async () => { it('should extract tags from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
mockReadTags({ TagsList: ['Parent'] }); mockReadTags({ TagsList: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -397,6 +398,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchy from TagsList', async () => { it('should extract hierarchy from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
mockReadTags({ TagsList: ['Parent/Child'] }); mockReadTags({ TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -417,6 +419,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a string', async () => { it('should extract tags from Keywords as a string', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
mockReadTags({ Keywords: 'Parent' }); mockReadTags({ Keywords: 'Parent' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -427,6 +430,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a list', async () => { it('should extract tags from Keywords as a list', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
mockReadTags({ Keywords: ['Parent'] }); mockReadTags({ Keywords: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -437,6 +441,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a list with a number', async () => { it('should extract tags from Keywords as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
mockReadTags({ Keywords: ['Parent', 2024] }); mockReadTags({ Keywords: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -448,6 +453,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchal tags from Keywords', async () => { it('should extract hierarchal tags from Keywords', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
mockReadTags({ Keywords: 'Parent/Child' }); mockReadTags({ Keywords: 'Parent/Child' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -467,6 +473,7 @@ describe(MetadataService.name, () => {
it('should ignore Keywords when TagsList is present', async () => { it('should ignore Keywords when TagsList is present', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any);
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -486,6 +493,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchy from HierarchicalSubject', async () => { it('should extract hierarchy from HierarchicalSubject', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any);
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -507,6 +515,7 @@ describe(MetadataService.name, () => {
it('should extract tags from HierarchicalSubject as a list with a number', async () => { it('should extract tags from HierarchicalSubject as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -518,6 +527,7 @@ describe(MetadataService.name, () => {
it('should extract ignore / characters in a HierarchicalSubject tag', async () => { it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any);
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
@@ -532,6 +542,7 @@ describe(MetadataService.name, () => {
it('should ignore HierarchicalSubject when TagsList is present', async () => { it('should ignore HierarchicalSubject when TagsList is present', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); 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'] }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -896,6 +907,7 @@ describe(MetadataService.name, () => {
ProfileDescription: 'extensive description', ProfileDescription: 'extensive description',
ProjectionType: 'equirectangular', ProjectionType: 'equirectangular',
tz: 'UTC-11:30', tz: 'UTC-11:30',
TagsList: ['parent/child'],
Rating: 3, Rating: 3,
}; };
@@ -935,6 +947,7 @@ describe(MetadataService.name, () => {
country: null, country: null,
state: null, state: null,
city: null, city: null,
tags: ['parent/child'],
}, },
{ lockedPropertiesBehavior: 'skip' }, { lockedPropertiesBehavior: 'skip' },
); );
@@ -1084,6 +1097,7 @@ describe(MetadataService.name, () => {
id: 'some-id', id: 'some-id',
type: AssetFileType.Sidecar, type: AssetFileType.Sidecar,
path: '/path/to/something', path: '/path/to/something',
isEdited: false,
}, },
], ],
}); });
@@ -1691,7 +1705,7 @@ describe(MetadataService.name, () => {
it('should unset sidecar path if file no longer exist', async () => { it('should unset sidecar path if file no longer exist', async () => {
const asset = forSidecarJob({ const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg', originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }], files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
}); });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset); mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValue(false); mocks.storage.checkFileExists.mockResolvedValue(false);
@@ -1704,7 +1718,7 @@ describe(MetadataService.name, () => {
it('should do nothing if the sidecar file still exists', async () => { it('should do nothing if the sidecar file still exists', async () => {
const asset = forSidecarJob({ const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg', originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }], files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
}); });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset); mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);

View File

@@ -254,6 +254,8 @@ export class MetadataService extends BaseService {
} }
} }
const tags = this.getTagList(exifTags);
const exifData: Insertable<AssetExifTable> = { const exifData: Insertable<AssetExifTable> = {
assetId: asset.id, assetId: asset.id,
@@ -296,6 +298,8 @@ export class MetadataService extends BaseService {
// grouping // grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(exifTags), autoStackId: this.getAutoStackId(exifTags),
tags: tags.length > 0 ? tags : null,
}; };
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation); const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
@@ -316,9 +320,10 @@ export class MetadataService extends BaseService {
width: asset.width == null ? assetWidth : undefined, width: asset.width == null ? assetWidth : undefined,
height: asset.height == null ? assetHeight : undefined, height: asset.height == null ? assetHeight : undefined,
}), }),
this.applyTagList(asset, exifTags),
]; ];
await this.applyTagList(asset);
if (this.isMotionPhoto(asset, exifTags)) { if (this.isMotionPhoto(asset, exifTags)) {
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
} }
@@ -405,35 +410,35 @@ export class MetadataService extends BaseService {
@OnEvent({ name: 'AssetTag' }) @OnEvent({ name: 'AssetTag' })
async handleTagAsset({ assetId }: ArgOf<'AssetTag'>) { async handleTagAsset({ assetId }: ArgOf<'AssetTag'>) {
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } }); await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId } });
} }
@OnEvent({ name: 'AssetUntag' }) @OnEvent({ name: 'AssetUntag' })
async handleUntagAsset({ assetId }: ArgOf<'AssetUntag'>) { async handleUntagAsset({ assetId }: ArgOf<'AssetUntag'>) {
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } }); await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId } });
} }
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar }) @OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> { async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
const { id, tags } = job; const { id } = job;
const asset = await this.assetJobRepository.getForSidecarWriteJob(id); const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
if (!asset) { if (!asset) {
return JobStatus.Failed; return JobStatus.Failed;
} }
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id); const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
const tagsList = (asset.tags || []).map((tag) => tag.value);
const { sidecarFile } = getAssetFiles(asset.files); const { sidecarFile } = getAssetFiles(asset.files);
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`; const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
const { description, dateTimeOriginal, latitude, longitude, rating } = _.pick( const { description, dateTimeOriginal, latitude, longitude, rating, tags } = _.pick(
{ {
description: asset.exifInfo.description, description: asset.exifInfo.description,
dateTimeOriginal: asset.exifInfo.dateTimeOriginal, dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
latitude: asset.exifInfo.latitude, latitude: asset.exifInfo.latitude,
longitude: asset.exifInfo.longitude, longitude: asset.exifInfo.longitude,
rating: asset.exifInfo.rating, rating: asset.exifInfo.rating,
tags: asset.exifInfo.tags,
}, },
lockedProperties, lockedProperties,
); );
@@ -446,7 +451,7 @@ export class MetadataService extends BaseService {
GPSLatitude: latitude, GPSLatitude: latitude,
GPSLongitude: longitude, GPSLongitude: longitude,
Rating: rating, Rating: rating,
TagsList: tags ? tagsList : undefined, TagsList: tags?.length ? tags : undefined,
}, },
_.isUndefined, _.isUndefined,
); );
@@ -560,11 +565,14 @@ export class MetadataService extends BaseService {
return tags; return tags;
} }
private async applyTagList(asset: { id: string; ownerId: string }, exifTags: ImmichTags) { private async applyTagList({ id, ownerId }: { id: string; ownerId: string }) {
const tags = this.getTagList(exifTags); const asset = await this.assetRepository.getById(id, { exifInfo: true });
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); const results = await upsertTags(this.tagRepository, {
userId: ownerId,
tags: asset?.exifInfo?.tags ?? [],
});
await this.tagRepository.replaceAssetTags( await this.tagRepository.replaceAssetTags(
asset.id, id,
results.map((tag) => tag.id), results.map((tag) => tag.id),
); );
} }

View File

@@ -372,7 +372,7 @@ describe(NotificationService.name, () => {
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([ mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' }, { id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg', isEdited: false },
]); ]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
@@ -403,7 +403,7 @@ describe(NotificationService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([{ ...assetStub.image.files[2], isEdited: false }]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(

View File

@@ -240,11 +240,11 @@ export class StorageTemplateService extends BaseService {
assetInfo: { sizeInBytes: fileSizeInByte, checksum }, assetInfo: { sizeInBytes: fileSizeInByte, checksum },
}); });
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path; const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar, { isEdited: false })?.path;
if (sidecarPath) { if (sidecarPath) {
await this.storageCore.moveFile({ await this.storageCore.moveFile({
entityId: id, entityId: id,
pathType: AssetPathType.Sidecar, pathType: AssetFileType.Sidecar,
oldPath: sidecarPath, oldPath: sidecarPath,
newPath: `${newPath}.xmp`, newPath: `${newPath}.xmp`,
}); });

View File

@@ -191,6 +191,7 @@ describe(TagService.name, () => {
it('should upsert records', async () => { it('should upsert records', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); 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.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([ mocks.tag.upsertAssetIds.mockResolvedValue([
{ tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' }, { tagId: 'tag-1', assetId: 'asset-2' },
@@ -204,6 +205,18 @@ describe(TagService.name, () => {
).resolves.toEqual({ ).resolves.toEqual({
count: 6, 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([ expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
{ tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' }, { tagId: 'tag-1', assetId: 'asset-2' },
@@ -229,6 +242,7 @@ describe(TagService.name, () => {
mocks.tag.get.mockResolvedValue(tagStub.tag); mocks.tag.get.mockResolvedValue(tagStub.tag);
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
mocks.tag.addAssetIds.mockResolvedValue(); mocks.tag.addAssetIds.mockResolvedValue();
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
await expect( await expect(
@@ -240,6 +254,14 @@ describe(TagService.name, () => {
{ id: 'asset-2', success: true }, { 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.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
}); });

View File

@@ -90,6 +90,7 @@ export class TagService extends BaseService {
const results = await this.tagRepository.upsertAssetIds(items); const results = await this.tagRepository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetId))) { for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.updateTags(assetId);
await this.eventRepository.emit('AssetTag', { assetId }); await this.eventRepository.emit('AssetTag', { assetId });
} }
@@ -107,6 +108,7 @@ export class TagService extends BaseService {
for (const { id: assetId, success } of results) { for (const { id: assetId, success } of results) {
if (success) { if (success) {
await this.updateTags(assetId);
await this.eventRepository.emit('AssetTag', { assetId }); await this.eventRepository.emit('AssetTag', { assetId });
} }
} }
@@ -125,6 +127,7 @@ export class TagService extends BaseService {
for (const { id: assetId, success } of results) { for (const { id: assetId, success } of results) {
if (success) { if (success) {
await this.updateTags(assetId);
await this.eventRepository.emit('AssetUntag', { assetId }); await this.eventRepository.emit('AssetUntag', { assetId });
} }
} }
@@ -145,4 +148,12 @@ export class TagService extends BaseService {
} }
return tag; 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 // Sidecar Scanning
| { name: JobName.SidecarQueueAll; data: IBaseJob } | { name: JobName.SidecarQueueAll; data: IBaseJob }
| { name: JobName.SidecarCheck; data: IEntityJob } | { name: JobName.SidecarCheck; data: IEntityJob }
| { name: JobName.SidecarWrite; data: ISidecarWriteJob } | { name: JobName.SidecarWrite; data: IEntityJob }
// Facial Recognition // Facial Recognition
| { name: JobName.AssetDetectFacesQueueAll; data: IBaseJob } | { name: JobName.AssetDetectFacesQueueAll; data: IBaseJob }

View File

@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database'; import { AssetFile, Exif } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.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 { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
import { checkAccess } from 'src/utils/access'; import { checkAccess } from 'src/utils/access';
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => { export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
return files.find((file) => file.type === type); return files.find((file) => file.type === type && file.isEdited === isEdited);
}; };
export const getAssetFiles = (files: AssetFile[]) => ({ export const getAssetFiles = (files: AssetFile[]) => ({
fullsizeFile: getAssetFile(files, AssetFileType.FullSize), fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
previewFile: getAssetFile(files, AssetFileType.Preview), previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
sidecarFile: getAssetFile(files, AssetFileType.Sidecar), sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSizeEdited), editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
editedPreviewFile: getAssetFile(files, AssetFileType.PreviewEdited), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.ThumbnailEdited), editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
}); });
export const addAssets = async ( export const addAssets = async (

View File

@@ -31,18 +31,21 @@ const sidecarFileWithoutExt = factory.assetFile({
}); });
const editedPreviewFile = factory.assetFile({ const editedPreviewFile = factory.assetFile({
type: AssetFileType.PreviewEdited, type: AssetFileType.Preview,
path: '/uploads/user-id/preview/path_edited.jpg', path: '/uploads/user-id/preview/path_edited.jpg',
isEdited: true,
}); });
const editedThumbnailFile = factory.assetFile({ const editedThumbnailFile = factory.assetFile({
type: AssetFileType.ThumbnailEdited, type: AssetFileType.Thumbnail,
path: '/uploads/user-id/thumbnail/path_edited.jpg', path: '/uploads/user-id/thumbnail/path_edited.jpg',
isEdited: true,
}); });
const editedFullsizeFile = factory.assetFile({ const editedFullsizeFile = factory.assetFile({
type: AssetFileType.FullSizeEdited, type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path_edited.jpg', path: '/uploads/user-id/fullsize/path_edited.jpg',
isEdited: true,
}); });
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];

View File

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

View File

@@ -335,6 +335,7 @@ const assetSidecarWriteFactory = () => {
id: newUuid(), id: newUuid(),
path: '/path/to/original-path.jpg.xmp', path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar, type: AssetFileType.Sidecar,
isEdited: false,
}, },
], ],
exifInfo: { exifInfo: {
@@ -386,6 +387,7 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
id: newUuid(), id: newUuid(),
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg', path: '/uploads/user-id/thumbs/path.jpg',
isEdited: false,
...file, ...file,
}); });

View File

@@ -11,6 +11,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-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 { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -321,6 +322,11 @@
await handleGetAllAlbums(); await handleGetAllAlbums();
break; break;
} }
case AssetAction.DELETE:
case AssetAction.TRASH: {
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: { case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack; stack = action.stack;
if (stack) { if (stack) {

View File

@@ -10,6 +10,7 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate } from '$app/navigation'; import { afterNavigate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { Theme } from '$lib/constants'; import { Theme } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte'; import { themeManager } from '$lib/managers/theme-manager.svelte';
@@ -292,8 +293,14 @@
untrack(() => map?.jumpTo({ center, zoom })); untrack(() => map?.jumpTo({ center, zoom }));
}); });
const onAssetsDelete = async () => {
mapMarkers = await loadMapMarkers();
};
</script> </script>
<OnEvents {onAssetsDelete} />
<!-- We handle style loading ourselves so we set style blank here --> <!-- We handle style loading ourselves so we set style blank here -->
<MapLibre <MapLibre
{hash} {hash}

View File

@@ -4,7 +4,13 @@ import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { updatePerson, type PersonResponseDto } from '@immich/sdk'; import { updatePerson, type PersonResponseDto } from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCalendarEditOutline } from '@mdi/js'; import {
mdiCalendarEditOutline,
mdiEyeOffOutline,
mdiEyeOutline,
mdiHeartMinusOutline,
mdiHeartOutline,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto) => { export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto) => {
@@ -14,7 +20,83 @@ export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto
onAction: () => modalManager.show(PersonEditBirthDateModal, { person }), onAction: () => modalManager.show(PersonEditBirthDateModal, { person }),
}; };
return { SetDateOfBirth }; 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'));
}
}; };
export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string) => { export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string) => {

View File

@@ -4,7 +4,6 @@
import { clickOutside } from '$lib/actions/click-outside'; import { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation'; import { listNavigation } from '$lib/actions/list-navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; 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 ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte'; import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
@@ -42,16 +41,12 @@
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { isExternalUrl } from '$lib/utils/navigation'; import { isExternalUrl } from '$lib/utils/navigation';
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
import { LoadingSpinner, modalManager, toastManager } from '@immich/ui'; import { ContextMenuButton, LoadingSpinner, modalManager, toastManager, type ActionItem } from '@immich/ui';
import { import {
mdiAccountBoxOutline, mdiAccountBoxOutline,
mdiAccountMultipleCheckOutline, mdiAccountMultipleCheckOutline,
mdiArrowLeft, mdiArrowLeft,
mdiDotsVertical, mdiDotsVertical,
mdiEyeOffOutline,
mdiEyeOutline,
mdiHeartMinusOutline,
mdiHeartOutline,
mdiPlus, mdiPlus,
} from '@mdi/js'; } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@@ -144,37 +139,6 @@
viewMode = PersonPageViewMode.UNASSIGN_ASSETS; 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) => { const handleMerge = async (person: PersonResponseDto) => {
await updateAssetCount(); await updateAssetCount();
await handleGoBack(); await handleGoBack();
@@ -325,13 +289,35 @@
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
}; };
const onPersonUpdate = (response: PersonResponseDto) => { const onPersonUpdate = async (response: PersonResponseDto) => {
if (person.id === response.id) { if (response.id !== person.id) {
return (person = response); return;
} }
if (response.isHidden) {
await goto(previousRoute);
return;
}
person = response;
}; };
const { SetDateOfBirth } = $derived(getPersonActions($t, person)); 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;
},
};
</script> </script>
<OnEvents {onPersonUpdate} onAssetsDelete={updateAssetCount} onAssetsArchive={updateAssetCount} /> <OnEvents {onPersonUpdate} onAssetsDelete={updateAssetCount} onAssetsArchive={updateAssetCount} />
@@ -507,29 +493,10 @@
{#if viewMode === PersonPageViewMode.VIEW_ASSETS} {#if viewMode === PersonPageViewMode.VIEW_ASSETS}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}> <ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
{#snippet trailing()} {#snippet trailing()}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ContextMenuButton
<MenuOption items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
text={$t('select_featured_photo')} aria-label={$t('open')}
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} {/snippet}
</ControlAppBar> </ControlAppBar>
{/if} {/if}

View File

@@ -40,9 +40,9 @@
</script> </script>
<AuthPageLayout withHeader={false}> <AuthPageLayout withHeader={false}>
{#if hasPinCode} <div class="flex items-center justify-center">
<div class="flex items-center justify-center"> <div class="w-96 flex flex-col gap-6 items-center justify-center">
<div class="w-96 flex flex-col gap-6 items-center justify-center"> {#if hasPinCode}
{#if isVerified} {#if isVerified}
<div in:fade={{ duration: 200 }}> <div in:fade={{ duration: 200 }}>
<Icon icon={mdiLockOpenVariantOutline} size="64" class="text-success/90" /> <Icon icon={mdiLockOpenVariantOutline} size="64" class="text-success/90" />
@@ -64,13 +64,7 @@
pinLength={6} pinLength={6}
onFilled={handleUnlockSession} 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"> <div class="text-primary">
<Icon icon={mdiLockSmart} size="64" /> <Icon icon={mdiLockSmart} size="64" />
</div> </div>
@@ -78,7 +72,11 @@
{$t('new_pin_code_subtitle')} {$t('new_pin_code_subtitle')}
</p> </p>
<PinCodeCreateForm showLabel={false} onCreated={() => (hasPinCode = true)} /> <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> </div>
{/if} </div>
</AuthPageLayout> </AuthPageLayout>