mirror of
https://github.com/immich-app/immich.git
synced 2026-02-08 02:39:24 +03:00
Compare commits
7 Commits
better-pro
...
release/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57f493cb83 | ||
|
|
354dd3cc3c | ||
|
|
57483a1e7f | ||
|
|
bcea64875f | ||
|
|
84e30abe5d | ||
|
|
e3e243fa2b | ||
|
|
b3820c259e |
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
# v2.6.0
|
||||
|
||||
## Highlights
|
||||
|
||||
{{RELEASE HIGHLIGHTS}}
|
||||
|
||||
As always, please consider supporting the project.
|
||||
|
||||
🎉 Cheers! 🎉
|
||||
|
||||
|
||||
----
|
||||
|
||||
And as always, bugs are fixed, and many other improvements also come with this release.
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### 🌟 Enhancements
|
||||
* feat(mobile): enhance album sorting functionality with order handling by @LeLunZ in https://github.com/immich-app/immich/pull/24816
|
||||
### 🐛 Bug fixes
|
||||
* fix: add missing translations for image editor by @michelheusschen in https://github.com/immich-app/immich/pull/25957
|
||||
* fix: image and video download complete notification shows "file_name" by @romoisverycool in https://github.com/immich-app/immich/pull/25975
|
||||
* fix: user profile refetched each time on opening app dialog by @shenlong-tanwen in https://github.com/immich-app/immich/pull/25992
|
||||
### 📚 Documentation
|
||||
* feat(docs): version policy by @mmomjian in https://github.com/immich-app/immich/pull/25979
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.5.5...v2.6.0
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
@@ -26,6 +26,16 @@ docker image prune
|
||||
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
|
||||
[releases]: https://github.com/immich-app/immich/releases
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
|
||||
|
||||
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
|
||||
Switching back to an earlier version, even within the same minor release tag, is not supported.
|
||||
|
||||
[semver]: https://semver.org/
|
||||
|
||||
## Migrating to VectorChord
|
||||
|
||||
:::info
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.0",
|
||||
"url": "https://docs.v2.6.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.5",
|
||||
"url": "https://docs.v2.5.5.archive.immich.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.5.5"
|
||||
version = "2.6.0"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
2
machine-learning/uv.lock
generated
2
machine-learning/uv.lock
generated
@@ -882,7 +882,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.5.5"
|
||||
version = "2.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3036,
|
||||
"android.injected.version.name" => "2.5.5",
|
||||
"android.injected.version.code" => 3037,
|
||||
"android.injected.version.name" => "2.6.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.5.5</string>
|
||||
<string>2.6.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
enum SortOrder { asc, desc }
|
||||
enum SortOrder {
|
||||
asc,
|
||||
desc;
|
||||
|
||||
SortOrder reverse() {
|
||||
return this == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
|
||||
}
|
||||
}
|
||||
|
||||
enum TextSearchType { context, filename, description, ocr }
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/user.model.dart';
|
||||
@@ -36,6 +37,7 @@ class RemoteAlbumService {
|
||||
AlbumSortMode sortMode, {
|
||||
bool isReverse = false,
|
||||
}) async {
|
||||
// list of albums sorted ascendingly according to the selected sort mode
|
||||
final List<RemoteAlbum> sorted = switch (sortMode) {
|
||||
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
||||
AlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||
@@ -44,8 +46,9 @@ class RemoteAlbumService {
|
||||
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||
};
|
||||
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
|
||||
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
return (effectiveOrder == SortOrder.asc ? sorted : sorted.reversed).toList();
|
||||
}
|
||||
|
||||
List<RemoteAlbum> searchAlbums(
|
||||
@@ -209,6 +212,6 @@ class RemoteAlbumService {
|
||||
return aDate.compareTo(bDate);
|
||||
});
|
||||
|
||||
return sorted.reversed.toList();
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/extensions/build_context_extensions.dart';
|
||||
@@ -281,6 +282,8 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
setState(() {
|
||||
albumSortOption = sortMode;
|
||||
isSorting = true;
|
||||
// reset sort order to default state when switching option
|
||||
albumSortIsReverse = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -293,6 +296,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveOrder = albumSortOption.effectiveOrder(albumSortIsReverse);
|
||||
return MenuAnchor(
|
||||
controller: widget.controller,
|
||||
style: MenuStyle(
|
||||
@@ -307,7 +311,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
.map(
|
||||
(sortMode) => MenuItemButton(
|
||||
leadingIcon: albumSortOption == sortMode
|
||||
? albumSortIsReverse
|
||||
? effectiveOrder == SortOrder.desc
|
||||
? Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: albumSortOption == sortMode
|
||||
@@ -355,7 +359,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: albumSortIsReverse
|
||||
child: effectiveOrder == SortOrder.desc
|
||||
? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
|
||||
: Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
@@ -73,18 +74,21 @@ class _AlbumSortHandlers {
|
||||
|
||||
// Store index allows us to re-arrange the values without affecting the saved prefs
|
||||
enum AlbumSortMode {
|
||||
title(1, "library_page_sort_title", _AlbumSortHandlers.title),
|
||||
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount),
|
||||
lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified),
|
||||
created(0, "library_page_sort_created", _AlbumSortHandlers.created),
|
||||
mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent),
|
||||
mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest);
|
||||
title(1, "library_page_sort_title", _AlbumSortHandlers.title, SortOrder.asc),
|
||||
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount, SortOrder.desc),
|
||||
lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified, SortOrder.desc),
|
||||
created(0, "library_page_sort_created", _AlbumSortHandlers.created, SortOrder.desc),
|
||||
mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent, SortOrder.desc),
|
||||
mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest, SortOrder.asc);
|
||||
|
||||
final int storeIndex;
|
||||
final String label;
|
||||
final AlbumSortFn sortFn;
|
||||
final SortOrder defaultOrder;
|
||||
|
||||
const AlbumSortMode(this.storeIndex, this.label, this.sortFn);
|
||||
const AlbumSortMode(this.storeIndex, this.label, this.sortFn, this.defaultOrder);
|
||||
|
||||
SortOrder effectiveOrder(bool isReverse) => isReverse ? defaultOrder.reverse() : defaultOrder;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
|
||||
@@ -27,19 +27,17 @@ import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
void configureFileDownloaderNotifications() {
|
||||
final fileName = 'file_name'.t(args: {'file_name': '{filename}'});
|
||||
|
||||
FileDownloader().configureNotificationForGroup(
|
||||
kDownloadGroupImage,
|
||||
running: TaskNotification('downloading_media'.t(), fileName),
|
||||
complete: TaskNotification('download_finished'.t(), fileName),
|
||||
running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'),
|
||||
complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'),
|
||||
progressBar: true,
|
||||
);
|
||||
|
||||
FileDownloader().configureNotificationForGroup(
|
||||
kDownloadGroupVideo,
|
||||
running: TaskNotification('downloading_media'.t(), fileName),
|
||||
complete: TaskNotification('download_finished'.t(), fileName),
|
||||
running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'),
|
||||
complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'),
|
||||
progressBar: true,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
@@ -20,7 +18,7 @@ class UserCircleAvatar extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userAvatarColor = user.avatarColor.toColor();
|
||||
final profileImageUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}';
|
||||
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
|
||||
|
||||
final textIcon = DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.5.5
|
||||
- API version: 2.6.0
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.5.5+3036
|
||||
version: 2.6.0+3037
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -85,35 +85,47 @@ void main() {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.created);
|
||||
expect(result, [albumA, albumB]);
|
||||
expect(result, [albumB, albumA]);
|
||||
});
|
||||
|
||||
test('should sort correctly based on updatedAt', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified);
|
||||
expect(result, [albumA, albumB]);
|
||||
expect(result, [albumB, albumA]);
|
||||
});
|
||||
|
||||
test('should sort correctly based on assetCount', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount);
|
||||
expect(result, [albumA, albumB]);
|
||||
expect(result, [albumB, albumA]);
|
||||
});
|
||||
|
||||
test('should sort correctly based on newestAssetTimestamp', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent);
|
||||
expect(result, [albumA, albumB]);
|
||||
expect(result, [albumB, albumA]);
|
||||
});
|
||||
|
||||
test('should sort correctly based on oldestAssetTimestamp', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest);
|
||||
expect(result, [albumB, albumA]);
|
||||
expect(result, [albumA, albumB]);
|
||||
});
|
||||
|
||||
test('should flip order when isReverse is true for all modes', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
for (final mode in AlbumSortMode.values) {
|
||||
final normal = await sut.sortAlbums(albums, mode, isReverse: false);
|
||||
final reversed = await sut.sortAlbums(albums, mode, isReverse: true);
|
||||
|
||||
// reversed should be the exact inverse of normal
|
||||
expect(reversed, normal.reversed.toList(), reason: 'Mode: $mode');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15072,7 +15072,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.5.5
|
||||
* 2.6.0
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { mapAlbum } from 'src/dtos/album.dto';
|
||||
import { albumStub } from 'test/fixtures/album.stub';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
|
||||
describe('mapAlbum', () => {
|
||||
it('should set start and end dates', () => {
|
||||
const dto = mapAlbum(albumStub.twoAssets, false);
|
||||
expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z'));
|
||||
expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z'));
|
||||
const startDate = new Date('2023-02-22T05:06:29.716Z');
|
||||
const endDate = new Date('2025-01-01T01:02:03.456Z');
|
||||
const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build();
|
||||
const dto = mapAlbum(album, false);
|
||||
expect(dto.startDate).toEqual(startDate);
|
||||
expect(dto.endDate).toEqual(endDate);
|
||||
});
|
||||
|
||||
it('should not set start and end dates for empty assets', () => {
|
||||
const dto = mapAlbum(albumStub.empty, false);
|
||||
const dto = mapAlbum(AlbumFactory.create(), false);
|
||||
expect(dto.startDate).toBeUndefined();
|
||||
expect(dto.endDate).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
"shared_link".*
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
@@ -243,7 +243,7 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
"shared_link".*
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
@@ -316,7 +316,7 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
"shared_link".*
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
|
||||
@@ -44,9 +44,9 @@ const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
};
|
||||
|
||||
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
return jsonArrayFrom(eb.selectFrom('shared_link').selectAll().whereRef('shared_link.albumId', '=', 'album.id')).as(
|
||||
'sharedLinks',
|
||||
);
|
||||
return jsonArrayFrom(
|
||||
eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'),
|
||||
).as('sharedLinks');
|
||||
};
|
||||
|
||||
const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
@@ -283,7 +283,7 @@ export class AlbumRepository {
|
||||
|
||||
return tx
|
||||
.selectFrom('album')
|
||||
.selectAll()
|
||||
.selectAll('album')
|
||||
.where('id', '=', newAlbum.id)
|
||||
.select(withOwner)
|
||||
.select(withAssets)
|
||||
|
||||
@@ -260,7 +260,7 @@ export class SharedLinkRepository {
|
||||
.selectAll('asset')
|
||||
.innerJoinLateral(
|
||||
(eb) =>
|
||||
eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exif'),
|
||||
eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exifInfo'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.as('assets'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,10 @@ import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -45,35 +46,33 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get the statistics for a user, excluding archived assets', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Timeline })).resolves.toEqual(
|
||||
statResponse,
|
||||
);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {
|
||||
visibility: AssetVisibility.Timeline,
|
||||
});
|
||||
await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Timeline })).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { visibility: AssetVisibility.Timeline });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for archived assets', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Archive })).resolves.toEqual(
|
||||
statResponse,
|
||||
);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {
|
||||
await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Archive })).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, {
|
||||
visibility: AssetVisibility.Archive,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for favorite assets', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true });
|
||||
await expect(sut.getStatistics(auth, { isFavorite: true })).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { isFavorite: true });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for all assets', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {});
|
||||
await expect(sut.getStatistics(auth, {})).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,10 +248,11 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail linking a live video if the motion part could not be found', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
sut.update(auth, assetStub.livePhotoStillAsset.id, {
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -267,11 +267,12 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail linking a live video if the motion part is not a video', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
|
||||
@@ -291,16 +292,17 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail linking a live video if the motion part has a different owner', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
sut.update(auth, assetStub.livePhotoStillAsset.id, {
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -315,52 +317,41 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should link a live video', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce({
|
||||
...assetStub.livePhotoMotionAsset,
|
||||
ownerId: authStub.admin.user.id,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.image);
|
||||
mocks.asset.update.mockResolvedValue(assetStub.image);
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline });
|
||||
const stillAsset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(stillAsset);
|
||||
mocks.asset.update.mockResolvedValue(stillAsset);
|
||||
const auth = AuthFactory.from(motionAsset.owner).build();
|
||||
|
||||
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id });
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.livePhotoMotionAsset.id,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
});
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
});
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, visibility: AssetVisibility.Hidden });
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { assetId: motionAsset.id, userId: auth.user.id });
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, livePhotoVideoId: motionAsset.id });
|
||||
});
|
||||
|
||||
it('should throw an error if asset could not be found after update', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf(
|
||||
await expect(sut.update(AuthFactory.create(), 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should unlink a live video', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
mocks.asset.update.mockResolvedValueOnce(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null });
|
||||
await sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null });
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
@@ -372,7 +363,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AssetShow', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,17 +383,15 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('updateAll', () => {
|
||||
it('should require asset write access for all ids', async () => {
|
||||
await expect(
|
||||
sut.updateAll(authStub.admin, {
|
||||
ids: ['asset-1'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const auth = AuthFactory.create();
|
||||
await expect(sut.updateAll(auth, { ids: ['asset-1'] })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should update all assets', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
|
||||
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive });
|
||||
await sut.updateAll(auth, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive });
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], {
|
||||
visibility: AssetVisibility.Archive,
|
||||
@@ -410,9 +399,10 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should not update Assets table if no relevant fields are provided', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
|
||||
await sut.updateAll(authStub.admin, {
|
||||
await sut.updateAll(auth, {
|
||||
ids: ['asset-1'],
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { Readable } from 'node:stream';
|
||||
import { DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
@@ -60,22 +61,22 @@ describe(DownloadService.name, () => {
|
||||
stream: new Readable(),
|
||||
};
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
const asset1 = AssetFactory.create();
|
||||
const asset2 = AssetFactory.create();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||
mocks.storage.realpath.mockRejectedValue(new Error('Could not read file'));
|
||||
mocks.asset.getByIds.mockResolvedValue([
|
||||
{ ...assetStub.noResizePath, id: 'asset-1' },
|
||||
{ ...assetStub.noWebpPath, id: 'asset-2' },
|
||||
]);
|
||||
mocks.asset.getByIds.mockResolvedValue([asset1, asset2]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
|
||||
stream: archiveMock.stream,
|
||||
});
|
||||
|
||||
expect(mocks.logger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg');
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset1.originalPath, asset1.originalFileName);
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, asset2.originalPath, asset2.originalFileName);
|
||||
});
|
||||
|
||||
it('should download an archive', async () => {
|
||||
@@ -85,20 +86,20 @@ describe(DownloadService.name, () => {
|
||||
stream: new Readable(),
|
||||
};
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
mocks.asset.getByIds.mockResolvedValue([
|
||||
{ ...assetStub.noResizePath, id: 'asset-1' },
|
||||
{ ...assetStub.noWebpPath, id: 'asset-2' },
|
||||
]);
|
||||
const asset1 = AssetFactory.create();
|
||||
const asset2 = AssetFactory.create();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||
mocks.asset.getByIds.mockResolvedValue([asset1, asset2]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
|
||||
stream: archiveMock.stream,
|
||||
});
|
||||
|
||||
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg');
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset1.originalPath, asset1.originalFileName);
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, asset2.originalPath, asset2.originalFileName);
|
||||
});
|
||||
|
||||
it('should handle duplicate file names', async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { MediaService } from 'src/services/media.service';
|
||||
import { JobCounts, RawImageInfo } from 'src/types';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { assetStub, previewFile } from 'test/fixtures/asset.stub';
|
||||
import { faceStub } from 'test/fixtures/face.stub';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
@@ -139,33 +140,30 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
});
|
||||
|
||||
it('should queue all assets with missing webp path', async () => {
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noWebpPath]));
|
||||
it('should queue all assets with missing preview', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream());
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.AssetGenerateThumbnails,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
{ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } },
|
||||
]);
|
||||
|
||||
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
});
|
||||
|
||||
it('should queue all assets with missing thumbhash', async () => {
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noThumbhash]));
|
||||
const asset = AssetFactory.from({ thumbhash: null })
|
||||
.files([AssetFileType.Thumbnail, AssetFileType.Preview])
|
||||
.build();
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream());
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.AssetGenerateThumbnails,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
{ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } },
|
||||
]);
|
||||
|
||||
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
@@ -1052,12 +1050,19 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
||||
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
|
||||
.exif({
|
||||
fileSizeInByte: 5000,
|
||||
profileDescription: 'Adobe RGB',
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, {
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
});
|
||||
@@ -1107,12 +1112,19 @@ describe(MediaService.name, () => {
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.copyTagGroup.mockResolvedValue(true);
|
||||
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.panoramaTif);
|
||||
const asset = AssetFactory.from({ originalFileName: 'panorama.tif' })
|
||||
.exif({
|
||||
fileSizeInByte: 5000,
|
||||
projectionType: 'EQUIRECTANGULAR',
|
||||
})
|
||||
.build();
|
||||
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.panoramaTif.originalPath, {
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
|
||||
colorspace: Colorspace.Srgb,
|
||||
orientation: undefined,
|
||||
processInvalidImages: false,
|
||||
@@ -1135,11 +1147,7 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
|
||||
expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith(
|
||||
'XMP-GPano',
|
||||
assetStub.panoramaTif.originalPath,
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith('XMP-GPano', asset.originalPath, expect.any(String));
|
||||
});
|
||||
|
||||
it('should respect encoding options when generating full-size preview', async () => {
|
||||
@@ -1149,12 +1157,19 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
||||
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
|
||||
.exif({
|
||||
fileSizeInByte: 5000,
|
||||
profileDescription: 'Adobe RGB',
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, {
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
});
|
||||
@@ -1181,9 +1196,16 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
||||
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
|
||||
.exif({
|
||||
fileSizeInByte: 5000,
|
||||
profileDescription: 'Adobe RGB',
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
@@ -1263,30 +1285,25 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should clean up edited files if an asset has no edits', async () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
|
||||
...assetStub.withoutEdits,
|
||||
});
|
||||
const asset = AssetFactory.from({ thumbhash: factory.buffer() })
|
||||
.exif()
|
||||
.files([
|
||||
{ type: AssetFileType.Preview, path: 'edited1.jpg', isEdited: true },
|
||||
{ type: AssetFileType.Thumbnail, path: 'edited2.jpg', isEdited: true },
|
||||
{ type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true },
|
||||
])
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
|
||||
const status = await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id });
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: {
|
||||
files: expect.arrayContaining([
|
||||
'/uploads/user-id/fullsize/path_edited.jpg',
|
||||
'/uploads/user-id/preview/path_edited.jpg',
|
||||
'/uploads/user-id/thumbnail/path_edited.jpg',
|
||||
]),
|
||||
files: expect.arrayContaining(['edited1.jpg', 'edited2.jpg', 'edited3.jpg']),
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: '/uploads/user-id/preview/path_edited.jpg' }),
|
||||
expect.objectContaining({ path: '/uploads/user-id/thumbnail/path_edited.jpg' }),
|
||||
expect.objectContaining({ path: '/uploads/user-id/fullsize/path_edited.jpg' }),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(status).toBe(JobStatus.Success);
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
@@ -1320,11 +1337,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should generate the original thumbhash if no edits exist', async () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
|
||||
...assetStub.withoutEdits,
|
||||
});
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
|
||||
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id, source: 'upload' });
|
||||
|
||||
@@ -1335,18 +1350,14 @@ describe(MediaService.name, () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
|
||||
...assetStub.withCropEdit,
|
||||
});
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
const thumbhashBuffer = factory.buffer();
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbhash: thumbhashBuffer,
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ export class MediaService extends BaseService {
|
||||
|
||||
const generated = await this.generateEditedThumbnails(asset, config);
|
||||
await this.syncFiles(
|
||||
asset.files.filter((asset) => asset.isEdited),
|
||||
asset.files.filter((file) => file.isEdited),
|
||||
generated?.files ?? [],
|
||||
);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
@@ -24,13 +25,6 @@ import { tagStub } from 'test/fixtures/tag.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const removeNonSidecarFiles = (asset: any) => {
|
||||
return {
|
||||
...asset,
|
||||
files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar),
|
||||
};
|
||||
};
|
||||
|
||||
const forSidecarJob = (
|
||||
asset: {
|
||||
id?: string;
|
||||
@@ -182,17 +176,18 @@ describe(MetadataService.name, () => {
|
||||
it('should handle a date in a sidecar file', async () => {
|
||||
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
||||
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), {
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
});
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: assetStub.image.id,
|
||||
id: asset.id,
|
||||
duration: null,
|
||||
fileCreatedAt: sidecarDate,
|
||||
localDateTime: sidecarDate,
|
||||
@@ -203,7 +198,8 @@ describe(MetadataService.name, () => {
|
||||
it('should take the file modification date when missing exif and earlier than creation date', async () => {
|
||||
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: fileModifiedAt,
|
||||
@@ -212,14 +208,14 @@ describe(MetadataService.name, () => {
|
||||
} as Stats);
|
||||
mockReadTags();
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.image.id,
|
||||
id: asset.id,
|
||||
duration: null,
|
||||
fileCreatedAt: fileModifiedAt,
|
||||
fileModifiedAt,
|
||||
@@ -232,7 +228,8 @@ describe(MetadataService.name, () => {
|
||||
it('should take the file creation date when missing exif and earlier than modification date', async () => {
|
||||
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: fileModifiedAt,
|
||||
@@ -241,14 +238,14 @@ describe(MetadataService.name, () => {
|
||||
} as Stats);
|
||||
mockReadTags();
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.image.id,
|
||||
id: asset.id,
|
||||
duration: null,
|
||||
fileCreatedAt,
|
||||
fileModifiedAt,
|
||||
@@ -260,10 +257,11 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
|
||||
process.env.TZ = 'America/Los_Angeles';
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
|
||||
@@ -279,16 +277,15 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle lists of numbers', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: assetStub.image.fileModifiedAt,
|
||||
mtimeMs: assetStub.image.fileModifiedAt.valueOf(),
|
||||
birthtimeMs: assetStub.image.fileCreatedAt.valueOf(),
|
||||
mtime: asset.fileModifiedAt,
|
||||
mtimeMs: asset.fileModifiedAt.valueOf(),
|
||||
birthtimeMs: asset.fileCreatedAt.valueOf(),
|
||||
} as Stats);
|
||||
mockReadTags({
|
||||
ISO: [160],
|
||||
});
|
||||
mockReadTags({ ISO: [160] });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
@@ -296,11 +293,11 @@ describe(MetadataService.name, () => {
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
});
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.image.id,
|
||||
id: asset.id,
|
||||
duration: null,
|
||||
fileCreatedAt: assetStub.image.fileCreatedAt,
|
||||
fileModifiedAt: assetStub.image.fileCreatedAt,
|
||||
localDateTime: assetStub.image.fileCreatedAt,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileCreatedAt,
|
||||
localDateTime: asset.fileCreatedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
@@ -308,77 +305,77 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should not delete latituide and longitude without reverse geocode', async () => {
|
||||
// regression test for issue 17511
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } });
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: assetStub.withLocation.fileModifiedAt,
|
||||
mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(),
|
||||
birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(),
|
||||
mtime: asset.fileModifiedAt,
|
||||
mtimeMs: asset.fileModifiedAt.valueOf(),
|
||||
birthtimeMs: asset.fileCreatedAt.valueOf(),
|
||||
} as Stats);
|
||||
mockReadTags({
|
||||
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
|
||||
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
|
||||
GPSLatitude: asset.exifInfo.latitude!,
|
||||
GPSLongitude: asset.exifInfo.longitude!,
|
||||
});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ city: null, state: null, country: null }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.withLocation.id,
|
||||
id: asset.id,
|
||||
duration: null,
|
||||
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
|
||||
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
|
||||
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply reverse geocoding', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
|
||||
const asset = AssetFactory.from().exif({ latitude: 10, longitude: 20 }).build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
|
||||
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: assetStub.withLocation.fileModifiedAt,
|
||||
mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(),
|
||||
birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(),
|
||||
mtime: asset.fileModifiedAt,
|
||||
mtimeMs: asset.fileModifiedAt.valueOf(),
|
||||
birthtimeMs: asset.fileCreatedAt.valueOf(),
|
||||
} as Stats);
|
||||
mockReadTags({
|
||||
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
|
||||
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
|
||||
});
|
||||
mockReadTags({ GPSLatitude: 10, GPSLongitude: 20 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.withLocation.id,
|
||||
id: asset.id,
|
||||
duration: null,
|
||||
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
|
||||
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
|
||||
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should discard latitude and longitude on null island', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mockReadTags({
|
||||
GPSLatitude: 0,
|
||||
GPSLongitude: 0,
|
||||
});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ latitude: null, longitude: null }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
@@ -386,19 +383,25 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
|
||||
});
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent/Child'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -406,135 +409,147 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||
userId: 'user-id',
|
||||
userId: asset.ownerId,
|
||||
value: 'Parent',
|
||||
parentId: undefined,
|
||||
});
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||
userId: 'user-id',
|
||||
userId: asset.ownerId,
|
||||
value: 'Parent/Child',
|
||||
parentId: 'tag-parent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
|
||||
});
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent', '2024'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined });
|
||||
});
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent/Child'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||
userId: 'user-id',
|
||||
userId: asset.ownerId,
|
||||
value: 'Parent',
|
||||
parentId: undefined,
|
||||
});
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||
userId: 'user-id',
|
||||
userId: asset.ownerId,
|
||||
value: 'Parent/Child',
|
||||
parentId: 'tag-parent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }),
|
||||
});
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent/Child', 'Child'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||
userId: 'user-id',
|
||||
userId: asset.ownerId,
|
||||
value: 'Parent',
|
||||
parentId: undefined,
|
||||
});
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||
userId: 'user-id',
|
||||
userId: asset.ownerId,
|
||||
value: 'Parent/Child',
|
||||
parentId: 'tag-parent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }),
|
||||
});
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent/Child', 'TagA'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||
userId: 'user-id',
|
||||
userId: asset.ownerId,
|
||||
value: 'Parent',
|
||||
parentId: undefined,
|
||||
});
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||
userId: 'user-id',
|
||||
userId: asset.ownerId,
|
||||
value: 'Parent/Child',
|
||||
parentId: 'tag-parent',
|
||||
});
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, {
|
||||
userId: asset.ownerId,
|
||||
value: 'TagA',
|
||||
parent: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
|
||||
});
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ tags: ['Parent', '2024'] })
|
||||
.build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
|
||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined });
|
||||
});
|
||||
|
||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||
@@ -1646,31 +1661,23 @@ describe(MetadataService.name, () => {
|
||||
|
||||
describe('handleQueueSidecar', () => {
|
||||
it('should queue assets with sidecar files', async () => {
|
||||
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image]));
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([asset]));
|
||||
|
||||
await sut.handleQueueSidecar({ force: true });
|
||||
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true);
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SidecarCheck,
|
||||
data: { id: assetStub.sidecar.id },
|
||||
},
|
||||
]);
|
||||
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarCheck, data: { id: asset.id } }]);
|
||||
});
|
||||
|
||||
it('should queue assets without sidecar files', async () => {
|
||||
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image]));
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([asset]));
|
||||
|
||||
await sut.handleQueueSidecar({ force: false });
|
||||
|
||||
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SidecarCheck,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarCheck, data: { id: asset.id } }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { StackService } from 'src/services/stack.service';
|
||||
import { assetStub, stackStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(StackService.name, () => {
|
||||
@@ -204,9 +205,9 @@ describe(StackService.name, () => {
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||
mocks.stack.getForAssetRemoval.mockResolvedValue({ id: null, primaryAssetId: null });
|
||||
|
||||
await expect(
|
||||
sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.imageFrom2015.id }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: newUuid() })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.event.emit).not.toHaveBeenCalled();
|
||||
|
||||
54
server/test/factories/album-user.factory.ts
Normal file
54
server/test/factories/album-user.factory.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AlbumUserLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class AlbumUserFactory {
|
||||
#user!: UserFactory;
|
||||
|
||||
private constructor(private readonly value: Selectable<AlbumUserTable>) {
|
||||
value.userId ??= newUuid();
|
||||
this.#user = UserFactory.from({ id: value.userId });
|
||||
}
|
||||
|
||||
static create(dto: AlbumUserLike = {}) {
|
||||
return AlbumUserFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: AlbumUserLike = {}) {
|
||||
return new AlbumUserFactory({
|
||||
albumId: newUuid(),
|
||||
userId: newUuid(),
|
||||
role: AlbumUserRole.Editor,
|
||||
createId: newUuidV7(),
|
||||
createdAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
updatedAt: newDate(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
album(dto: AlbumUserLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
|
||||
const album = build(AlbumFactory.from(dto), builder);
|
||||
this.value.albumId = album.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
const user = build(UserFactory.from(dto), builder);
|
||||
this.value.userId = user.build().id;
|
||||
this.#user = user;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return {
|
||||
...this.value,
|
||||
user: this.#user.build(),
|
||||
};
|
||||
}
|
||||
}
|
||||
87
server/test/factories/album.factory.ts
Normal file
87
server/test/factories/album.factory.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { AssetOrder } from 'src/enum';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { AlbumUserFactory } from 'test/factories/album-user.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AlbumLike, AlbumUserLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class AlbumFactory {
|
||||
#owner: UserFactory;
|
||||
#sharedLinks: Selectable<SharedLinkTable>[] = [];
|
||||
#albumUsers: AlbumUserFactory[] = [];
|
||||
#assets: AssetFactory[] = [];
|
||||
|
||||
private constructor(private readonly value: Selectable<AlbumTable>) {
|
||||
value.ownerId ??= newUuid();
|
||||
this.#owner = UserFactory.from({ id: value.ownerId });
|
||||
}
|
||||
|
||||
static create(dto: AlbumLike = {}) {
|
||||
return AlbumFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: AlbumLike = {}) {
|
||||
return new AlbumFactory({
|
||||
id: newUuid(),
|
||||
ownerId: newUuid(),
|
||||
albumName: 'My Album',
|
||||
albumThumbnailAssetId: null,
|
||||
createdAt: newDate(),
|
||||
deletedAt: null,
|
||||
description: 'Album description',
|
||||
isActivityEnabled: false,
|
||||
order: AssetOrder.Desc,
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...dto,
|
||||
}).owner();
|
||||
}
|
||||
|
||||
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#owner = build(UserFactory.from(dto), builder);
|
||||
this.value.ownerId = this.#owner.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
sharedLinks() {
|
||||
this.#sharedLinks = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
albumUser(dto: AlbumUserLike = {}, builder?: FactoryBuilder<AlbumUserFactory>) {
|
||||
const albumUser = build(AlbumUserFactory.from(dto).album(this.value), builder);
|
||||
this.#albumUsers.push(albumUser);
|
||||
return this;
|
||||
}
|
||||
|
||||
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
|
||||
const asset = build(AssetFactory.from(dto), builder);
|
||||
|
||||
// use album owner by default
|
||||
if (!dto.ownerId) {
|
||||
asset.owner(this.#owner.build());
|
||||
}
|
||||
|
||||
if (!this.#assets) {
|
||||
this.#assets = [];
|
||||
}
|
||||
|
||||
this.#assets.push(asset);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return {
|
||||
...this.value,
|
||||
owner: this.#owner.build(),
|
||||
assets: this.#assets.map((asset) => asset.build()),
|
||||
albumUsers: this.#albumUsers.map((albumUser) => albumUser.build()),
|
||||
sharedLinks: this.#sharedLinks,
|
||||
};
|
||||
}
|
||||
}
|
||||
38
server/test/factories/asset-edit.factory.ts
Normal file
38
server/test/factories/asset-edit.factory.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
|
||||
export class AssetEditFactory {
|
||||
private constructor(private readonly value: Selectable<AssetEditTable>) {}
|
||||
|
||||
static create(dto: AssetEditLike = {}) {
|
||||
return AssetEditFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: AssetEditLike = {}) {
|
||||
const id = dto.id ?? newUuid();
|
||||
|
||||
return new AssetEditFactory({
|
||||
id,
|
||||
assetId: newUuid(),
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: { x: 5, y: 6, width: 200, height: 100 },
|
||||
sequence: 1,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
|
||||
const asset = build(AssetFactory.from(dto), builder);
|
||||
this.value.assetId = asset.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value } as Selectable<AssetEditTable<AssetEditAction.Crop>>;
|
||||
}
|
||||
}
|
||||
55
server/test/factories/asset-exif.factory.ts
Normal file
55
server/test/factories/asset-exif.factory.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetExifLike } from 'test/factories/types';
|
||||
import { factory } from 'test/small.factory';
|
||||
|
||||
export class AssetExifFactory {
|
||||
private constructor(private readonly value: Selectable<AssetExifTable>) {}
|
||||
|
||||
static create(dto: AssetExifLike = {}) {
|
||||
return AssetExifFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: AssetExifLike = {}) {
|
||||
return new AssetExifFactory({
|
||||
updatedAt: factory.date(),
|
||||
updateId: factory.uuid(),
|
||||
assetId: factory.uuid(),
|
||||
autoStackId: null,
|
||||
bitsPerSample: null,
|
||||
city: 'Austin',
|
||||
colorspace: null,
|
||||
country: 'United States of America',
|
||||
dateTimeOriginal: factory.date(),
|
||||
description: '',
|
||||
exifImageHeight: 420,
|
||||
exifImageWidth: 42,
|
||||
exposureTime: null,
|
||||
fileSizeInByte: 69,
|
||||
fNumber: 1.7,
|
||||
focalLength: 4.38,
|
||||
fps: null,
|
||||
iso: 947,
|
||||
latitude: 30.267_334_570_570_195,
|
||||
longitude: -97.789_833_534_282_07,
|
||||
lensModel: null,
|
||||
livePhotoCID: null,
|
||||
make: 'Google',
|
||||
model: 'Pixel 7',
|
||||
modifyDate: factory.date(),
|
||||
orientation: '1',
|
||||
profileDescription: null,
|
||||
projectionType: null,
|
||||
rating: 4,
|
||||
lockedProperties: [],
|
||||
state: 'Texas',
|
||||
tags: ['parent/child'],
|
||||
timeZone: 'UTC-6',
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
43
server/test/factories/asset-file.factory.ts
Normal file
43
server/test/factories/asset-file.factory.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AssetFileLike, AssetLike, FactoryBuilder } from 'test/factories/types';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class AssetFileFactory {
|
||||
private constructor(private readonly value: Selectable<AssetFileTable>) {}
|
||||
|
||||
static create(dto: AssetFileLike = {}) {
|
||||
return AssetFileFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: AssetFileLike = {}) {
|
||||
const id = dto.id ?? newUuid();
|
||||
const isEdited = dto.isEdited ?? false;
|
||||
|
||||
return new AssetFileFactory({
|
||||
id,
|
||||
assetId: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: `/data/12/34/thumbs/${id.slice(0, 2)}/${id.slice(2, 4)}/${id}${isEdited ? '_edited' : ''}.jpg`,
|
||||
updateId: newUuidV7(),
|
||||
isProgressive: false,
|
||||
isEdited,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
|
||||
const asset = build(AssetFactory.from(dto), builder);
|
||||
this.value.assetId = asset.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
126
server/test/factories/asset.factory.ts
Normal file
126
server/test/factories/asset.factory.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
|
||||
import { AssetExifFactory } from 'test/factories/asset-exif.factory';
|
||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class AssetFactory {
|
||||
#owner!: UserFactory;
|
||||
#assetExif?: AssetExifFactory;
|
||||
#files: AssetFileFactory[] = [];
|
||||
#edits: AssetEditFactory[] = [];
|
||||
|
||||
private constructor(private readonly value: Selectable<AssetTable>) {
|
||||
value.ownerId ??= newUuid();
|
||||
this.#owner = UserFactory.from({ id: value.ownerId });
|
||||
}
|
||||
|
||||
static create(dto: AssetLike = {}) {
|
||||
return AssetFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: AssetLike = {}) {
|
||||
const id = dto.id ?? newUuid();
|
||||
|
||||
const originalFileName = dto.originalFileName ?? `IMG_${id}.jpg`;
|
||||
|
||||
return new AssetFactory({
|
||||
id,
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
deletedAt: null,
|
||||
updateId: newUuidV7(),
|
||||
status: AssetStatus.Active,
|
||||
checksum: newSha1(),
|
||||
deviceAssetId: '',
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
encodedVideoPath: null,
|
||||
fileCreatedAt: newDate(),
|
||||
fileModifiedAt: newDate(),
|
||||
isExternal: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: newDate(),
|
||||
originalFileName,
|
||||
originalPath: `/data/library/${originalFileName}`,
|
||||
ownerId: newUuid(),
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetType.Image,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
isEdited: false,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#owner = build(UserFactory.from(dto), builder);
|
||||
this.value.ownerId = this.#owner.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
exif(dto: AssetExifLike = {}, builder?: FactoryBuilder<AssetExifFactory>) {
|
||||
this.#assetExif = build(AssetExifFactory.from(dto), builder);
|
||||
return this;
|
||||
}
|
||||
|
||||
edit(dto: AssetEditLike = {}, builder?: FactoryBuilder<AssetEditFactory>) {
|
||||
this.#edits.push(build(AssetEditFactory.from(dto).asset(this.value), builder));
|
||||
this.value.isEdited = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
file(dto: AssetFileLike = {}, builder?: FactoryBuilder<AssetFileFactory>) {
|
||||
this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder));
|
||||
return this;
|
||||
}
|
||||
|
||||
files(dto?: 'edits'): AssetFactory;
|
||||
files(items: AssetFileLike[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory;
|
||||
files(items: AssetFileType[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory;
|
||||
files(dto?: 'edits' | AssetFileLike[] | AssetFileType[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory {
|
||||
const items: AssetFileLike[] = [];
|
||||
|
||||
if (dto === undefined || dto === 'edits') {
|
||||
items.push(...Object.values(AssetFileType).map((type) => ({ type })));
|
||||
|
||||
if (dto === 'edits') {
|
||||
items.push(...Object.values(AssetFileType).map((type) => ({ type, isEdited: true })));
|
||||
}
|
||||
} else {
|
||||
for (const item of dto) {
|
||||
items.push(typeof item === 'string' ? { type: item as AssetFileType } : item);
|
||||
}
|
||||
}
|
||||
for (const item of items) {
|
||||
this.file(item, builder);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
const exif = this.#assetExif?.build();
|
||||
|
||||
return {
|
||||
...this.value,
|
||||
owner: this.#owner.build(),
|
||||
exifInfo: exif as NonNullable<typeof exif>,
|
||||
files: this.#files.map((file) => file.build()),
|
||||
edits: this.#edits.map((edit) => edit.build()),
|
||||
faces: [] as Selectable<AssetFaceTable>[],
|
||||
};
|
||||
}
|
||||
}
|
||||
48
server/test/factories/auth.factory.ts
Normal file
48
server/test/factories/auth.factory.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
|
||||
import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
|
||||
export class AuthFactory {
|
||||
#user: UserFactory;
|
||||
#sharedLink?: SharedLinkFactory;
|
||||
|
||||
private constructor(user: UserFactory) {
|
||||
this.#user = user;
|
||||
}
|
||||
|
||||
static create(dto: UserLike = {}) {
|
||||
return AuthFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: UserLike = {}) {
|
||||
return new AuthFactory(UserFactory.from(dto));
|
||||
}
|
||||
|
||||
apiKey() {
|
||||
// TODO
|
||||
return this;
|
||||
}
|
||||
|
||||
sharedLink(dto: SharedLinkLike = {}, builder?: FactoryBuilder<SharedLinkFactory>) {
|
||||
this.#sharedLink = build(SharedLinkFactory.from(dto), builder);
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): AuthDto {
|
||||
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id,
|
||||
isAdmin,
|
||||
name,
|
||||
email,
|
||||
quotaUsageInBytes,
|
||||
quotaSizeInBytes,
|
||||
},
|
||||
sharedLink: this.#sharedLink?.build(),
|
||||
};
|
||||
}
|
||||
}
|
||||
5
server/test/factories/builder.factory.ts
Normal file
5
server/test/factories/builder.factory.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { FactoryBuilder } from 'test/factories/types';
|
||||
|
||||
export const build = <T>(factory: T, builder?: FactoryBuilder<T>) => {
|
||||
return builder ? builder(factory) : factory;
|
||||
};
|
||||
63
server/test/factories/shared-link.factory.ts
Normal file
63
server/test/factories/shared-link.factory.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AlbumLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { factory, newDate, newUuid } from 'test/small.factory';
|
||||
|
||||
export class SharedLinkFactory {
|
||||
#owner: UserFactory;
|
||||
#album?: AlbumFactory;
|
||||
|
||||
private constructor(private readonly value: Selectable<SharedLinkTable>) {
|
||||
value.userId ??= newUuid();
|
||||
this.#owner = UserFactory.from({ id: value.userId });
|
||||
}
|
||||
|
||||
static create(dto: SharedLinkLike = {}) {
|
||||
return SharedLinkFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: SharedLinkLike = {}) {
|
||||
const type = dto.type ?? SharedLinkType.Individual;
|
||||
const albumId = (dto.albumId ?? type === SharedLinkType.Album) ? newUuid() : null;
|
||||
|
||||
return new SharedLinkFactory({
|
||||
id: factory.uuid(),
|
||||
description: 'Shared link description',
|
||||
userId: newUuid(),
|
||||
key: factory.buffer(),
|
||||
type,
|
||||
albumId,
|
||||
createdAt: newDate(),
|
||||
expiresAt: null,
|
||||
allowUpload: true,
|
||||
allowDownload: true,
|
||||
showExif: true,
|
||||
password: null,
|
||||
slug: null,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>): SharedLinkFactory {
|
||||
this.#owner = build(UserFactory.from(dto), builder);
|
||||
return this;
|
||||
}
|
||||
|
||||
album(dto: AlbumLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
|
||||
this.#album = build(AlbumFactory.from(dto), builder);
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return {
|
||||
...this.value,
|
||||
owner: this.#owner.build(),
|
||||
album: this.#album?.build(),
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
20
server/test/factories/types.ts
Normal file
20
server/test/factories/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
export type FactoryBuilder<T, R extends T = T> = (builder: T) => R;
|
||||
|
||||
export type AssetLike = Partial<Selectable<AssetTable>>;
|
||||
export type AssetExifLike = Partial<Selectable<AssetExifTable>>;
|
||||
export type AssetEditLike = Partial<Selectable<AssetEditTable>>;
|
||||
export type AssetFileLike = Partial<Selectable<AssetFileTable>>;
|
||||
export type AlbumLike = Partial<Selectable<AlbumTable>>;
|
||||
export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
|
||||
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
|
||||
export type UserLike = Partial<Selectable<UserTable>>;
|
||||
46
server/test/factories/user.factory.ts
Normal file
46
server/test/factories/user.factory.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { UserStatus } from 'src/enum';
|
||||
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { UserLike } from 'test/factories/types';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class UserFactory {
|
||||
private constructor(private value: Selectable<UserTable>) {}
|
||||
|
||||
static create(dto: UserLike = {}) {
|
||||
return UserFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: UserLike = {}) {
|
||||
return new UserFactory({
|
||||
id: newUuid(),
|
||||
email: 'test@immich.cloud',
|
||||
password: '',
|
||||
pinCode: null,
|
||||
createdAt: newDate(),
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
avatarColor: null,
|
||||
deletedAt: null,
|
||||
oauthId: '',
|
||||
updatedAt: newDate(),
|
||||
storageLabel: null,
|
||||
name: 'Test User',
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
status: UserStatus.Active,
|
||||
profileChangedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return {
|
||||
...this.value,
|
||||
metadata: [] as UserMetadataTable[],
|
||||
};
|
||||
}
|
||||
}
|
||||
68
server/test/fixtures/album.stub.ts
vendored
68
server/test/fixtures/album.stub.ts
vendored
@@ -45,56 +45,6 @@ export const albumStub = {
|
||||
order: AssetOrder.Desc,
|
||||
updateId: '42',
|
||||
}),
|
||||
sharedWithMultiple: Object.freeze({
|
||||
id: 'album-3',
|
||||
albumName: 'Empty album shared with users',
|
||||
description: '',
|
||||
ownerId: authStub.admin.user.id,
|
||||
owner: userStub.admin,
|
||||
assets: [],
|
||||
albumThumbnailAsset: null,
|
||||
albumThumbnailAssetId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
albumUsers: [
|
||||
{
|
||||
user: userStub.user1,
|
||||
role: AlbumUserRole.Editor,
|
||||
},
|
||||
{
|
||||
user: userStub.user2,
|
||||
role: AlbumUserRole.Editor,
|
||||
},
|
||||
],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.Desc,
|
||||
updateId: '42',
|
||||
}),
|
||||
sharedWithAdmin: Object.freeze({
|
||||
id: 'album-3',
|
||||
albumName: 'Empty album shared with admin',
|
||||
description: '',
|
||||
ownerId: authStub.user1.user.id,
|
||||
owner: userStub.user1,
|
||||
assets: [],
|
||||
albumThumbnailAsset: null,
|
||||
albumThumbnailAssetId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
albumUsers: [
|
||||
{
|
||||
user: userStub.admin,
|
||||
role: AlbumUserRole.Editor,
|
||||
},
|
||||
],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.Desc,
|
||||
updateId: '42',
|
||||
}),
|
||||
oneAsset: Object.freeze({
|
||||
id: 'album-4',
|
||||
albumName: 'Album with one asset',
|
||||
@@ -113,24 +63,6 @@ export const albumStub = {
|
||||
order: AssetOrder.Desc,
|
||||
updateId: '42',
|
||||
}),
|
||||
twoAssets: Object.freeze({
|
||||
id: 'album-4a',
|
||||
albumName: 'Album with two assets',
|
||||
description: '',
|
||||
ownerId: authStub.admin.user.id,
|
||||
owner: userStub.admin,
|
||||
assets: [assetStub.image, assetStub.withLocation],
|
||||
albumThumbnailAsset: assetStub.image,
|
||||
albumThumbnailAssetId: assetStub.image.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
albumUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.Desc,
|
||||
updateId: '42',
|
||||
}),
|
||||
emptyWithValidThumbnail: Object.freeze({
|
||||
id: 'album-5',
|
||||
albumName: 'Empty album with valid thumbnail',
|
||||
|
||||
410
server/test/fixtures/asset.stub.ts
vendored
410
server/test/fixtures/asset.stub.ts
vendored
@@ -20,45 +20,8 @@ const fullsizeFile = factory.assetFile({
|
||||
path: '/uploads/user-id/fullsize/path.webp',
|
||||
});
|
||||
|
||||
const sidecarFileWithExt = factory.assetFile({
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/original/path.ext.xmp',
|
||||
});
|
||||
|
||||
const sidecarFileWithoutExt = factory.assetFile({
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/original/path.xmp',
|
||||
});
|
||||
|
||||
const editedPreviewFile = factory.assetFile({
|
||||
type: AssetFileType.Preview,
|
||||
path: '/uploads/user-id/preview/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const editedThumbnailFile = factory.assetFile({
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/uploads/user-id/thumbnail/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const editedFullsizeFile = factory.assetFile({
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const files = [fullsizeFile, previewFile, thumbnailFile];
|
||||
|
||||
const editedFiles = [
|
||||
fullsizeFile,
|
||||
previewFile,
|
||||
thumbnailFile,
|
||||
editedFullsizeFile,
|
||||
editedPreviewFile,
|
||||
editedThumbnailFile,
|
||||
];
|
||||
|
||||
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
|
||||
return {
|
||||
id: stackId,
|
||||
@@ -132,87 +95,6 @@ export const assetStub = {
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
noWebpPath: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/data/library/IMG_456.jpg',
|
||||
files: [previewFile],
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
duration: null,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'IMG_456.jpg',
|
||||
faces: [],
|
||||
isExternal: false,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 123_000,
|
||||
} as Exif,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [],
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
noThumbhash: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.ext',
|
||||
files,
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
thumbhash: null,
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
duration: null,
|
||||
isExternal: false,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.ext',
|
||||
faces: [],
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [],
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
primaryImage: Object.freeze({
|
||||
id: 'primary-asset-id',
|
||||
status: AssetStatus.Active,
|
||||
@@ -526,48 +408,6 @@ export const assetStub = {
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
imageFrom2015: Object.freeze({
|
||||
id: 'asset-id-2015',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.ext',
|
||||
files,
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2015-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2015-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2015-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
isExternal: false,
|
||||
duration: null,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
updateId: 'foo',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.ext',
|
||||
faces: [],
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
} as Exif,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [],
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
video: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
@@ -736,81 +576,6 @@ export const assetStub = {
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
sidecar: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.ext',
|
||||
thumbhash: null,
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
files: [previewFile, sidecarFileWithExt],
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
isExternal: false,
|
||||
duration: null,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.ext',
|
||||
faces: [],
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: 'foo',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [],
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
sidecarWithoutExt: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.ext',
|
||||
thumbhash: null,
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
files: [previewFile, sidecarFileWithoutExt],
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
isExternal: false,
|
||||
duration: null,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.ext',
|
||||
faces: [],
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [],
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
hasEncodedVideo: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
@@ -854,46 +619,6 @@ export const assetStub = {
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
hasFileExtension: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/data/user1/photo.jpg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
files,
|
||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
isExternal: true,
|
||||
duration: null,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
libraryId: 'library-id',
|
||||
sharedLinks: [],
|
||||
originalFileName: 'photo.jpg',
|
||||
faces: [],
|
||||
deletedAt: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [],
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
imageDng: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
@@ -938,93 +663,6 @@ export const assetStub = {
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
imageHif: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.hif',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
files,
|
||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
duration: null,
|
||||
isExternal: false,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.hif',
|
||||
faces: [],
|
||||
deletedAt: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
profileDescription: 'Adobe RGB',
|
||||
bitsPerSample: 14,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [],
|
||||
isEdited: false,
|
||||
}),
|
||||
|
||||
panoramaTif: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.tif',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
files,
|
||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
duration: null,
|
||||
isExternal: false,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.tif',
|
||||
faces: [],
|
||||
deletedAt: null,
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
projectionType: 'EQUIRECTANGULAR',
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [],
|
||||
}),
|
||||
|
||||
withCropEdit: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
@@ -1082,52 +720,4 @@ export const assetStub = {
|
||||
] as AssetEditActionItem[],
|
||||
isEdited: true,
|
||||
}),
|
||||
|
||||
withoutEdits: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.Active,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.jpg',
|
||||
files: editedFiles,
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.Image,
|
||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2025-01-01T01:02:03.456Z'),
|
||||
isFavorite: true,
|
||||
duration: null,
|
||||
isExternal: false,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
updateId: 'foo',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.jpg',
|
||||
faces: [],
|
||||
deletedAt: null,
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
exifImageHeight: 3840,
|
||||
exifImageWidth: 2160,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
stack: null,
|
||||
orientation: '',
|
||||
projectionType: null,
|
||||
height: 3840,
|
||||
width: 2160,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
edits: [],
|
||||
isEdited: false,
|
||||
}),
|
||||
};
|
||||
|
||||
17
server/test/fixtures/user.stub.ts
vendored
17
server/test/fixtures/user.stub.ts
vendored
@@ -38,21 +38,4 @@ export const userStub = {
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
},
|
||||
user2: <UserAdmin>{
|
||||
...authStub.user2.user,
|
||||
status: UserStatus.Active,
|
||||
profileChangedAt: new Date('2021-01-01'),
|
||||
metadata: [],
|
||||
name: 'immich_name',
|
||||
storageLabel: null,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
avatarColor: null,
|
||||
profileImagePath: '',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user