mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 05:05:22 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8d1c6cec6 | |||
| ea53d6ac39 | |||
| 4eaac35aa6 | |||
| 7a923659d1 | |||
| 01712cf0a7 | |||
| 2015f95ff5 | |||
| d4f29ab6ac | |||
| 3decc864b5 | |||
| eca0e60db8 | |||
| 8cff5883b5 |
@@ -28,6 +28,10 @@ export const errorDto = {
|
||||
badRequest: (message: any = null) => ({
|
||||
message: message ?? expect.anything(),
|
||||
}),
|
||||
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
|
||||
message: 'Validation failed',
|
||||
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
|
||||
}),
|
||||
noPermission: {
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
},
|
||||
@@ -37,9 +41,6 @@ export const errorDto = {
|
||||
alreadyHasAdmin: {
|
||||
message: 'The server already has an admin',
|
||||
},
|
||||
invalidEmail: {
|
||||
message: ['email must be an email'],
|
||||
},
|
||||
};
|
||||
|
||||
export const signupResponseDto = {
|
||||
|
||||
@@ -110,7 +110,9 @@ describe('/libraries', () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create an external library with duplicate exclusion patterns', async () => {
|
||||
@@ -125,7 +127,9 @@ describe('/libraries', () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,7 +161,9 @@ describe('/libraries', () => {
|
||||
.send({ name: '' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should change the import paths', async () => {
|
||||
@@ -181,7 +187,9 @@ describe('/libraries', () => {
|
||||
.send({ importPaths: [''] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject duplicate import paths', async () => {
|
||||
@@ -191,7 +199,9 @@ describe('/libraries', () => {
|
||||
.send({ importPaths: ['/path', '/path'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should change the exclusion pattern', async () => {
|
||||
@@ -215,7 +225,9 @@ describe('/libraries', () => {
|
||||
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an empty exclusion pattern', async () => {
|
||||
@@ -225,7 +237,9 @@ describe('/libraries', () => {
|
||||
.send({ exclusionPatterns: [''] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,9 @@ describe('/map', () => {
|
||||
.get('/map/reverse-geocode?lon=123')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if a lat is not a number', async () => {
|
||||
@@ -117,7 +119,9 @@ describe('/map', () => {
|
||||
.get('/map/reverse-geocode?lat=abc&lon=123.456')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if a lat is out of range', async () => {
|
||||
@@ -125,7 +129,9 @@ describe('/map', () => {
|
||||
.get('/map/reverse-geocode?lat=91&lon=123.456')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if a lon is not provided', async () => {
|
||||
@@ -133,7 +139,9 @@ describe('/map', () => {
|
||||
.get('/map/reverse-geocode?lat=75')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
});
|
||||
|
||||
const reverseGeocodeTestCases = [
|
||||
|
||||
@@ -105,7 +105,11 @@ describe(`/oauth`, () => {
|
||||
it(`should throw an error if a redirect uri is not provided`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/authorize').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a redirect uri', async () => {
|
||||
@@ -164,13 +168,17 @@ describe(`/oauth`, () => {
|
||||
it(`should throw an error if a url is not provided`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should throw an error if the url is empty`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should throw an error if the state is not provided`, async () => {
|
||||
@@ -375,7 +383,11 @@ describe(`/oauth`, () => {
|
||||
it(`should throw an error if the logout_token is not provided`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[logout_token] Invalid input: expected string, received undefined']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['logout_token'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should throw an error if an invalid logout token is provided`, async () => {
|
||||
|
||||
@@ -341,7 +341,9 @@ describe('/shared-links', () => {
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require an asset/album id', async () => {
|
||||
|
||||
@@ -41,7 +41,9 @@ describe('/stacks', () => {
|
||||
.send({ assetIds: [asset.id] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
@@ -51,7 +53,12 @@ describe('/stacks', () => {
|
||||
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['assetIds', 0], message: 'Invalid UUID' },
|
||||
{ path: ['assetIds', 1], message: 'Invalid UUID' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require access', async () => {
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('/tags', () => {
|
||||
.get(`/tags/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should get tag details', async () => {
|
||||
@@ -427,7 +427,7 @@ describe('/tags', () => {
|
||||
.delete(`/tags/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should delete a tag', async () => {
|
||||
|
||||
@@ -108,14 +108,20 @@ describe('/admin/users', () => {
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) {
|
||||
for (const [key, message] of [
|
||||
['password', 'Invalid input: expected string, received null'],
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||
['notify', 'Invalid input: expected boolean, received null'],
|
||||
] as const) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/admin/users`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ...createUserDto.user1, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,14 +159,19 @@ describe('/admin/users', () => {
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
for (const key of ['password', 'email', 'name', 'shouldChangePassword']) {
|
||||
for (const [key, message] of [
|
||||
['password', 'Invalid input: expected string, received null'],
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||
] as const) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/admin/users/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,9 @@ describe('/users', () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']),
|
||||
errorDto.validationError([
|
||||
{ path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -207,7 +209,9 @@ describe('/users', () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']),
|
||||
errorDto.validationError([
|
||||
{ path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
|
||||
RUN apt-get update && \
|
||||
# Pascal support was dropped in 9.11
|
||||
apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 && \
|
||||
apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 tzdata && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -112,7 +112,7 @@ ARG RKNN_TOOLKIT_VERSION="v2.3.0"
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
|
||||
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
|
||||
|
||||
FROM prod-${DEVICE} AS prod
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ analyzer:
|
||||
- lib/**/*.g.dart
|
||||
- lib/**/*.drift.dart
|
||||
|
||||
# TODO: Re-enable after upgrading custom_lint
|
||||
# plugins:
|
||||
# - custom_lint
|
||||
errors:
|
||||
unawaited_futures: warning
|
||||
|
||||
plugins:
|
||||
riverpod_lint: ^3.1.3
|
||||
|
||||
custom_lint:
|
||||
rules:
|
||||
- avoid_build_context_in_providers: false
|
||||
|
||||
@@ -57,7 +57,11 @@ void main() async {
|
||||
|
||||
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
|
||||
} catch (error, stack) {
|
||||
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: BootstrapErrorWidget(error: error.toString(), stack: stack.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
@@ -333,7 +333,7 @@ class _QuickAccessButtonList extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final partnerSharedWithAsync = ref.watch(driftSharedWithPartnerProvider);
|
||||
final partners = partnerSharedWithAsync.valueOrNull ?? [];
|
||||
final partners = partnerSharedWithAsync.value ?? [];
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32),
|
||||
|
||||
@@ -639,7 +639,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_location'.t(context: context),
|
||||
currentFilter: locationCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
|
||||
if (userPreferences.value?.tagsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.sell_outlined,
|
||||
onTap: showTagPicker,
|
||||
@@ -665,7 +665,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
|
||||
if (userPreferences.value?.ratingsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.star_outline_rounded,
|
||||
onTap: showStarRatingPicker,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/search.service.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
|
||||
@@ -57,6 +57,12 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
ref.listenManual(
|
||||
remoteAlbumProvider.select((state) => state.albums),
|
||||
(_, _) => sortAlbums(),
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
|
||||
|
||||
@@ -19,7 +19,7 @@ class AssetDetails extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
|
||||
final exifInfo = ref.watch(assetExifProvider(asset)).value;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
|
||||
@@ -14,14 +14,14 @@ import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
@@ -365,7 +365,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
BaseAsset displayAsset = asset;
|
||||
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
|
||||
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
|
||||
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).value : null;
|
||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||
displayAsset = stackChildren.elementAt(stackIndex);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
||||
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset> {
|
||||
class StackChildrenNotifier extends AsyncNotifier<List<RemoteAsset>> {
|
||||
final BaseAsset asset;
|
||||
StackChildrenNotifier(this.asset);
|
||||
|
||||
@override
|
||||
Future<List<RemoteAsset>> build(BaseAsset asset) {
|
||||
Future<List<RemoteAsset>> build() {
|
||||
final asset = this.asset;
|
||||
if (asset is! RemoteAsset || asset.stackId == null) {
|
||||
return Future.value(const []);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
@@ -17,9 +18,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -105,6 +106,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
||||
// ignore: invalid_use_of_protected_member
|
||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
|
||||
_reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||
@@ -161,6 +163,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
_preloader.preload(index, context.sizeData);
|
||||
_handleCasting();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
// ignore: invalid_use_of_protected_member
|
||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class _ScopedMapTimeline extends StatelessWidget {
|
||||
}
|
||||
|
||||
final users = ref.watch(mapStateProvider).withPartners
|
||||
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
||||
? ref.watch(timelineUsersProvider).value ?? [user.id]
|
||||
: [user.id];
|
||||
|
||||
final timelineService = ref
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
@@ -48,6 +49,26 @@ class TimelineArgs {
|
||||
showStorageIndicator.hashCode ^
|
||||
withStack.hashCode ^
|
||||
groupBy.hashCode;
|
||||
|
||||
TimelineArgs copyWith({
|
||||
double? maxWidth,
|
||||
double? maxHeight,
|
||||
double? spacing,
|
||||
int? columnCount,
|
||||
bool? showStorageIndicator,
|
||||
bool? withStack,
|
||||
GroupAssetsBy? groupBy,
|
||||
}) {
|
||||
return TimelineArgs(
|
||||
maxWidth: maxWidth ?? this.maxWidth,
|
||||
maxHeight: maxHeight ?? this.maxHeight,
|
||||
spacing: spacing ?? this.spacing,
|
||||
columnCount: columnCount ?? this.columnCount,
|
||||
showStorageIndicator: showStorageIndicator ?? this.showStorageIndicator,
|
||||
withStack: withStack ?? this.withStack,
|
||||
groupBy: groupBy ?? this.groupBy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineState {
|
||||
@@ -86,25 +107,37 @@ 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));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
final timelineSegmentProvider = StreamNotifierProvider<_TimelineSegmentNotifier, List<Segment>>(
|
||||
_TimelineSegmentNotifier.new,
|
||||
dependencies: [timelineServiceProvider, timelineArgsProvider],
|
||||
);
|
||||
|
||||
final groupBy = args.groupBy ?? GroupAssetsBy.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
|
||||
class _TimelineSegmentNotifier extends StreamNotifier<List<Segment>> {
|
||||
@override
|
||||
Stream<List<Segment>> build() async* {
|
||||
final args = ref.watch(timelineArgsProvider);
|
||||
final columnCount = args.columnCount;
|
||||
final spacing = args.spacing;
|
||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
final groupBy = args.groupBy ?? GroupAssetsBy.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
return FixedSegmentBuilder(
|
||||
buckets: buckets,
|
||||
tileHeight: tileExtent,
|
||||
columnCount: columnCount,
|
||||
spacing: spacing,
|
||||
groupBy: groupBy,
|
||||
).generate();
|
||||
});
|
||||
}
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
return FixedSegmentBuilder(
|
||||
buckets: buckets,
|
||||
tileHeight: tileExtent,
|
||||
columnCount: columnCount,
|
||||
spacing: spacing,
|
||||
groupBy: groupBy,
|
||||
).generate();
|
||||
});
|
||||
}, dependencies: [timelineServiceProvider, timelineArgsProvider]);
|
||||
@override
|
||||
bool updateShouldNotify(AsyncValue<List<Segment>> previous, AsyncValue<List<Segment>> next) {
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
return !listEquals(previous.value, next.value);
|
||||
}
|
||||
}
|
||||
|
||||
final timelineStateProvider = NotifierProvider<TimelineStateNotifier, TimelineState>(TimelineStateNotifier.new);
|
||||
|
||||
@@ -71,10 +71,9 @@ class Timeline extends StatelessWidget {
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
|
||||
() => TimelineArgsNotifier(
|
||||
initialMaxWidth: constraints.maxWidth,
|
||||
initialMaxHeight: constraints.maxHeight,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
@@ -92,6 +91,7 @@ class Timeline extends StatelessWidget {
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
),
|
||||
@@ -122,6 +122,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
this.persistentBottomBar = false,
|
||||
this.snapToMonth = true,
|
||||
this.maxWidth,
|
||||
this.maxHeight,
|
||||
this.loadingWidget,
|
||||
});
|
||||
|
||||
@@ -134,6 +135,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
final bool persistentBottomBar;
|
||||
final bool snapToMonth;
|
||||
final double? maxWidth;
|
||||
final double? maxHeight;
|
||||
final Widget? loadingWidget;
|
||||
|
||||
@override
|
||||
@@ -172,13 +174,21 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
@override
|
||||
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;
|
||||
if (widget.maxWidth != oldWidget.maxWidth || widget.maxHeight != oldWidget.maxHeight) {
|
||||
final segments = ref.read(timelineSegmentProvider).value;
|
||||
int? restoreAssetIndex;
|
||||
if (segments != null) {
|
||||
restoreAssetIndex = _getCurrentAssetIndex(segments);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
ref
|
||||
.read(timelineArgsProvider.notifier)
|
||||
.updateConstraints(maxWidth: widget.maxWidth!, maxHeight: widget.maxHeight!);
|
||||
final _ = ref.refresh(timelineSegmentProvider);
|
||||
_restoreAssetIndex = restoreAssetIndex;
|
||||
_restoreAssetPosition(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -200,26 +210,40 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
}
|
||||
|
||||
void _restorePosition(List<Segment> segments) {
|
||||
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!);
|
||||
if (targetSegment == null) {
|
||||
_restoreAssetIndex = null;
|
||||
return;
|
||||
}
|
||||
final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex;
|
||||
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
|
||||
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
|
||||
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
|
||||
final targetOffset = targetSegment.indexToLayoutOffset(targetRowIndex);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollController.hasClients) {
|
||||
_scrollController.jumpTo(targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
|
||||
}
|
||||
});
|
||||
_restoreAssetIndex = null;
|
||||
}
|
||||
|
||||
void _restoreAssetPosition(_) {
|
||||
if (_restoreAssetIndex == null) return;
|
||||
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!);
|
||||
if (targetSegment != null) {
|
||||
final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex;
|
||||
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
|
||||
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
|
||||
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
|
||||
final targetOffset = targetSegment.indexToLayoutOffset(targetRowIndex);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_scrollController.jumpTo(targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
|
||||
}
|
||||
});
|
||||
if (asyncSegments is AsyncData<List<Segment>>) {
|
||||
_restorePosition(asyncSegments.value);
|
||||
return;
|
||||
}
|
||||
late ProviderSubscription<AsyncValue<List<Segment>>> sub;
|
||||
sub = ref.listenManual<AsyncValue<List<Segment>>>(timelineSegmentProvider, (_, next) {
|
||||
if (next is AsyncData<List<Segment>>) {
|
||||
sub.close();
|
||||
_restorePosition(next.value);
|
||||
}
|
||||
});
|
||||
_restoreAssetIndex = null;
|
||||
}
|
||||
|
||||
void _onMultiSelectionToggled(_, bool isEnabled) {
|
||||
|
||||
@@ -3,20 +3,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/providers/activity_service.provider.dart';
|
||||
|
||||
// ignore: unintended_html_in_doc_comment
|
||||
/// Maintains the current list of all activities for <share-album-id, asset>
|
||||
/// Maintains the current list of all activities for [share-album-id, asset]
|
||||
|
||||
final albumActivityProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<AlbumActivity, List<Activity>, (String albumId, String? assetId)>(AlbumActivity.new);
|
||||
.family<AlbumActivity, List<Activity>, (String, String?)>(AlbumActivity.new);
|
||||
|
||||
class AlbumActivity extends AutoDisposeFamilyAsyncNotifier<List<Activity>, (String albumId, String? assetId)> {
|
||||
late String albumId;
|
||||
late String? assetId;
|
||||
class AlbumActivity extends AsyncNotifier<List<Activity>> {
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
|
||||
AlbumActivity((String albumId, String? assetId) args) : albumId = args.$1, assetId = args.$2;
|
||||
|
||||
@override
|
||||
Future<List<Activity>> build((String albumId, String? assetId) args) async {
|
||||
albumId = args.$1;
|
||||
assetId = args.$2;
|
||||
Future<List<Activity>> build() async {
|
||||
return ref.watch(activityServiceProvider).getAllActivities(albumId, assetId: assetId);
|
||||
}
|
||||
|
||||
@@ -57,7 +56,7 @@ class AlbumActivity extends AutoDisposeFamilyAsyncNotifier<List<Activity>, (Stri
|
||||
}
|
||||
|
||||
void _addToState(Activity activity) {
|
||||
final activities = state.valueOrNull ?? [];
|
||||
final activities = state.value ?? [];
|
||||
if (activities.any((a) => a.id == activity.id)) {
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +64,7 @@ class AlbumActivity extends AutoDisposeFamilyAsyncNotifier<List<Activity>, (Stri
|
||||
}
|
||||
|
||||
Activity? _removeFromState(String id) {
|
||||
final activities = state.valueOrNull;
|
||||
final activities = state.value;
|
||||
if (activities == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
|
||||
class AlbumTitleNotifier extends StateNotifier<String> {
|
||||
AlbumTitleNotifier() : super("");
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'current_album.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$currentAlbumHash() => r'61f00273d6b69da45add1532cc3d3a076ee55110';
|
||||
|
||||
/// See also [CurrentAlbum].
|
||||
@ProviderFor(CurrentAlbum)
|
||||
final currentAlbumProvider =
|
||||
AutoDisposeNotifierProvider<CurrentAlbum, Album?>.internal(
|
||||
CurrentAlbum.new,
|
||||
name: r'currentAlbumProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentAlbumHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$CurrentAlbum = AutoDisposeNotifier<Album?>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
@@ -67,24 +69,41 @@ class AssetViewerState {
|
||||
}
|
||||
|
||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
StreamSubscription<BaseAsset?>? _assetSub;
|
||||
String? _watchedHeroTag;
|
||||
|
||||
@override
|
||||
AssetViewerState build() {
|
||||
ref.listen(_watchedCurrentAssetProvider, (_, next) {
|
||||
final updated = next.valueOrNull;
|
||||
if (updated != null) {
|
||||
state = state.copyWith(currentAsset: updated);
|
||||
}
|
||||
ref.onDispose(() {
|
||||
_assetSub?.cancel();
|
||||
_assetSub = null;
|
||||
_watchedHeroTag = null;
|
||||
});
|
||||
return const AssetViewerState();
|
||||
}
|
||||
|
||||
void _syncAssetSubscription(BaseAsset? asset) {
|
||||
final heroTag = asset?.heroTag;
|
||||
if (heroTag == _watchedHeroTag) return;
|
||||
_watchedHeroTag = heroTag;
|
||||
_assetSub?.cancel();
|
||||
_assetSub = null;
|
||||
if (asset == null) return;
|
||||
_assetSub = ref.read(assetServiceProvider).watchAsset(asset).listen((updated) {
|
||||
if (updated == null || updated.heroTag != _watchedHeroTag) return;
|
||||
state = state.copyWith(currentAsset: updated);
|
||||
});
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const AssetViewerState();
|
||||
_syncAssetSubscription(null);
|
||||
}
|
||||
|
||||
void setAsset(BaseAsset asset) {
|
||||
if (asset == state.currentAsset) return;
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
_syncAssetSubscription(asset);
|
||||
}
|
||||
|
||||
void setOpacity(double opacity) {
|
||||
@@ -134,10 +153,3 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
}
|
||||
|
||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||
|
||||
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
|
||||
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (asset == null) return const Stream.empty();
|
||||
return ref.read(assetServiceProvider).watchAsset(asset);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/models/download/download_state.model.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
|
||||
/// Whether to display the video part of a motion photo
|
||||
final isPlayingMotionVideoProvider = StateNotifierProvider<IsPlayingMotionVideo, bool>((ref) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
|
||||
final showControlsProvider = StateNotifierProvider<ShowControls, bool>((ref) {
|
||||
return ShowControls(ref);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
@@ -11,9 +12,9 @@ import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
|
||||
/// Tracks per-asset upload progress.
|
||||
/// Key: local asset ID, Value: upload progress 0.0 to 1.0, or -1.0 for error
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/services/local_album.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
|
||||
@@ -2,16 +2,16 @@ import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class EnqueueStatus {
|
||||
final int enqueueCount;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/services/gcast.service.dart';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/folder/root_folder.model.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ final mapServiceProvider = Provider<MapService>(
|
||||
}
|
||||
|
||||
final users = ref.watch(mapStateProvider).withPartners
|
||||
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
||||
? ref.watch(timelineUsersProvider).value ?? [user.id]
|
||||
: [user.id];
|
||||
|
||||
final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions());
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
@@ -10,13 +13,51 @@ final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
|
||||
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final timelineArgsProvider = Provider.autoDispose<TimelineArgs>(
|
||||
(ref) => throw UnimplementedError('Will be overridden through a ProviderScope.'),
|
||||
final timelineArgsProvider = NotifierProvider.autoDispose<TimelineArgsNotifier, TimelineArgs>(
|
||||
TimelineArgsNotifier.new,
|
||||
dependencies: const [],
|
||||
);
|
||||
|
||||
class TimelineArgsNotifier extends Notifier<TimelineArgs> {
|
||||
TimelineArgsNotifier({
|
||||
double initialMaxWidth = 0,
|
||||
double initialMaxHeight = 0,
|
||||
this.showStorageIndicator = false,
|
||||
this.withStack = false,
|
||||
this.groupBy,
|
||||
}) : _maxWidth = initialMaxWidth,
|
||||
_maxHeight = initialMaxHeight;
|
||||
|
||||
double _maxWidth;
|
||||
double _maxHeight;
|
||||
final bool showStorageIndicator;
|
||||
final bool withStack;
|
||||
final GroupAssetsBy? groupBy;
|
||||
|
||||
@override
|
||||
TimelineArgs build() {
|
||||
final columnCount = ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow)));
|
||||
return TimelineArgs(
|
||||
maxWidth: _maxWidth,
|
||||
maxHeight: _maxHeight,
|
||||
columnCount: columnCount,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
);
|
||||
}
|
||||
|
||||
void updateConstraints({required double maxWidth, required double maxHeight}) {
|
||||
if (_maxWidth == maxWidth && _maxHeight == maxHeight) return;
|
||||
_maxWidth = maxWidth;
|
||||
_maxHeight = maxHeight;
|
||||
state = state.copyWith(maxWidth: maxWidth, maxHeight: maxHeight);
|
||||
}
|
||||
}
|
||||
|
||||
final timelineServiceProvider = Provider<TimelineService>(
|
||||
(ref) {
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).value ?? [];
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
@@ -33,11 +74,22 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
|
||||
),
|
||||
);
|
||||
|
||||
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
||||
if (currentUserId == null) {
|
||||
return Stream.value([]);
|
||||
final timelineUsersProvider = StreamNotifierProvider<_TimelineUsersNotifier, List<String>>(_TimelineUsersNotifier.new);
|
||||
|
||||
class _TimelineUsersNotifier extends StreamNotifier<List<String>> {
|
||||
@override
|
||||
Stream<List<String>> build() {
|
||||
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
||||
if (currentUserId == null) {
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
}
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
});
|
||||
@override
|
||||
bool updateShouldNotify(AsyncValue<List<String>> previous, AsyncValue<List<String>> next) {
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
return !listEquals(previous.value, next.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
|
||||
final multiselectProvider = StateProvider((ref) {
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/services/network.service.dart';
|
||||
|
||||
final networkProvider = StateNotifierProvider<NetworkNotifier, String>((ref) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
|
||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
|
||||
final secureStorageProvider = StateNotifierProvider<SecureStorageProvider, void>((ref) {
|
||||
return SecureStorageProvider();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_config.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/services/shared_link.service.dart';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
|
||||
enum TabEnum { home, search, albums, library }
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
|
||||
@@ -25,6 +25,7 @@ final deepLinkServiceProvider = Provider(
|
||||
ref.watch(driftPeopleServiceProvider),
|
||||
ref.watch(currentUserProvider),
|
||||
),
|
||||
dependencies: [remoteAlbumServiceProvider],
|
||||
);
|
||||
|
||||
class DeepLinkService {
|
||||
|
||||
@@ -3,12 +3,13 @@ import 'dart:math';
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/legacy.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
|
||||
|
||||
class VideoControls extends ConsumerStatefulWidget {
|
||||
@@ -25,7 +26,7 @@ class VideoControls extends ConsumerStatefulWidget {
|
||||
class _VideoControlsState extends ConsumerState<VideoControls> {
|
||||
late final RestartableTimer _hideTimer;
|
||||
|
||||
AutoDisposeStateNotifierProvider<VideoPlayerNotifier, VideoPlayerState> get _provider =>
|
||||
StateNotifierProvider<VideoPlayerNotifier, VideoPlayerState> get _provider =>
|
||||
videoPlayerProvider(widget.videoPlayerName);
|
||||
|
||||
@override
|
||||
|
||||
@@ -5,10 +5,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
|
||||
class ThemeSetting extends HookConsumerWidget {
|
||||
const ThemeSetting({super.key});
|
||||
@@ -21,7 +21,8 @@ class ThemeSetting extends HookConsumerWidget {
|
||||
final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system);
|
||||
|
||||
final applyThemeToBackgroundSetting = useAppSettingsState(AppSettingsEnum.colorfulInterface);
|
||||
final applyThemeToBackgroundProvider = useValueNotifier(ref.read(colorfulInterfaceSettingProvider));
|
||||
final isColorfulInterface = ref.read(colorfulInterfaceSettingProvider);
|
||||
final applyThemeToBackgroundProvider = useValueNotifier(isColorfulInterface);
|
||||
|
||||
useValueChanged(
|
||||
currentThemeString.value,
|
||||
|
||||
Generated
+1
-1
@@ -146,7 +146,7 @@ Class | Method | HTTP request | Description
|
||||
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
|
||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
|
||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group
|
||||
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
|
||||
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
|
||||
*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
|
||||
|
||||
+4
-4
@@ -16,9 +16,9 @@ class DuplicatesApi {
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Delete a duplicate
|
||||
/// Dismiss a duplicate group
|
||||
///
|
||||
/// Delete a single duplicate asset specified by its ID.
|
||||
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -51,9 +51,9 @@ class DuplicatesApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete a duplicate
|
||||
/// Dismiss a duplicate group
|
||||
///
|
||||
/// Delete a single duplicate asset specified by its ID.
|
||||
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
+144
-6
@@ -9,6 +9,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "93.0.0"
|
||||
analysis_server_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analysis_server_plugin
|
||||
sha256: "5f3920acbd5765764ec9ef6c5bbdd102015424281232ee4fb4f5431c87abb4eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.7"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -17,6 +25,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.1"
|
||||
analyzer_buffer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_buffer
|
||||
sha256: "5fcd06b0715ebeee99f03e3f437b3412249969d8d12b191ea8a1d76e42a4e4a1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
analyzer_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_plugin
|
||||
sha256: "7df504f0c9d6891bacc9f73a5a8c5f6fe4fc49c90ec8e3379916372906ba0b32"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.1"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -209,6 +233,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -273,6 +305,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
crop_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -557,10 +597,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.3.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -659,6 +699,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.14"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -780,10 +828,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hooks_riverpod
|
||||
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
|
||||
sha256: "08527ec06aaef75e4b78694e045ef0cd8346594eaf9cc18b0f866398b07b93b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.3.1"
|
||||
hotreloader:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1149,6 +1197,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1433,10 +1489,28 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.2.1"
|
||||
riverpod_analyzer_utils:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
path: "packages/riverpod_analyzer_utils"
|
||||
ref: e8b84952e40b395ef47ab1f581eddddf64f0b2fd
|
||||
resolved-ref: e8b84952e40b395ef47ab1f581eddddf64f0b2fd
|
||||
url: "https://github.com/rrousselGit/riverpod/"
|
||||
source: git
|
||||
version: "1.0.0-dev.9"
|
||||
riverpod_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
path: "packages/riverpod_lint"
|
||||
ref: e8b84952e40b395ef47ab1f581eddddf64f0b2fd
|
||||
resolved-ref: e8b84952e40b395ef47ab1f581eddddf64f0b2fd
|
||||
url: "https://github.com/rrousselGit/riverpod/"
|
||||
source: git
|
||||
version: "3.1.3"
|
||||
scroll_date_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1565,6 +1639,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_static:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1611,6 +1701,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.13"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1707,6 +1813,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.30.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1715,6 +1829,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.16"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1923,6 +2045,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1987,6 +2117,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
yaml_edit:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml_edit
|
||||
sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.4"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: "3.41.9"
|
||||
|
||||
+19
-6
@@ -35,7 +35,7 @@ dependencies:
|
||||
fluttertoast: ^8.2.14
|
||||
geolocator: ^14.0.2
|
||||
home_widget: ^0.8.1
|
||||
hooks_riverpod: ^2.6.1
|
||||
hooks_riverpod: ^3.3.1
|
||||
http: ^1.6.0
|
||||
image_picker: ^1.2.1
|
||||
immich_ui:
|
||||
@@ -91,10 +91,9 @@ dependencies:
|
||||
dev_dependencies:
|
||||
auto_route_generator: ^10.5.0
|
||||
build_runner: ^2.13.1
|
||||
# Drift generator
|
||||
drift_dev: ^2.32.1
|
||||
fake_async: ^1.3.3
|
||||
file: ^7.0.1 # for MemoryFileSystem
|
||||
file: ^7.0.1
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_native_splash: ^2.4.7
|
||||
@@ -103,13 +102,27 @@ dev_dependencies:
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
mocktail: ^1.0.5
|
||||
# Type safe platform code
|
||||
pigeon: ^26.3.4
|
||||
riverpod_lint: ^3.1.3
|
||||
|
||||
# cast 2.1.0 declares a loose bonsoir range but its code targets the 5.x API.
|
||||
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
|
||||
dependency_overrides:
|
||||
# cast 2.1.0 declares a loose bonsoir range but its code targets the 5.x API.
|
||||
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
|
||||
bonsoir: ^5.1.11
|
||||
# the pub version has an outdated analyzer dependency, and the git version is not published to pub.dev
|
||||
# use the git version until the pub version is updated
|
||||
riverpod_lint:
|
||||
git:
|
||||
url: https://github.com/rrousselGit/riverpod/
|
||||
ref: 'e8b84952e40b395ef47ab1f581eddddf64f0b2fd'
|
||||
path: packages/riverpod_lint/
|
||||
# transitive dependency of riverpod_lint, and the pub version is outdated
|
||||
# remove the override when the pub version of riverpod_lint is updated
|
||||
riverpod_analyzer_utils:
|
||||
git:
|
||||
url: https://github.com/rrousselGit/riverpod/
|
||||
ref: 'e8b84952e40b395ef47ab1f581eddddf64f0b2fd'
|
||||
path: packages/riverpod_analyzer_utils/
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
@@ -54,7 +55,7 @@ void main() {
|
||||
|
||||
mapStateNotifier.state = mapState.copyWith(darkStyleFetched: const AsyncData("dark"));
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.valueOrNull, "dark");
|
||||
expect(mapStyle?.value, "dark");
|
||||
});
|
||||
|
||||
testWidgets("Return error when style is not fetched", (tester) async {
|
||||
@@ -88,7 +89,7 @@ void main() {
|
||||
|
||||
mapStateNotifier.state = mapState.copyWith(themeMode: ThemeMode.light, lightStyleFetched: const AsyncData("light"));
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.valueOrNull, "light");
|
||||
expect(mapStyle?.value, "light");
|
||||
});
|
||||
|
||||
group("System mode", () {
|
||||
@@ -111,7 +112,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(mapStyle?.valueOrNull, "dark");
|
||||
expect(mapStyle?.value, "dark");
|
||||
});
|
||||
|
||||
testWidgets("Return light theme style when system is light", (tester) async {
|
||||
@@ -133,7 +134,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(mapStyle?.valueOrNull, "light");
|
||||
expect(mapStyle?.value, "light");
|
||||
});
|
||||
|
||||
testWidgets("Switches style when system brightness changes", (tester) async {
|
||||
@@ -155,11 +156,11 @@ void main() {
|
||||
darkStyleFetched: const AsyncData("dark"),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.valueOrNull, "light");
|
||||
expect(mapStyle?.value, "light");
|
||||
|
||||
tester.binding.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.valueOrNull, "dark");
|
||||
expect(mapStyle?.value, "dark");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
|
||||
extension PumpConsumerWidget on WidgetTester {
|
||||
/// Wraps the provided [widget] with Material app such that it becomes:
|
||||
|
||||
@@ -5172,7 +5172,7 @@
|
||||
},
|
||||
"/duplicates/{id}": {
|
||||
"delete": {
|
||||
"description": "Delete a single duplicate asset specified by its ID.",
|
||||
"description": "Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.",
|
||||
"operationId": "deleteDuplicate",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5202,7 +5202,7 @@
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete a duplicate",
|
||||
"summary": "Dismiss a duplicate group",
|
||||
"tags": [
|
||||
"Duplicates"
|
||||
],
|
||||
|
||||
@@ -4480,7 +4480,7 @@ export function resolveDuplicates({ duplicateResolveDto }: {
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Delete a duplicate
|
||||
* Dismiss a duplicate group
|
||||
*/
|
||||
export function deleteDuplicate({ id }: {
|
||||
id: string;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { HttpError } from '@oazapfts/runtime';
|
||||
|
||||
export interface ApiValidationError {
|
||||
code: string;
|
||||
path: (string | number)[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApiExceptionResponse {
|
||||
message: string;
|
||||
error?: string;
|
||||
statusCode: number;
|
||||
errors?: ApiValidationError[];
|
||||
}
|
||||
|
||||
export interface ApiHttpError extends HttpError {
|
||||
|
||||
@@ -28,14 +28,16 @@ describe(ActivityController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/activities');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['[albumId] Invalid input: expected string, received undefined']),
|
||||
factory.responses.validationError([
|
||||
{ path: ['albumId'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid albumId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId', async () => {
|
||||
@@ -43,7 +45,7 @@ describe(ActivityController.name, () => {
|
||||
.get('/activities')
|
||||
.query({ albumId: factory.uuid(), assetId: '123' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +60,7 @@ describe(ActivityController.name, () => {
|
||||
.post('/activities')
|
||||
.send({ albumId: '123', type: 'like' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should require a comment when type is comment', async () => {
|
||||
@@ -66,7 +68,11 @@ describe(ActivityController.name, () => {
|
||||
.post('/activities')
|
||||
.send({ albumId: factory.uuid(), type: 'comment', comment: null });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[comment] Invalid input: expected string, received null']));
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['comment'], message: 'Invalid input: expected string, received null' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +85,7 @@ describe(ActivityController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,13 +27,17 @@ describe(AlbumController.name, () => {
|
||||
it('should reject an invalid shared param', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"']));
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId param', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe(ApiKeyController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe(ApiKeyController.name, () => {
|
||||
.put(`/api-keys/123`)
|
||||
.send({ name: 'new name', permissions: [Permission.All] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should allow updating just the name', async () => {
|
||||
@@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,9 @@ describe(AssetMediaController.name, () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']),
|
||||
factory.responses.validationError([
|
||||
{ path: ['metadata'], message: 'Invalid input: expected JSON string, received string' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -91,8 +93,8 @@ describe(AssetMediaController.name, () => {
|
||||
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest([
|
||||
'[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
|
||||
factory.responses.validationError([
|
||||
{ path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -104,8 +106,8 @@ describe(AssetMediaController.name, () => {
|
||||
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest([
|
||||
'[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
|
||||
factory.responses.validationError([
|
||||
{ path: ['fileModifiedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -117,7 +119,9 @@ describe(AssetMediaController.name, () => {
|
||||
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']),
|
||||
factory.responses.validationError([
|
||||
{ path: ['isFavorite'], message: 'Invalid option: expected one of "true"|"false"' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -128,7 +132,9 @@ describe(AssetMediaController.name, () => {
|
||||
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
|
||||
factory.responses.validationError([
|
||||
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe(AssetController.name, () => {
|
||||
.send({ ids: ['123'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should require duplicateId to be a string', async () => {
|
||||
@@ -42,7 +42,9 @@ describe(AssetController.name, () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']),
|
||||
factory.responses.validationError([
|
||||
{ path: ['duplicateId'], message: 'Invalid input: expected string, received boolean' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -70,7 +72,7 @@ describe(AssetController.name, () => {
|
||||
.send({ ids: ['123'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +85,7 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,12 +99,10 @@ describe(AssetController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([
|
||||
'[sourceId] Invalid input: expected string, received undefined',
|
||||
'[targetId] Invalid input: expected string, received undefined',
|
||||
]),
|
||||
),
|
||||
factory.responses.validationError([
|
||||
{ path: ['sourceId'], message: 'Invalid input: expected string, received undefined' },
|
||||
{ path: ['targetId'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -125,7 +125,9 @@ describe(AssetController.name, () => {
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
@@ -134,9 +136,9 @@ describe(AssetController.name, () => {
|
||||
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
|
||||
),
|
||||
factory.responses.validationError([
|
||||
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,7 +161,9 @@ describe(AssetController.name, () => {
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test' }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
@@ -168,9 +172,9 @@ describe(AssetController.name, () => {
|
||||
.send({ items: [{ assetId: factory.uuid() }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
|
||||
),
|
||||
factory.responses.validationError([
|
||||
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -191,33 +195,56 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['Invalid input: expected object, received undefined']));
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: [], message: 'Invalid input: expected object, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid gps coordinates', async () => {
|
||||
for (const test of [
|
||||
{ latitude: 12 },
|
||||
{ longitude: 12 },
|
||||
{ latitude: 12, longitude: 'abc' },
|
||||
{ latitude: 'abc', longitude: 12 },
|
||||
{ latitude: null, longitude: 12 },
|
||||
{ latitude: 12, longitude: null },
|
||||
{ latitude: 91, longitude: 12 },
|
||||
{ latitude: -91, longitude: 12 },
|
||||
{ latitude: 12, longitude: -181 },
|
||||
{ latitude: 12, longitude: 181 },
|
||||
]) {
|
||||
for (const [test, errors] of [
|
||||
[{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
|
||||
[{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
|
||||
[
|
||||
{ latitude: 12, longitude: 'abc' },
|
||||
[{ path: ['longitude'], message: 'Invalid input: expected number, received string' }],
|
||||
],
|
||||
[
|
||||
{ latitude: 'abc', longitude: 12 },
|
||||
[{ path: ['latitude'], message: 'Invalid input: expected number, received string' }],
|
||||
],
|
||||
[
|
||||
{ latitude: null, longitude: 12 },
|
||||
[{ path: ['latitude'], message: 'Invalid input: expected number, received null' }],
|
||||
],
|
||||
[
|
||||
{ latitude: 12, longitude: null },
|
||||
[{ path: ['longitude'], message: 'Invalid input: expected number, received null' }],
|
||||
],
|
||||
[{ latitude: 91, longitude: 12 }, [{ path: ['latitude'], message: 'Too big: expected number to be <=90' }]],
|
||||
[{ latitude: -91, longitude: 12 }, [{ path: ['latitude'], message: 'Too small: expected number to be >=-90' }]],
|
||||
[
|
||||
{ latitude: 12, longitude: -181 },
|
||||
[{ path: ['longitude'], message: 'Too small: expected number to be >=-180' }],
|
||||
],
|
||||
[{ latitude: 12, longitude: 181 }, [{ path: ['longitude'], message: 'Too big: expected number to be <=180' }]],
|
||||
] as const) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
expect(body).toEqual(factory.responses.validationError(errors));
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid rating', async () => {
|
||||
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
|
||||
for (const [test, errors] of [
|
||||
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
|
||||
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
|
||||
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
|
||||
] as const) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
expect(body).toEqual(factory.responses.validationError(errors));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -261,13 +288,17 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should require items to be an array', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined']));
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['items'], message: 'Invalid input: expected array, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require each item to have a valid key', async () => {
|
||||
@@ -276,7 +307,9 @@ describe(AssetController.name, () => {
|
||||
.send({ items: [{ value: { some: 'value' } }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['[items.0.key] Invalid input: expected string, received undefined']),
|
||||
factory.responses.validationError([
|
||||
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -286,9 +319,9 @@ describe(AssetController.name, () => {
|
||||
.send({ items: [{ key: 'mobile-app', value: null }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']),
|
||||
),
|
||||
factory.responses.validationError([
|
||||
{ path: ['items', 0, 'value'], message: 'Invalid input: expected record, received null' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -326,7 +359,7 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,7 +409,7 @@ describe(AssetController.name, () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should check the action and parameters discriminator', async () => {
|
||||
@@ -398,13 +431,12 @@ describe(AssetController.name, () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle",
|
||||
),
|
||||
]),
|
||||
),
|
||||
factory.responses.validationError([
|
||||
{
|
||||
path: ['edits', 0, 'parameters'],
|
||||
message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -413,7 +445,11 @@ describe(AssetController.name, () => {
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
.send({ edits: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items']));
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['edits'], message: 'Too small: expected array to have >=1 items' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -426,7 +462,7 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,19 +28,27 @@ describe(AuthController.name, () => {
|
||||
it('should require an email address', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received undefined' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a password', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a valid email', async () => {
|
||||
@@ -48,7 +56,9 @@ describe(AuthController.name, () => {
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ name, email: 'immich', password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received string' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform email to lower case', async () => {
|
||||
@@ -73,9 +83,9 @@ describe(AuthController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'[email] Invalid input: expected email, received undefined',
|
||||
'[password] Invalid input: expected string, received undefined',
|
||||
errorDto.validationError([
|
||||
{ path: ['email'], message: 'Invalid input: expected email, received undefined' },
|
||||
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -85,7 +95,9 @@ describe(AuthController.name, () => {
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: null, password: 'password' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should not allow null password`, async () => {
|
||||
@@ -93,7 +105,9 @@ describe(AuthController.name, () => {
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: 'admin@immich.cloud', password: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[password] Invalid input: expected string, received null']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['password'], message: 'Invalid input: expected string, received null' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid email', async () => {
|
||||
@@ -104,7 +118,9 @@ describe(AuthController.name, () => {
|
||||
.send({ name: 'admin', email: [], password: 'password' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform the email to all lowercase', async () => {
|
||||
@@ -195,19 +211,31 @@ describe(AuthController.name, () => {
|
||||
it('should reject 5 digits', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject 7 digits', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-numbers', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ describe(DuplicateController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,8 @@ export class DuplicateController {
|
||||
@Authenticated({ permission: Permission.DuplicateDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Delete a duplicate',
|
||||
description: 'Delete a single duplicate asset specified by its ID.',
|
||||
summary: 'Dismiss a duplicate group',
|
||||
description: 'Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
|
||||
@@ -31,7 +31,9 @@ describe(MaintenanceController.name, () => {
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']),
|
||||
errorDto.validationError([
|
||||
{ path: ['restoreBackupFilename'], message: 'Backup filename is required when action is restore_database' },
|
||||
]),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -47,7 +47,11 @@ describe(MemoryController.name, () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['data', 'year'], message: 'Invalid input: expected number, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept showAt and hideAt', async () => {
|
||||
@@ -81,7 +85,7 @@ describe(MemoryController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,13 +98,15 @@ describe(MemoryController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require at least one field', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['At least one field must be provided']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +126,7 @@ describe(MemoryController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
@@ -128,7 +134,7 @@ describe(MemoryController.name, () => {
|
||||
.put(`/memories/${factory.uuid()}/assets`)
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,7 +147,7 @@ describe(MemoryController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
@@ -149,7 +155,7 @@ describe(MemoryController.name, () => {
|
||||
.delete(`/memories/${factory.uuid()}/assets`)
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,11 @@ describe(NotificationController.name, () => {
|
||||
.query({ level: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')]));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['level'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +49,9 @@ describe(NotificationController.name, () => {
|
||||
it('should require a list', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['ids'], message: 'Invalid input: expected array, received boolean' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require uuids', async () => {
|
||||
@@ -53,7 +59,9 @@ describe(NotificationController.name, () => {
|
||||
.put(`/notifications`)
|
||||
.send({ ids: [true] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['ids', 0], message: 'Invalid input: expected string, received boolean' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid uuids', async () => {
|
||||
@@ -75,7 +83,7 @@ describe(NotificationController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ describe(PartnerController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
|
||||
errorDto.validationError([
|
||||
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,7 +46,9 @@ describe(PartnerController.name, () => {
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
|
||||
errorDto.validationError([
|
||||
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +65,7 @@ describe(PartnerController.name, () => {
|
||||
.send({ sharedWithId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[sharedWithId] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['sharedWithId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +81,7 @@ describe(PartnerController.name, () => {
|
||||
.send({ inTimeline: true })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,7 +96,7 @@ describe(PartnerController.name, () => {
|
||||
.delete(`/partners/invalid`)
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ describe(PersonController.name, () => {
|
||||
.query({ closestPersonId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[closestPersonId] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['closestPersonId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it(`should require closestAssetId to be a uuid`, async () => {
|
||||
@@ -44,7 +44,7 @@ describe(PersonController.name, () => {
|
||||
.query({ closestAssetId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[closestAssetId] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['closestAssetId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe(PersonController.name, () => {
|
||||
.delete('/people')
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should respond with 204', async () => {
|
||||
@@ -104,7 +104,9 @@ describe(PersonController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should not allow a null name`, async () => {
|
||||
@@ -113,7 +115,9 @@ describe(PersonController.name, () => {
|
||||
.send({ name: null })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received null']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received null' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should require featureFaceAssetId to be a uuid`, async () => {
|
||||
@@ -122,7 +126,7 @@ describe(PersonController.name, () => {
|
||||
.send({ featureFaceAssetId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[featureFaceAssetId] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it(`should require isFavorite to be a boolean`, async () => {
|
||||
@@ -131,7 +135,11 @@ describe(PersonController.name, () => {
|
||||
.send({ isFavorite: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should require isHidden to be a boolean`, async () => {
|
||||
@@ -140,7 +148,9 @@ describe(PersonController.name, () => {
|
||||
.send({ isHidden: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isHidden] Invalid input: expected boolean, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['isHidden'], message: 'Invalid input: expected boolean, received string' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
@@ -154,7 +164,11 @@ describe(PersonController.name, () => {
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: false });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received boolean']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['birthDate'], message: 'Invalid input: expected string, received boolean' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not accept an invalid birth date (number)', async () => {
|
||||
@@ -162,7 +176,9 @@ describe(PersonController.name, () => {
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: 123_456 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received number']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['birthDate'], message: 'Invalid input: expected string, received number' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not accept a birth date in the future)', async () => {
|
||||
@@ -170,7 +186,9 @@ describe(PersonController.name, () => {
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: '9999-01-01' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[birthDate] Birth date cannot be in the future']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['birthDate'], message: 'Birth date cannot be in the future' }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,7 +201,7 @@ describe(PersonController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should respond with 204', async () => {
|
||||
|
||||
@@ -27,31 +27,41 @@ describe(SearchController.name, () => {
|
||||
it('should reject page as a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[page] Invalid input: expected number, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['page'], message: 'Invalid input: expected number, received string' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject page as a negative number', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject page as 0', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject size as a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[size] Invalid input: expected number, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['size'], message: 'Invalid input: expected number, received string' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid size', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['size'], message: 'Too small: expected number to be >=1' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an visibility as not an enum', async () => {
|
||||
@@ -60,7 +70,9 @@ describe(SearchController.name, () => {
|
||||
.send({ visibility: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
|
||||
errorDto.validationError([
|
||||
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -69,7 +81,11 @@ describe(SearchController.name, () => {
|
||||
.post('/search/metadata')
|
||||
.send({ isFavorite: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an isEncoded as not a boolean', async () => {
|
||||
@@ -77,7 +93,11 @@ describe(SearchController.name, () => {
|
||||
.post('/search/metadata')
|
||||
.send({ isEncoded: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isEncoded] Invalid input: expected boolean, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['isEncoded'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an isOffline as not a boolean', async () => {
|
||||
@@ -85,13 +105,19 @@ describe(SearchController.name, () => {
|
||||
.post('/search/metadata')
|
||||
.send({ isOffline: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isOffline] Invalid input: expected boolean, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['isOffline'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an isMotion as not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isMotion] Invalid input: expected boolean, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['isMotion'], message: 'Invalid input: expected boolean, received string' }]),
|
||||
);
|
||||
});
|
||||
|
||||
describe('POST /search/random', () => {
|
||||
@@ -105,7 +131,11 @@ describe(SearchController.name, () => {
|
||||
.post('/search/random')
|
||||
.send({ withStacked: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[withStacked] Invalid input: expected boolean, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['withStacked'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if withPeople is not a boolean', async () => {
|
||||
@@ -113,7 +143,11 @@ describe(SearchController.name, () => {
|
||||
.post('/search/random')
|
||||
.send({ withPeople: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[withPeople] Invalid input: expected boolean, received string']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['withPeople'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +174,9 @@ describe(SearchController.name, () => {
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,7 +189,9 @@ describe(SearchController.name, () => {
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +211,11 @@ describe(SearchController.name, () => {
|
||||
it('should require a type', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[type] Invalid option: expected one of')]));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['type'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,11 @@ describe(SyncController.name, () => {
|
||||
.post('/sync/stream')
|
||||
.send({ types: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -57,7 +61,9 @@ describe(SyncController.name, () => {
|
||||
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[acks] Too big: expected array to have <=1000 items']));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['acks'], message: 'Too big: expected array to have <=1000 items' }]),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -73,7 +79,11 @@ describe(SyncController.name, () => {
|
||||
.delete('/sync/ack')
|
||||
.send({ types: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,8 +67,11 @@ describe(SystemConfigController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'[nightlyTasks.startTime] Invalid input: expected string in HH:mm format, received string',
|
||||
errorDto.validationError([
|
||||
{
|
||||
path: ['nightlyTasks', 'startTime'],
|
||||
message: 'Invalid input: expected string in HH:mm format, received string',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -86,7 +89,9 @@ describe(SystemConfigController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['[nightlyTasks.databaseCleanup] Invalid input: expected boolean, received string']),
|
||||
errorDto.validationError([
|
||||
{ path: ['nightlyTasks', 'databaseCleanup'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -116,7 +121,12 @@ describe(SystemConfigController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['[image.thumbnail.progressive] Invalid input: expected boolean, received string']),
|
||||
errorDto.validationError([
|
||||
{
|
||||
path: ['image', 'thumbnail', 'progressive'],
|
||||
message: 'Invalid input: expected boolean, received string',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ describe(TagController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ describe(TimelineController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/timeline/buckets').query({ bbox: '1,2,3' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['[bbox] bbox must have 4 comma-separated numbers: west,south,east,north'] as any),
|
||||
errorDto.validationError([
|
||||
{ path: ['bbox'], message: 'bbox must have 4 comma-separated numbers: west,south,east,north' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -51,7 +53,7 @@ describe(TimelineController.name, () => {
|
||||
.get('/timeline/buckets')
|
||||
.query({ bbox: '1,2,3,invalid' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[bbox] bbox parts must be valid numbers'] as any));
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['bbox'], message: 'bbox parts must be valid numbers' }]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,9 +78,9 @@ describe(UserAdminController.name, () => {
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(
|
||||
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
|
||||
),
|
||||
errorDto.validationError([
|
||||
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -98,9 +98,9 @@ describe(UserAdminController.name, () => {
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(
|
||||
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
|
||||
),
|
||||
errorDto.validationError([
|
||||
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -125,9 +125,9 @@ describe(UserAdminController.name, () => {
|
||||
.send({ quotaSizeInBytes: 1.2 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(
|
||||
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
|
||||
),
|
||||
errorDto.validationError([
|
||||
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,15 +43,17 @@ describe(UserController.name, () => {
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const key of ['email', 'name']) {
|
||||
for (const [key, message] of [
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
] as const) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const dto = { [key]: null };
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/users/me`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send(dto);
|
||||
.send({ [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,16 +40,16 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
|
||||
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
|
||||
const zodError = error.getZodError();
|
||||
if (zodError instanceof ZodError && zodError.issues.length > 0) {
|
||||
body['message'] = zodError.issues.map((issue) =>
|
||||
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
|
||||
);
|
||||
return {
|
||||
status,
|
||||
body: { message: 'Validation failed', errors: zodError.issues },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// remove fields that duplicate the HTTP response line or will be reformatted in a later step
|
||||
// remove fields injected by NestJS that duplicate the HTTP response line
|
||||
delete body['error'];
|
||||
delete body['statusCode'];
|
||||
delete body['errors'];
|
||||
return { status, body };
|
||||
}
|
||||
|
||||
|
||||
@@ -274,23 +274,23 @@ export class MediaRepository {
|
||||
index: stream.index,
|
||||
height,
|
||||
width: dar ? Math.round(height * dar) : this.parseInt(stream.width),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : (stream.codec_name ?? null),
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined) ?? null,
|
||||
level: this.parseOptionalInt(stream.level),
|
||||
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
|
||||
frameRate: this.parseFrameRate(stream.avg_frame_rate ?? stream.r_frame_rate),
|
||||
timeBase: this.parseRational(stream.time_base)?.den,
|
||||
timeBase: this.parseRational(stream.time_base)?.den ?? null,
|
||||
rotation: this.parseInt(stream.rotation),
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
pixelFormat: stream.pix_fmt || 'yuv420p',
|
||||
colorPrimaries: this.parseEnum(ColorPrimaries, stream.color_primaries) ?? ColorPrimaries.Unknown,
|
||||
colorMatrix: this.parseEnum(ColorMatrix, stream.color_space) ?? ColorMatrix.Unknown,
|
||||
colorTransfer: this.parseEnum(ColorTransfer, stream.color_transfer) ?? ColorTransfer.Unknown,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | undefined,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | null,
|
||||
dvLevel: this.parseOptionalInt(stream.dv_level),
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(stream.dv_bl_signal_compatibility_id) as
|
||||
| DvSignalCompatibility
|
||||
| undefined,
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(
|
||||
stream.dv_bl_signal_compatibility_id,
|
||||
) as DvSignalCompatibility | null,
|
||||
};
|
||||
}),
|
||||
audioStreams: results.streams
|
||||
@@ -298,9 +298,9 @@ export class MediaRepository {
|
||||
.sort((a, b) => this.compareStreams(a, b))
|
||||
.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecName: stream.codec_name,
|
||||
codecName: stream.codec_name ?? null,
|
||||
profile:
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : undefined,
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : null,
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
})),
|
||||
};
|
||||
@@ -449,29 +449,29 @@ export class MediaRepository {
|
||||
return Number.parseFloat(value as string) || 0;
|
||||
}
|
||||
|
||||
private parseOptionalInt(value: string | number | undefined): number | undefined {
|
||||
private parseOptionalInt(value: string | number | undefined): number | null {
|
||||
const parsed = Number.parseInt(value as string);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
private parseEnum<E extends Record<string, number | string>>(enumObj: E, value?: string) {
|
||||
return value ? (enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) : undefined;
|
||||
return value ? ((enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) ?? null) : null;
|
||||
}
|
||||
|
||||
/** Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. */
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | undefined {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | null {
|
||||
if (value) {
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseFrameRate(value: string | undefined): number | undefined {
|
||||
private parseFrameRate(value: string | undefined): number | null {
|
||||
const r = this.parseRational(value);
|
||||
return r ? r.num / r.den : undefined;
|
||||
return r ? r.num / r.den : null;
|
||||
}
|
||||
|
||||
private getDar(dar: string | undefined): number {
|
||||
@@ -498,6 +498,7 @@ export class MediaRepository {
|
||||
return this.parseEnum(Av1Profile, profile);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private compareStreams(a: FfprobeStream, b: FfprobeStream): number {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
@@ -149,6 +150,36 @@ describe(DuplicateService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw for an unknown or unauthorized group id', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.delete(authStub.admin, 'group-1')).rejects.toThrow(BadRequestException);
|
||||
expect(mocks.duplicateRepository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dismiss the duplicate group', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.delete.mockResolvedValue();
|
||||
await expect(sut.delete(authStub.admin, 'group-1')).resolves.toBeUndefined();
|
||||
expect(mocks.duplicateRepository.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'group-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should throw if any group id is unknown or unauthorized', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).rejects.toThrow(BadRequestException);
|
||||
expect(mocks.duplicateRepository.deleteAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dismiss all duplicate groups', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
|
||||
mocks.duplicateRepository.deleteAll.mockResolvedValue();
|
||||
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).resolves.toBeUndefined();
|
||||
expect(mocks.duplicateRepository.deleteAll).toHaveBeenCalledWith(authStub.admin.user.id, ['group-1', 'group-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should handle mixed success and failure', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
@@ -82,10 +82,12 @@ export class DuplicateService extends BaseService {
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: [id] });
|
||||
await this.duplicateRepository.delete(auth.user.id, id);
|
||||
}
|
||||
|
||||
async deleteAll(auth: AuthDto, dto: BulkIdsDto) {
|
||||
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: dto.ids });
|
||||
await this.duplicateRepository.deleteAll(auth.user.id, dto.ids);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotNull, ShallowDehydrateObject } from 'kysely';
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
@@ -1937,7 +1937,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
describe('handleVideoConversion', () => {
|
||||
let asset: ReturnType<typeof AssetFactory.create> & {
|
||||
videoStream: VideoStreamInfo & { timeBase: NotNull };
|
||||
videoStream: VideoStreamInfo & { timeBase: number };
|
||||
audioStream: AudioStreamInfo | null;
|
||||
format: VideoFormat;
|
||||
};
|
||||
|
||||
@@ -672,7 +672,7 @@ describe(MetadataService.name, () => {
|
||||
colorPrimaries: 9,
|
||||
colorTransfer: 16,
|
||||
colorMatrix: 9,
|
||||
dvProfile: undefined,
|
||||
dvProfile: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
+10
-10
@@ -89,26 +89,26 @@ export interface VideoStreamInfo {
|
||||
height: number;
|
||||
width: number;
|
||||
rotation: number;
|
||||
codecName?: string;
|
||||
profile?: H264Profile | HevcProfile | Av1Profile;
|
||||
level?: number;
|
||||
codecName: string | null;
|
||||
profile: H264Profile | HevcProfile | Av1Profile | null;
|
||||
level: number | null;
|
||||
frameCount: number;
|
||||
frameRate?: number;
|
||||
timeBase?: number;
|
||||
frameRate: number | null;
|
||||
timeBase: number | null;
|
||||
bitrate: number;
|
||||
pixelFormat: string;
|
||||
colorPrimaries: ColorPrimaries;
|
||||
colorMatrix: ColorMatrix;
|
||||
colorTransfer: ColorTransfer;
|
||||
dvProfile?: DvProfile;
|
||||
dvLevel?: number;
|
||||
dvBlSignalCompatibilityId?: DvSignalCompatibility;
|
||||
dvProfile: DvProfile | null;
|
||||
dvLevel: number | null;
|
||||
dvBlSignalCompatibilityId: DvSignalCompatibility | null;
|
||||
}
|
||||
|
||||
export interface AudioStreamInfo {
|
||||
index: number;
|
||||
codecName?: string;
|
||||
profile?: AacProfile;
|
||||
codecName: string | null;
|
||||
profile: AacProfile | null;
|
||||
bitrate: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AssetFileType, AssetVisibility, DatabaseExtension, ExifOrientation } fr
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoStreamInfo } from 'src/types';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
|
||||
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
|
||||
return {
|
||||
@@ -146,7 +146,7 @@ export function withVideoStream(eb: ExpressionBuilder<DB, 'asset_exif' | 'asset_
|
||||
'asset_video.dvBlSignalCompatibilityId',
|
||||
])
|
||||
.where('asset_video.assetId', 'is not', sql.lit(null)),
|
||||
).$castTo<(VideoStreamInfo & { timeBase: NotNull }) | null>();
|
||||
).$castTo<(VideoStreamInfo & { timeBase: number }) | null>();
|
||||
}
|
||||
|
||||
export function withVideoFormat(eb: ExpressionBuilder<DB, 'asset' | 'asset_video'>) {
|
||||
@@ -158,6 +158,22 @@ export function withVideoFormat(eb: ExpressionBuilder<DB, 'asset' | 'asset_video
|
||||
).$castTo<VideoFormat | null>();
|
||||
}
|
||||
|
||||
export function withVideoPackets(eb: ExpressionBuilder<DB, 'asset' | 'asset_keyframe'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom(dummy)
|
||||
.where('asset_keyframe.assetId', 'is not', sql.lit(null))
|
||||
.select([
|
||||
'asset_keyframe.pts as keyframePts',
|
||||
'asset_keyframe.accDuration as keyframeAccDuration',
|
||||
'asset_keyframe.ownDuration as keyframeOwnDuration',
|
||||
'asset_keyframe.totalDuration',
|
||||
'asset_keyframe.packetCount',
|
||||
'asset_keyframe.outputFrames',
|
||||
]),
|
||||
).$castTo<VideoPacketInfo | null>();
|
||||
}
|
||||
|
||||
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
|
||||
Vendored
+220
-12
@@ -1,5 +1,13 @@
|
||||
import { NotNull } from 'kysely';
|
||||
import { ColorMatrix, ColorPrimaries, ColorTransfer, DvProfile, DvSignalCompatibility } from 'src/enum';
|
||||
import {
|
||||
AacProfile,
|
||||
ColorMatrix,
|
||||
ColorPrimaries,
|
||||
ColorTransfer,
|
||||
DvProfile,
|
||||
DvSignalCompatibility,
|
||||
H264Profile,
|
||||
HevcProfile,
|
||||
} from 'src/enum';
|
||||
import { AudioStreamInfo, VideoFormat, VideoInfo, VideoStreamInfo } from 'src/types';
|
||||
|
||||
const probeStubDefaultFormat: VideoFormat = {
|
||||
@@ -22,11 +30,17 @@ const probeStubDefaultVideoStream: VideoStreamInfo[] = [
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
];
|
||||
|
||||
const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ index: 3, codecName: 'mp3', bitrate: 100 }];
|
||||
const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ index: 3, codecName: 'mp3', bitrate: 100, profile: null }];
|
||||
|
||||
const probeStubDefault: VideoInfo = {
|
||||
format: probeStubDefaultFormat,
|
||||
@@ -53,7 +67,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
@@ -67,7 +87,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
@@ -81,16 +107,22 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
multipleAudioStreams: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
{ index: 2, codecName: 'mp3', bitrate: 102 },
|
||||
{ index: 1, codecName: 'mp3', bitrate: 101 },
|
||||
{ index: 0, codecName: 'mp3', bitrate: 100 },
|
||||
{ index: 2, codecName: 'mp3', bitrate: 102, profile: null },
|
||||
{ index: 1, codecName: 'mp3', bitrate: 101, profile: null },
|
||||
{ index: 0, codecName: 'mp3', bitrate: 100, profile: null },
|
||||
],
|
||||
}),
|
||||
noHeight: Object.freeze<VideoInfo>({
|
||||
@@ -108,7 +140,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -127,7 +165,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -159,7 +203,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Smpte2084,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -178,7 +228,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -197,7 +253,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -216,7 +278,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -235,7 +303,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -254,27 +328,33 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
audioStreamAac: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100, profile: AacProfile.Lc }],
|
||||
}),
|
||||
audioStreamMp3: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100, profile: null }],
|
||||
}),
|
||||
audioStreamOpus: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100, profile: null }],
|
||||
}),
|
||||
audioStreamUnknown: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
{ index: 0, codecName: 'aac', bitrate: 100 },
|
||||
{ index: 1, codecName: 'unknown', bitrate: 200 },
|
||||
{ index: 0, codecName: 'aac', bitrate: 100, profile: AacProfile.Lc },
|
||||
{ index: 1, codecName: 'unknown', bitrate: 200, profile: null },
|
||||
],
|
||||
}),
|
||||
matroskaContainer: Object.freeze<VideoInfo>({
|
||||
@@ -340,6 +420,9 @@ export const videoInfoStub = {
|
||||
colorMatrix: ColorMatrix.Bt2020Nc,
|
||||
colorTransfer: ColorTransfer.Smpte2084,
|
||||
timeBase: 600,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -393,7 +476,7 @@ export const videoInfoStub = {
|
||||
};
|
||||
|
||||
interface SelectedStreams {
|
||||
videoStream: VideoStreamInfo & { timeBase: NotNull };
|
||||
videoStream: VideoStreamInfo & { timeBase: number };
|
||||
audioStream: AudioStreamInfo | null;
|
||||
format: VideoFormat;
|
||||
}
|
||||
@@ -407,3 +490,128 @@ const toSelectedStreams = (info: VideoInfo) => ({
|
||||
export const probeStub = Object.fromEntries(
|
||||
Object.entries(videoInfoStub).map(([key, info]) => [key, toSelectedStreams(info)]),
|
||||
) as Record<keyof typeof videoInfoStub, SelectedStreams>;
|
||||
|
||||
export const eiffelTower = {
|
||||
originalPath: 'eiffel-tower.mp4',
|
||||
videoStream: {
|
||||
index: 0,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
rotation: 0,
|
||||
codecName: 'h264',
|
||||
profile: H264Profile.High,
|
||||
level: 40,
|
||||
frameCount: 557,
|
||||
frameRate: 24.908_004_845_459_07,
|
||||
timeBase: 90_000,
|
||||
bitrate: 5_128_622,
|
||||
pixelFormat: 'yuv420p',
|
||||
colorPrimaries: ColorPrimaries.Smpte170M,
|
||||
colorTransfer: ColorTransfer.Smpte170M,
|
||||
colorMatrix: ColorMatrix.Smpte170M,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 125_629, index: 1, profile: AacProfile.Lc },
|
||||
packets: {
|
||||
totalDuration: 2_012_441,
|
||||
packetCount: 557,
|
||||
outputFrames: 557,
|
||||
keyframePts: [0, 462_502, 925_004, 1_210_454, 1_387_506, 1_542_878, 1_850_008],
|
||||
keyframeAccDuration: [3613, 466_077, 928_541, 1_213_968, 1_391_005, 1_546_364, 1_853_469],
|
||||
keyframeOwnDuration: [3613, 3613, 3613, 3613, 3613, 3613, 3613],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 22_616,
|
||||
bitrate: 5_128_622,
|
||||
},
|
||||
};
|
||||
|
||||
export const waterfall = {
|
||||
originalPath: 'waterfall.mp4',
|
||||
videoStream: {
|
||||
index: 2,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
rotation: -90,
|
||||
codecName: 'hevc',
|
||||
profile: HevcProfile.Main,
|
||||
level: 156,
|
||||
frameCount: 309,
|
||||
frameRate: 29.829_901_982_867_92,
|
||||
timeBase: 90_000,
|
||||
bitrate: 43_363_499,
|
||||
pixelFormat: 'yuvj420p',
|
||||
colorPrimaries: ColorPrimaries.Bt709,
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 191_878, index: 1, profile: null },
|
||||
packets: {
|
||||
totalDuration: 932_286,
|
||||
packetCount: 309,
|
||||
outputFrames: 309,
|
||||
keyframePts: [0, 89_987, 179_974, 269_961, 359_948, 449_936, 539_923, 629_910, 725_166, 815_273, 905_295],
|
||||
keyframeAccDuration: [
|
||||
2999, 92_987, 182_974, 272_961, 362_948, 452_934, 542_922, 632_909, 728_175, 818_274, 908_296,
|
||||
],
|
||||
keyframeOwnDuration: [2999, 3000, 3000, 3000, 3000, 2998, 2999, 2999, 3009, 3001, 3001],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 10_359,
|
||||
bitrate: 43_363_499,
|
||||
},
|
||||
};
|
||||
|
||||
export const train = {
|
||||
originalPath: 'train.mov',
|
||||
videoStream: {
|
||||
index: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rotation: -90,
|
||||
codecName: 'hevc',
|
||||
profile: HevcProfile.Main10,
|
||||
level: 123,
|
||||
frameCount: 1229,
|
||||
frameRate: 56.536_072_989_342_94,
|
||||
timeBase: 600,
|
||||
bitrate: 12_595_191,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
colorPrimaries: ColorPrimaries.Bt2020,
|
||||
colorTransfer: ColorTransfer.AribStdB67,
|
||||
colorMatrix: ColorMatrix.Bt2020Nc,
|
||||
dvProfile: DvProfile.Dvhe08,
|
||||
dvLevel: 5,
|
||||
dvBlSignalCompatibilityId: DvSignalCompatibility.Hlg,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 175_477, index: 1, profile: AacProfile.Lc },
|
||||
packets: {
|
||||
totalDuration: 12_290,
|
||||
packetCount: 1229,
|
||||
outputFrames: 1303,
|
||||
keyframePts: [
|
||||
0, 601, 1201, 1802, 2402, 3003, 3604, 4204, 4805, 5405, 6006, 6607, 7207, 7808, 8408, 9009, 9609, 10_210, 10_811,
|
||||
11_411, 12_062, 12_703,
|
||||
],
|
||||
keyframeAccDuration: [
|
||||
10, 580, 1180, 1780, 2380, 2980, 3580, 4180, 4780, 5380, 5980, 6580, 7180, 7780, 8380, 8980, 9580, 10_180, 10_780,
|
||||
11_380, 11_780, 12_100,
|
||||
],
|
||||
keyframeOwnDuration: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 21_738,
|
||||
bitrate: 12_595_191,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotNull, Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
@@ -156,7 +156,7 @@ export const getForGenerateThumbnail = (asset: ReturnType<AssetFactory['build']>
|
||||
files: asset.files.map((file) => getDehydrated(file)),
|
||||
exifInfo: getDehydrated(asset.exifInfo),
|
||||
edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[],
|
||||
videoStream: null as (VideoStreamInfo & { timeBase: NotNull }) | null,
|
||||
videoStream: null as (VideoStreamInfo & { timeBase: number }) | null,
|
||||
audioStream: null as AudioStreamInfo | null,
|
||||
format: null as VideoFormat | null,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user