mirror of
https://github.com/immich-app/immich.git
synced 2026-07-09 05:39:33 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fceda1ced | |||
| 4fd58885e2 | |||
| c5222c7579 | |||
| c4f084f4a5 | |||
| 66e30d5fa1 | |||
| 053a52aec8 | |||
| 2ad554fc61 | |||
| 427321a102 | |||
| 655cd01d57 | |||
| cfd95f0196 |
+1
-1
@@ -28,4 +28,4 @@ run = "prettier --write ."
|
||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||
|
||||
[tools]
|
||||
wrangler = "4.100.0"
|
||||
wrangler = "4.107.0"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AssetVisibility,
|
||||
deleteAssets,
|
||||
LoginResponseDto,
|
||||
SharedLinkType,
|
||||
updateAsset,
|
||||
} from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -357,6 +358,32 @@ describe('/search', () => {
|
||||
expect(body.assets.items).toHaveLength(assets.length);
|
||||
});
|
||||
}
|
||||
|
||||
it('should reject shared link access without an album filter', async () => {
|
||||
const album = await utils.createAlbum(admin.accessToken, { albumName: 'foo' });
|
||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
});
|
||||
const { status, body } = await request(app).post(`/search/metadata?key=${sharedLink.key}`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual({ message: 'Shared link access is only allowed in combination with an albumIds filter' });
|
||||
});
|
||||
|
||||
it('should allow shared link access for albums', async () => {
|
||||
const asset = await utils.createAsset(admin.accessToken);
|
||||
const album = await utils.createAlbum(admin.accessToken, { albumName: 'foo', assetIds: [asset.id] });
|
||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
});
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post(`/search/metadata?key=${sharedLink.key}`)
|
||||
.send({ albumIds: [album.id] });
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /search/random', () => {
|
||||
|
||||
@@ -287,43 +287,43 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "11.6.0"
|
||||
version = "11.9.0"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120174"
|
||||
checksum = "sha256:7981d0109fffdf8f15b838243240c8c9f9114dd4b7111d4227c1453522d75b61"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748044"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120177"
|
||||
checksum = "sha256:3622d3010d6af601190e7b63994fb0fe297e805699145aec949f4879f2672fb5"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-linux-arm64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748040"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120176"
|
||||
checksum = "sha256:8d987d82585453bcf260ea5d0bae346d298f1a5e71bcde8d99976521408ad895"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-linux-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748039"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120175"
|
||||
checksum = "sha256:655fcf2609e3145cfd7c2f05113429523706d0a618f2957eaa6c3562f6a5998b"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-linux-x64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748037"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120180"
|
||||
checksum = "sha256:ca6f98a8911ec975509464026205e2b54aed74cecd36c5ae9932e5997bd7de2a"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748038"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-win32-x64.zip"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120173"
|
||||
checksum = "sha256:82c130717a59f237c49e8eaa8a7ed75c1d8d351a5b6dedeee3196b8e1b597c6e"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-win32-x64.zip"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/455748043"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
|
||||
@@ -16,7 +16,7 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
pnpm = "11.6.0"
|
||||
pnpm = "11.9.0"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
|
||||
@@ -61,6 +61,17 @@ sealed class BaseAsset {
|
||||
bool get isLocalOnly => storage == AssetState.local;
|
||||
bool get isRemoteOnly => storage == AssetState.remote;
|
||||
|
||||
// Same asset even if localId is known on one side but not the other (heroTag isn't stable then)
|
||||
bool refersToSameAsset(BaseAsset other) {
|
||||
if (remoteId != null && other.remoteId != null) {
|
||||
return remoteId == other.remoteId;
|
||||
}
|
||||
if (localId != null && other.localId != null) {
|
||||
return localId == other.localId;
|
||||
}
|
||||
return checksum != null && checksum == other.checksum;
|
||||
}
|
||||
|
||||
bool get isEditable => false;
|
||||
|
||||
// Overridden in subclasses
|
||||
|
||||
@@ -395,7 +395,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
@@ -414,7 +414,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
displayAsset = stackChildren.elementAt(stackIndex);
|
||||
}
|
||||
|
||||
final isCurrent = currentHeroTag == displayAsset.heroTag;
|
||||
final isCurrent = currentAsset != null && currentAsset.refersToSameAsset(displayAsset);
|
||||
|
||||
final viewportWidth = MediaQuery.widthOf(context);
|
||||
final viewportHeight = MediaQuery.heightOf(context);
|
||||
|
||||
@@ -86,13 +86,14 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
// This provider watches the buckets from the timeline service & args and serves the segments.
|
||||
// It should be used only after the timeline service and timeline args provider is overridden
|
||||
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
||||
final args = ref.watch(timelineArgsProvider);
|
||||
final columnCount = args.columnCount;
|
||||
final spacing = args.spacing;
|
||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||
// maxHeight is left out on purpose, a height-only change must not restart the bucket stream
|
||||
final (maxWidth, columnCount, spacing, groupByArg) = ref.watch(
|
||||
timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)),
|
||||
);
|
||||
final availableTileWidth = maxWidth - (spacing * (columnCount - 1));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
|
||||
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||
final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
|
||||
@@ -29,7 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
class Timeline extends ConsumerWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
@@ -62,35 +62,40 @@ class Timeline extends StatelessWidget {
|
||||
final Widget? loadingWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
builder: (_, constraints) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
// overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds,
|
||||
// a function override would stay locked to the first frame's constraints for the whole session
|
||||
timelineArgsProvider.overrideWithValue(
|
||||
TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: columnCount,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -169,13 +174,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.maxWidth != oldWidget.maxWidth) {
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final index = _getCurrentAssetIndex(segments);
|
||||
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
|
||||
final _ = ref.refresh(timelineArgsProvider);
|
||||
_restoreAssetIndex = index;
|
||||
});
|
||||
// The updated args already regenerate the segments, only remember the scroll position to restore it afterwards
|
||||
final segments = ref.read(timelineSegmentProvider).valueOrNull;
|
||||
if (segments != null && _scrollController.hasClients) {
|
||||
_restoreAssetIndex = _getCurrentAssetIndex(segments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
@@ -39,5 +40,10 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
// Drift re-emits a fresh but content-identical list on unrelated table updates,
|
||||
// which would dispose and rebuild the timeline service mid-load
|
||||
return ref
|
||||
.watch(timelineRepositoryProvider)
|
||||
.watchTimelineUserIds(currentUserId)
|
||||
.distinct(const ListEquality<String>().equals);
|
||||
});
|
||||
|
||||
Generated
+18
-3
@@ -302,7 +302,11 @@ class SearchApi {
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MetadataSearchDto] metadataSearchDto (required):
|
||||
Future<Response> searchAssetsWithHttpInfo(MetadataSearchDto metadataSearchDto, { Future<void>? abortTrigger, }) async {
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> searchAssetsWithHttpInfo(MetadataSearchDto metadataSearchDto, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/search/metadata';
|
||||
|
||||
@@ -313,6 +317,13 @@ class SearchApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
@@ -335,8 +346,12 @@ class SearchApi {
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MetadataSearchDto] metadataSearchDto (required):
|
||||
Future<SearchResponseDto?> searchAssets(MetadataSearchDto metadataSearchDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await searchAssetsWithHttpInfo(metadataSearchDto, abortTrigger: abortTrigger,);
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<SearchResponseDto?> searchAssets(MetadataSearchDto metadataSearchDto, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchAssetsWithHttpInfo(metadataSearchDto, key: key, slug: slug, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../unit/factories/local_asset_factory.dart';
|
||||
import '../../unit/factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
group('BaseAsset.refersToSameAsset', () {
|
||||
test('search/folder copy (localId null) matches the merged DB copy (localId set)', () {
|
||||
// #29472: search and folder assets arrive with localId null, then the viewer
|
||||
// watches the DB copy which fills localId. heroTag embeds localId, so it
|
||||
// diverges for the same asset and isCurrent used to go false.
|
||||
final searchCopy = RemoteAssetFactory.create(id: 'asset-1');
|
||||
final mergedCopy = searchCopy.copyWith(localId: 'local-1');
|
||||
|
||||
expect(searchCopy.localId, isNull);
|
||||
expect(mergedCopy.localId, 'local-1');
|
||||
expect(searchCopy.heroTag, isNot(mergedCopy.heroTag));
|
||||
|
||||
expect(searchCopy.refersToSameAsset(mergedCopy), isTrue);
|
||||
expect(mergedCopy.refersToSameAsset(searchCopy), isTrue);
|
||||
});
|
||||
|
||||
test('different remote assets are not the same', () {
|
||||
final a = RemoteAssetFactory.create(id: 'asset-1');
|
||||
final b = RemoteAssetFactory.create(id: 'asset-2');
|
||||
|
||||
expect(a.refersToSameAsset(b), isFalse);
|
||||
});
|
||||
|
||||
test('same checksum but different remote ids are not the same (duplicate files)', () {
|
||||
final a = RemoteAssetFactory.create(id: 'asset-1');
|
||||
final b = RemoteAssetFactory.create(id: 'asset-2').copyWith(checksum: a.checksum);
|
||||
|
||||
expect(a.checksum, b.checksum);
|
||||
expect(a.refersToSameAsset(b), isFalse);
|
||||
});
|
||||
|
||||
test('falls back to checksum when only one side has an id (local-only vs remote-only)', () {
|
||||
final remoteOnly = RemoteAssetFactory.create(id: 'asset-1');
|
||||
final localOnly = LocalAssetFactory.create(id: 'local-1').copyWith(checksum: remoteOnly.checksum);
|
||||
|
||||
expect(remoteOnly.refersToSameAsset(localOnly), isTrue);
|
||||
expect(localOnly.refersToSameAsset(remoteOnly), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
// A first fetch that never delivers - the state a suspended or storm-starved
|
||||
// bucket watch is stuck in when the timeline mounts on a zero-sized first frame
|
||||
class _FrozenBucketService implements TimelineService {
|
||||
final _ctrl = StreamController<List<Bucket>>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => _ctrl.stream;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _EmptyBucketService implements TimelineService {
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => Stream.value(const []);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
// Counts how many times the bucket stream is subscribed. Each subscription is a
|
||||
// fresh photo query, so the count is our proxy for "did the relayout re-run the
|
||||
// segment query for an input it does not use".
|
||||
class _CountingBucketService implements TimelineService {
|
||||
int watchCount = 0;
|
||||
final _ctrl = StreamController<List<Bucket>>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets => () {
|
||||
watchCount++;
|
||||
return _ctrl.stream;
|
||||
};
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('timeline args follow constraints after a zero-sized first frame while buckets are still loading', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = Size.zero;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
TimelineArgs? probed;
|
||||
final probe = Consumer(
|
||||
builder: (_, ref, __) {
|
||||
probed = ref.watch(timelineArgsProvider);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_FrozenBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(probed, isNotNull);
|
||||
expect(probed!.maxWidth, 0.0);
|
||||
|
||||
tester.view.physicalSize = const Size(1206, 2622);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
probed!.maxWidth,
|
||||
402.0,
|
||||
reason: 'args locked to the zero-sized first frame leave the timeline blank for the whole session',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('timeline args follow constraints after a zero-sized first frame once buckets resolve', (tester) async {
|
||||
tester.view.physicalSize = Size.zero;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
TimelineArgs? probed;
|
||||
final probe = SliverToBoxAdapter(
|
||||
child: Consumer(
|
||||
builder: (_, ref, __) {
|
||||
probed = ref.watch(timelineArgsProvider);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Timeline(
|
||||
withScrubber: false,
|
||||
readOnly: true,
|
||||
appBar: const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
topSliverWidget: probe,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
tester.view.physicalSize = const Size(1206, 2622);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(probed, isNotNull);
|
||||
expect(probed!.maxWidth, 402.0);
|
||||
});
|
||||
|
||||
testWidgets('a height-only relayout (multiselect app bar toggle) keeps the timeline, a width change refreshes it', (
|
||||
tester,
|
||||
) async {
|
||||
final service = _CountingBucketService();
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
tester.view.physicalSize = const Size(1206, 2622);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
TimelineArgs? probed;
|
||||
final probe = Consumer(
|
||||
builder: (_, ref, __) {
|
||||
probed = ref.watch(timelineArgsProvider);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(service),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final initialSubscriptions = service.watchCount;
|
||||
final initialWidth = probed!.maxWidth;
|
||||
final initialHeight = probed!.maxHeight;
|
||||
expect(initialSubscriptions, greaterThan(0));
|
||||
|
||||
// toggling multiselect changes the app bar, so only the available height moves
|
||||
tester.view.physicalSize = const Size(1206, 2000);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(probed!.maxHeight, isNot(initialHeight), reason: 'the height should have actually changed');
|
||||
expect(probed!.maxWidth, initialWidth);
|
||||
expect(
|
||||
service.watchCount,
|
||||
initialSubscriptions,
|
||||
reason: 'a height-only change must not re-run the bucket query for an input the segments do not use',
|
||||
);
|
||||
|
||||
// a real width change (rotation, fold, split screen) should refresh the tiles
|
||||
tester.view.physicalSize = const Size(1000, 2000);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(probed!.maxWidth, lessThan(initialWidth));
|
||||
expect(service.watchCount, greaterThan(initialSubscriptions));
|
||||
});
|
||||
}
|
||||
@@ -10552,7 +10552,24 @@
|
||||
"post": {
|
||||
"description": "Search for assets based on various metadata criteria.",
|
||||
"operationId": "searchAssets",
|
||||
"parameters": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"format": "prettier --cache --check i18n/",
|
||||
"format:fix": "prettier --cache --write --list-different i18n"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0",
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
},
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"oidc-provider": "^9.0.0",
|
||||
"tsx": "^4.20.6"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0"
|
||||
"packageManager": "pnpm@11.9.0"
|
||||
}
|
||||
|
||||
@@ -163,19 +163,26 @@
|
||||
"description": "Filter by distance to a coordinate",
|
||||
"properties": {
|
||||
"latitude": {
|
||||
"type": "string",
|
||||
"type": "number",
|
||||
"title": "Latitude",
|
||||
"description": "GPS latitude of a coordinate which the asset must be close to"
|
||||
"description": "GPS latitude of a coordinate which the asset must be close to",
|
||||
"minimum": -90,
|
||||
"maximum": 90,
|
||||
"precision": 0.000001
|
||||
},
|
||||
"longitude": {
|
||||
"type": "string",
|
||||
"type": "number",
|
||||
"title": "Longitude",
|
||||
"description": "GPS longitude of a coordinate which the asset must be close to"
|
||||
"description": "GPS longitude of a coordinate which the asset must be close to",
|
||||
"minimum": -180,
|
||||
"maximum": 180,
|
||||
"precision": 0.000001
|
||||
},
|
||||
"radius": {
|
||||
"type": "number",
|
||||
"title": "Maximum distance",
|
||||
"description": "How close in kilometres the asset must be to the given point"
|
||||
"description": "How close in kilometres the asset must be to the given point",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,10 +96,10 @@ const methods = wrapper<Manifest>({
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
|
||||
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
|
||||
const configLat = config.coordinate?.latitude;
|
||||
const configLon = config.coordinate?.longitude;
|
||||
|
||||
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
|
||||
if (configLat === undefined || configLon === undefined) {
|
||||
return { workflow: { continue: true } };
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"packageManager": "pnpm@11.6.0",
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
|
||||
@@ -32,5 +32,5 @@
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0"
|
||||
"packageManager": "pnpm@11.9.0"
|
||||
}
|
||||
|
||||
@@ -5764,13 +5764,18 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
|
||||
/**
|
||||
* Search assets by metadata
|
||||
*/
|
||||
export function searchAssets({ metadataSearchDto }: {
|
||||
export function searchAssets({ key, slug, metadataSearchDto }: {
|
||||
key?: string;
|
||||
slug?: string;
|
||||
metadataSearchDto: MetadataSearchDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: SearchResponseDto;
|
||||
}>("/search/metadata", oazapfts.json({
|
||||
}>(`/search/metadata${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: metadataSearchDto
|
||||
|
||||
+8
-8
@@ -49,14 +49,14 @@
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.219.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.219.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.67.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.65.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.71.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.220.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.220.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.68.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.66.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.72.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.219.0",
|
||||
"@opentelemetry/sdk-node": "^0.220.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^1.0.0",
|
||||
"@react-email/render": "^2.0.0",
|
||||
@@ -84,7 +84,7 @@
|
||||
"jose": "^6.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "0.28.17",
|
||||
"kysely": "0.29.2",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
@@ -116,7 +116,7 @@
|
||||
"ua-parser-js": "^2.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"validator": "^13.12.0",
|
||||
"zod": "4.3.6"
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.0",
|
||||
|
||||
@@ -28,7 +28,7 @@ export class SearchController {
|
||||
constructor(private service: SearchService) {}
|
||||
|
||||
@Post('metadata')
|
||||
@Authenticated({ permission: Permission.AssetRead })
|
||||
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Endpoint({
|
||||
summary: 'Search assets by metadata',
|
||||
|
||||
@@ -12,6 +12,9 @@ const JsonSchemaPropertySchema = z
|
||||
description: z.string().describe('Description'),
|
||||
default: z.any().optional().describe('Default value'),
|
||||
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
|
||||
minimum: z.number().optional().describe('Minimum value for number types'),
|
||||
maximum: z.number().optional().describe('Maximum value for number types'),
|
||||
precision: z.number().default(1).optional().describe('Smallest interval (granularity) for number types'),
|
||||
array: z.boolean().optional().describe('Type is an array type'),
|
||||
required: z.array(z.string()).optional().describe('A list of required properties'),
|
||||
uiHint: z
|
||||
|
||||
@@ -86,7 +86,7 @@ select
|
||||
from
|
||||
"asset_file"
|
||||
|
||||
-- IntegrityRepository.streamAssetPaths
|
||||
-- IntegrityRepository.streamAssetPathsForMissingFiles
|
||||
select
|
||||
"allPaths"."path" as "path",
|
||||
"allPaths"."assetId",
|
||||
|
||||
@@ -119,7 +119,7 @@ export class IntegrityRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamAssetPaths() {
|
||||
streamAssetPathsForMissingFiles() {
|
||||
return this.db
|
||||
.selectFrom((eb) =>
|
||||
eb
|
||||
@@ -143,7 +143,7 @@ export class IntegrityRepository {
|
||||
)
|
||||
.leftJoin('integrity_report', (join) =>
|
||||
join
|
||||
.on('integrity_report.type', '=', IntegrityReport.UntrackedFile)
|
||||
.on('integrity_report.type', '=', IntegrityReport.MissingFile)
|
||||
.on((eb) =>
|
||||
eb.or([
|
||||
eb('integrity_report.assetId', '=', eb.ref('allPaths.assetId')),
|
||||
|
||||
@@ -3,10 +3,9 @@ import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
|
||||
import ffmpeg, { FfprobeData, FfprobeStream } from 'fluent-ffmpeg';
|
||||
import _ from 'lodash';
|
||||
import { Duration } from 'luxon';
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { Writable } from 'node:stream';
|
||||
import { promisify } from 'node:util';
|
||||
import sharp from 'sharp';
|
||||
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
|
||||
import { Exif } from 'src/database';
|
||||
@@ -44,8 +43,6 @@ const probe = (input: string, options: string[]): Promise<FfprobeData> =>
|
||||
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
|
||||
);
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
sharp.concurrency(0);
|
||||
sharp.cache({ files: 0 });
|
||||
|
||||
@@ -291,33 +288,37 @@ export class MediaRepository {
|
||||
* Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
|
||||
* Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
|
||||
*/
|
||||
async probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
|
||||
const { stdout } = await execFile('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
String(streamIndex),
|
||||
'-show_entries',
|
||||
'packet=pts,duration,flags',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
input,
|
||||
]);
|
||||
probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
|
||||
const ffprobe = spawn(
|
||||
'ffprobe',
|
||||
[
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
String(streamIndex),
|
||||
'-show_entries',
|
||||
'packet=pts,duration,flags',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
input,
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
|
||||
let totalDuration = 0;
|
||||
const keyframePts: number[] = [];
|
||||
const keyframeAccDuration: number[] = [];
|
||||
const keyframeOwnDuration: number[] = [];
|
||||
const postDiscard: { pts: number; duration: number }[] = [];
|
||||
for (const line of stdout.split('\n')) {
|
||||
const parseLine = (line: string) => {
|
||||
if (!line) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
const [ptsStr, durationStr, flags] = line.split(',');
|
||||
const pts = Number.parseInt(ptsStr);
|
||||
const duration = Number.parseInt(durationStr);
|
||||
if (Number.isNaN(pts) || Number.isNaN(duration)) {
|
||||
continue;
|
||||
if (Number.isNaN(pts) || Number.isNaN(duration) || !flags) {
|
||||
return;
|
||||
}
|
||||
// Discarded packets don't contribute to packet count, but still contribute to video duration
|
||||
totalDuration += duration;
|
||||
@@ -332,20 +333,43 @@ export class MediaRepository {
|
||||
// Non-keyframes are accounted for in totalDuration.
|
||||
keyframeOwnDuration.push(duration);
|
||||
}
|
||||
}
|
||||
|
||||
if (postDiscard.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
totalDuration,
|
||||
packetCount: postDiscard.length,
|
||||
outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration),
|
||||
keyframePts,
|
||||
keyframeAccDuration,
|
||||
keyframeOwnDuration,
|
||||
};
|
||||
|
||||
let stderr = '';
|
||||
let remainder = '';
|
||||
ffprobe.stderr.setEncoding('utf8');
|
||||
ffprobe.stderr.on('data', (chunk: string) => (stderr += chunk));
|
||||
ffprobe.stdout.setEncoding('utf8');
|
||||
ffprobe.stdout.on('data', (chunk: string) => {
|
||||
const lines = chunk.split('\n');
|
||||
lines[0] = remainder + lines[0];
|
||||
remainder = lines.pop() as string;
|
||||
for (const line of lines) {
|
||||
parseLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise<VideoPacketInfo | null>((resolve, reject) => {
|
||||
ffprobe.on('error', reject);
|
||||
ffprobe.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`ffprobe exited with code ${code}: ${stderr.trim()}`));
|
||||
}
|
||||
parseLine(remainder);
|
||||
if (postDiscard.length === 0) {
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
resolve({
|
||||
totalDuration,
|
||||
packetCount: postDiscard.length,
|
||||
outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration),
|
||||
keyframePts,
|
||||
keyframeAccDuration,
|
||||
keyframeOwnDuration,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
||||
|
||||
@@ -342,20 +342,9 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
|
||||
if (!sharedLink.albumId) {
|
||||
await this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const album = await this.albumRepository.getById(sharedLink.albumId, { withAssets: false });
|
||||
if (!album) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.albumRepository.addAssetIds(album.id, [assetId]);
|
||||
for (const { user } of album.albumUsers) {
|
||||
await this.eventRepository.emit('AlbumUpdate', { id: album.id, recipientId: user.id });
|
||||
}
|
||||
await (sharedLink.albumId
|
||||
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
|
||||
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
|
||||
@@ -360,7 +360,7 @@ export class IntegrityService extends BaseService {
|
||||
|
||||
this.logger.log(`Scanning for missing files...`);
|
||||
|
||||
const assetPaths = this.integrityRepository.streamAssetPaths();
|
||||
const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles();
|
||||
|
||||
let total = 0;
|
||||
for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
|
||||
@@ -77,6 +77,8 @@ export class SearchService extends BaseService {
|
||||
|
||||
if (dto.albumIds && dto.albumIds.length > 0) {
|
||||
await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead });
|
||||
} else if (auth.sharedLink) {
|
||||
throw new BadRequestException('Shared link access is only allowed in combination with an albumIds filter');
|
||||
} else {
|
||||
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ describe('asDateString', () => {
|
||||
const date = new Date(2000, 0, 15); // 15 Jan 2000, local midnight
|
||||
expect(asDateString(date)).toBe('2000-01-15');
|
||||
});
|
||||
|
||||
it('should correctly pad years with a leading 0', () => {
|
||||
expect(asDateString(new Date('280-12-12'))).toBe('0280-12-12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('asDateTimeString', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { isIP, isIPRange } from 'validator';
|
||||
@@ -173,12 +174,7 @@ export const isoDateToDate = z
|
||||
z.date(),
|
||||
{
|
||||
decode: (isoString) => new Date(isoString),
|
||||
encode: (date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
},
|
||||
encode: (date) => DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'),
|
||||
},
|
||||
)
|
||||
.meta({ example: '2024-01-01' });
|
||||
|
||||
@@ -213,11 +213,6 @@ describe(AssetService.name, () => {
|
||||
const assets = [...result];
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toEqual(response.id);
|
||||
|
||||
expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: album.id,
|
||||
recipientId: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle adding a duplicate asset to an album shared link', async () => {
|
||||
|
||||
@@ -408,7 +408,7 @@ describe(IntegrityService.name, () => {
|
||||
} = await ctx.newAsset({ ownerId, originalPath: '/path/to/file2' });
|
||||
|
||||
const { id: reportId } = await ctx.get(IntegrityRepository).create({
|
||||
type: IntegrityReport.UntrackedFile,
|
||||
type: IntegrityReport.MissingFile,
|
||||
path: '/path/to/file2',
|
||||
assetId: assetId2,
|
||||
});
|
||||
|
||||
+2
-2
@@ -76,7 +76,7 @@
|
||||
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
|
||||
"@socket.io/component-emitter": "^3.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/enhanced-img": "^0.11.0",
|
||||
"@sveltejs/kit": "^2.56.1",
|
||||
"@sveltejs/vite-plugin-svelte": "7.1.2",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
@@ -105,7 +105,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^4.0.0",
|
||||
"rollup-plugin-visualizer": "^7.0.0",
|
||||
"svelte": "5.56.3",
|
||||
"svelte": "5.56.4",
|
||||
"svelte-check": "^4.4.6",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</Field>
|
||||
{:else if schema.type === 'number'}
|
||||
<Field {label} {description}>
|
||||
<NumberInput bind:value={getNumber, setValue} />
|
||||
<NumberInput bind:value={getNumber, setValue} step={schema.precision} min={schema.minimum} max={schema.maximum} />
|
||||
</Field>
|
||||
{:else if schema.type === 'string'}
|
||||
<Field {label} {description}>
|
||||
|
||||
@@ -247,7 +247,7 @@
|
||||
>
|
||||
<ImageThumbnail
|
||||
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
|
||||
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
|
||||
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', selected && 'rounded-2xl', brokenAssetClass]}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
|
||||
@@ -12,6 +12,10 @@ describe('cleanClass', () => {
|
||||
it('should unnest arrays', () => {
|
||||
expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3');
|
||||
});
|
||||
|
||||
it('should unnest doubly nested arrays', () => {
|
||||
expect(cleanClass('class1', [['class2a', 'class2b'], 'class3'])).toBe('class1 class2a class2b class3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDefined', () => {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export const cleanClass = (...classNames: unknown[]) => {
|
||||
return twMerge(
|
||||
classNames
|
||||
.flatMap((className) => (Array.isArray(className) ? className : [className]))
|
||||
flattenClass(classNames)
|
||||
.filter((className) => {
|
||||
if (!className || typeof className === 'boolean') {
|
||||
return false;
|
||||
@@ -16,6 +15,9 @@ export const cleanClass = (...classNames: unknown[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
const flattenClass = (classNames: unknown[]): unknown[] =>
|
||||
classNames.flatMap((className) => (Array.isArray(className) ? flattenClass(className) : [className]));
|
||||
|
||||
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== null && value !== undefined;
|
||||
|
||||
export const getHeatmapRange = () => {
|
||||
|
||||
@@ -93,6 +93,9 @@ export type JSONSchemaProperty = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default?: any;
|
||||
enum?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
precision?: number;
|
||||
array?: boolean;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
|
||||
@@ -242,6 +242,7 @@
|
||||
|
||||
function removeFilter(key: keyof SearchTerms) {
|
||||
delete terms[key];
|
||||
assetMultiSelectManager.clear();
|
||||
void goto(Route.search(terms));
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user