mirror of
https://github.com/immich-app/immich.git
synced 2026-01-25 10:54:37 -08:00
Compare commits
13 Commits
feat/isola
...
feat/dynam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd71f6379 | ||
|
|
ed9b4e795a | ||
|
|
93c19d1b2e | ||
|
|
074cc1db73 | ||
|
|
fb94ee80aa | ||
|
|
083ee0b5fe | ||
|
|
0bae88bef6 | ||
|
|
184f1a6d32 | ||
|
|
248cb86143 | ||
|
|
1649d87360 | ||
|
|
8970566865 | ||
|
|
0b4a96140e | ||
|
|
72caf8983c |
@@ -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',
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}"}),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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'),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
9
server/test/fixtures/asset.stub.ts
vendored
9
server/test/fixtures/asset.stub.ts
vendored
@@ -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];
|
||||||
|
|||||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@@ -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: [],
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user