Compare commits

..

4 Commits

Author SHA1 Message Date
Alex 9764326880 merge main 2024-10-31 11:14:26 -05:00
Alex 1ec5d89723 custom group id 2024-07-31 17:24:06 -05:00
Alex 17181e3330 share handler 2024-07-31 13:51:30 -05:00
Alex 7eccf99797 revert to no flavor 2024-07-31 12:34:42 -05:00
148 changed files with 1802 additions and 2313 deletions
+1
View File
@@ -26,6 +26,7 @@ The default configuration looks like this:
"bframes": -1,
"refs": 0,
"gopSize": 0,
"npl": 0,
"temporalAQ": false,
"cqMode": "auto",
"twoPass": false,
@@ -83,12 +83,6 @@ const projects: CommunityProjectProps[] = [
description: 'Power tools for organizing your immich library.',
url: 'https://github.com/varun-raj/immich-power-tools',
},
{
title: 'Immich Public Proxy',
description:
'Share your Immich photos and albums in a safe way without exposing your Immich instance to the public.',
url: 'https://github.com/alangrainger/immich-public-proxy',
},
];
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {
-72
View File
@@ -1148,78 +1148,6 @@ describe('/asset', () => {
},
},
},
{
input: 'formats/raw/Canon/PowerShot_G12.CR2',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'PowerShot_G12.CR2',
fileCreatedAt: '2015-12-27T09:55:40.000Z',
exifInfo: {
make: 'Canon',
model: 'Canon PowerShot G12',
exifImageHeight: 2736,
exifImageWidth: 3648,
exposureTime: '1/1000',
fNumber: 4,
focalLength: 18.098,
iso: 80,
lensModel: null,
fileSizeInByte: 11_113_617,
dateTimeOriginal: '2015-12-27T09:55:40.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Fujifilm/X100V_compressed.RAF',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'X100V_compressed.RAF',
fileCreatedAt: '2024-10-12T21:01:01.000Z',
exifInfo: {
make: 'FUJIFILM',
model: 'X100V',
exifImageHeight: 4160,
exifImageWidth: 6240,
exposureTime: '1/4000',
fNumber: 16,
focalLength: 23,
iso: 160,
lensModel: null,
fileSizeInByte: 13_551_312,
dateTimeOriginal: '2024-10-12T21:01:01.000Z',
latitude: null,
longitude: null,
orientation: '6',
},
},
},
{
input: 'formats/raw/Ricoh/GR3/Ricoh_GR3-450.DNG',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'Ricoh_GR3-450.DNG',
fileCreatedAt: '2024-06-08T13:48:39.000Z',
exifInfo: {
dateTimeOriginal: '2024-06-08T13:48:39.000Z',
exifImageHeight: 4064,
exifImageWidth: 6112,
exposureTime: '1/400',
fNumber: 5,
fileSizeInByte: 31_175_472,
focalLength: 18.3,
iso: 100,
latitude: 36.613_24,
lensModel: 'GR LENS 18.3mm F2.8',
longitude: -121.897_85,
make: 'RICOH IMAGING COMPANY, LTD.',
model: 'RICOH GR III',
orientation: '1',
},
},
},
];
it(`should upload and generate a thumbnail for different file types`, async () => {
+2
View File
@@ -305,6 +305,8 @@
"transcoding_threads_description": "Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0.",
"transcoding_tone_mapping": "Tone-mapping",
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
"transcoding_tone_mapping_npl": "Tone-mapping NPL",
"transcoding_tone_mapping_npl_description": "Colors will be adjusted to look normal for a display of this brightness. Counter-intuitively, lower values increase the brightness of the video and vice versa since it compensates for the brightness of the display. 0 sets this value automatically.",
"transcoding_transcode_policy": "Transcode policy",
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
"transcoding_two_pass_encoding": "Two-pass encoding",
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.24.4"
"flutter": "3.24.3"
}
+7
View File
@@ -32,6 +32,13 @@ target 'Runner' do
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
# share_handler addition start
target 'ShareExtension' do
inherit! :search_paths
pod "share_handler_ios_models", :path => ".symlinks/plugins/share_handler_ios/ios/Models"
end
# share_handler addition end
end
post_install do |installer|
+19 -9
View File
@@ -48,7 +48,7 @@ PODS:
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- flutter_web_auth (0.6.0):
- flutter_web_auth (0.5.0):
- Flutter
- fluttertoast (0.0.2):
- Flutter
@@ -65,8 +65,6 @@ PODS:
- maplibre_gl (0.0.1):
- Flutter
- MapLibre (= 5.14.0-pre3)
- native_video_player (1.0.0):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -83,6 +81,14 @@ PODS:
- SDWebImage (5.19.4):
- SDWebImage/Core (= 5.19.4)
- SDWebImage/Core (5.19.4)
- share_handler_ios (0.0.13):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.13)
- share_handler_ios_models
- share_handler_ios/share_handler_ios_models (0.0.13):
- Flutter
- share_handler_ios_models
- share_handler_ios_models (0.0.9)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -117,12 +123,13 @@ DEPENDENCIES:
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
@@ -171,8 +178,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -183,6 +188,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/ios"
share_handler_ios:
:path: ".symlinks/plugins/share_handler_ios/ios"
share_handler_ios_models:
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -207,7 +216,7 @@ SPEC CHECKSUMS:
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_web_auth: acc15a8fd7bba796a933c724a6dffc3d00f07c27
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
@@ -215,7 +224,6 @@ SPEC CHECKSUMS:
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
@@ -223,6 +231,8 @@ SPEC CHECKSUMS:
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
share_handler_ios: 088138c17ce73333b4875a35860cd52604041521
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
@@ -232,6 +242,6 @@ SPEC CHECKSUMS:
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
PODFILE CHECKSUM: 1d6c11dc7539b1a7e0f9cc83a63904e0f8555f72
COCOAPODS: 1.15.2
+292 -11
View File
@@ -16,8 +16,22 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
FA74AF1DE1B65E796CC9A190 /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F2F1F1634A89EAAC89566694 /* Pods_ShareExtension.framework */; };
FA87C46B2C5AADE800109ACC /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA87C46A2C5AADE800109ACC /* ShareViewController.swift */; };
FA87C46E2C5AADE800109ACC /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FA87C46C2C5AADE800109ACC /* MainInterface.storyboard */; };
FA87C4722C5AADE800109ACC /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FA87C4682C5AADE800109ACC /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
FA87C4702C5AADE800109ACC /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = FA87C4672C5AADE800109ACC;
remoteInfo = ShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
@@ -29,6 +43,17 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
FA87C4732C5AADE800109ACC /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
FA87C4722C5AADE800109ACC /* ShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -41,16 +66,26 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7B82909F828DFBC0DC7A92BF /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Immich-Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Immich-Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B4CED4956FDE3EF1F786D7AD /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
D1C2E3E4FBFF4719EF41D366 /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = "<group>"; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F2F1F1634A89EAAC89566694 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
FA87C4682C5AADE800109ACC /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
FA87C46A2C5AADE800109ACC /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
FA87C46D2C5AADE800109ACC /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
FA87C46F2C5AADE800109ACC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
FA87C4782C5AAE3500109ACC /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
FA87C4792C5AAE5300109ACC /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -63,6 +98,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
FA87C4652C5AADE800109ACC /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FA74AF1DE1B65E796CC9A190 /* Pods_ShareExtension.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -72,6 +115,9 @@
2E3441B73560D0F6FD25E04F /* Pods-Runner.debug.xcconfig */,
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */,
F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */,
B4CED4956FDE3EF1F786D7AD /* Pods-ShareExtension.debug.xcconfig */,
D1C2E3E4FBFF4719EF41D366 /* Pods-ShareExtension.release.xcconfig */,
7B82909F828DFBC0DC7A92BF /* Pods-ShareExtension.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -80,6 +126,7 @@
isa = PBXGroup;
children = (
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */,
F2F1F1634A89EAAC89566694 /* Pods_ShareExtension.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -109,6 +156,7 @@
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
FA87C4692C5AADE800109ACC /* ShareExtension */,
97C146EF1CF9000F007C117D /* Products */,
0FB772A5B9601143383626CA /* Pods */,
1754452DD81DA6620E279E51 /* Frameworks */,
@@ -118,7 +166,8 @@
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Immich-Debug.app */,
97C146EE1CF9000F007C117D /* Runner.app */,
FA87C4682C5AADE800109ACC /* ShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -126,6 +175,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FA87C4782C5AAE3500109ACC /* Runner.entitlements */,
65DD438629917FAD0047FFA8 /* BackgroundSync */,
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -140,6 +190,17 @@
path = Runner;
sourceTree = "<group>";
};
FA87C4692C5AADE800109ACC /* ShareExtension */ = {
isa = PBXGroup;
children = (
FA87C4792C5AAE5300109ACC /* ShareExtension.entitlements */,
FA87C46A2C5AADE800109ACC /* ShareViewController.swift */,
FA87C46C2C5AADE800109ACC /* MainInterface.storyboard */,
FA87C46F2C5AADE800109ACC /* Info.plist */,
);
path = ShareExtension;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -153,6 +214,7 @@
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
FA87C4732C5AADE800109ACC /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */,
6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */,
@@ -160,12 +222,31 @@
buildRules = (
);
dependencies = (
FA87C4712C5AADE800109ACC /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
FA87C4672C5AADE800109ACC /* ShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = FA87C4772C5AADE800109ACC /* Build configuration list for PBXNativeTarget "ShareExtension" */;
buildPhases = (
81EA71A2AAF4A216CCE6E79E /* [CP] Check Pods Manifest.lock */,
FA87C4642C5AADE800109ACC /* Sources */,
FA87C4652C5AADE800109ACC /* Frameworks */,
FA87C4662C5AADE800109ACC /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = ShareExtension;
productName = ShareExtension;
productReference = FA87C4682C5AADE800109ACC /* ShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -173,6 +254,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1510;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
@@ -180,6 +262,9 @@
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
FA87C4672C5AADE800109ACC = {
CreatedOnToolsVersion = 15.1;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@@ -196,6 +281,7 @@
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
FA87C4672C5AADE800109ACC /* ShareExtension */,
);
};
/* End PBXProject section */
@@ -212,6 +298,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
FA87C4662C5AADE800109ACC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FA87C46E2C5AADE800109ACC /* MainInterface.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -270,6 +364,28 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
81EA71A2AAF4A216CCE6E79E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-ShareExtension-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -316,8 +432,24 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
FA87C4642C5AADE800109ACC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FA87C46B2C5AADE800109ACC /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
FA87C4712C5AADE800109ACC /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = FA87C4672C5AADE800109ACC /* ShareExtension */;
targetProxy = FA87C4702C5AADE800109ACC /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -335,6 +467,14 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
FA87C46C2C5AADE800109ACC /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
FA87C46D2C5AADE800109ACC /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
@@ -396,23 +536,25 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.107.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile;
PRODUCT_NAME = "Immich-Profile";
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -539,22 +681,25 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.107.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.debug;
PRODUCT_NAME = "Immich-Debug";
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -567,22 +712,25 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.107.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_NAME = Immich;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -590,6 +738,129 @@
};
name = Release;
};
FA87C4742C5AADE800109ACC /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B4CED4956FDE3EF1F786D7AD /* Pods-ShareExtension.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
FA87C4752C5AADE800109ACC /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D1C2E3E4FBFF4719EF41D366 /* Pods-ShareExtension.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
FA87C4762C5AADE800109ACC /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7B82909F828DFBC0DC7A92BF /* Pods-ShareExtension.profile.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -613,6 +884,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
FA87C4772C5AADE800109ACC /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
FA87C4742C5AADE800109ACC /* Debug */,
FA87C4752C5AADE800109ACC /* Release */,
FA87C4762C5AADE800109ACC /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
@@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -31,7 +31,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -56,7 +56,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -73,7 +73,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
+160 -121
View File
@@ -1,124 +1,163 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.119.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>181</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
</plist>
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Immich</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.107.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>162</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
<!-- Add for share_handler start -->
<!-- The 'NSUserActivityTypes' key is only needed if you plan to use the recordSentMessage API
allowing for conversations to show up as direct share suggestions -->
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<!-- Uncomment below lines if you want to use a custom group id rather than the default. Set it
in Build Settings -> User-Defined -->
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<!-- Add for share_handler end -->
</dict>
</plist>
+6 -1
View File
@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count &gt; 0 ).@count
&gt; 0 </string>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
@@ -0,0 +1,10 @@
//
// ShareViewController.swift
// ShareExtension
//
// Created by Alex Tran on 7/31/24.
//
import share_handler_ios_models
class ShareViewController: ShareHandlerIosViewController {}
+24 -10
View File
@@ -22,8 +22,12 @@ class Asset {
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
type = remote.type.toAssetType(),
fileName = remote.originalFileName,
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
height = isFlipped(remote)
? remote.exifInfo?.exifImageWidth?.toInt()
: remote.exifInfo?.exifImageHeight?.toInt(),
width = isFlipped(remote)
? remote.exifInfo?.exifImageHeight?.toInt()
: remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
ownerId = fastHash(remote.ownerId),
exifInfo =
@@ -188,14 +192,6 @@ class Asset {
@ignore
set byteHash(List<int> hash) => checksum = base64.encode(hash);
@ignore
int? get orientatedWidth =>
exifInfo != null && exifInfo!.isFlipped ? height : width;
@ignore
int? get orientatedHeight =>
exifInfo != null && exifInfo!.isFlipped ? width : height;
@override
bool operator ==(other) {
if (other is! Asset) return false;
@@ -515,3 +511,21 @@ extension AssetsHelper on IsarCollection<Asset> {
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
}
}
/// Returns `true` if this [int] is flipped 90° clockwise
bool isRotated90CW(int orientation) {
return [7, 8, -90].contains(orientation);
}
/// Returns `true` if this [int] is flipped 270° clockwise
bool isRotated270CW(int orientation) {
return [5, 6, 90].contains(orientation);
}
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
bool isFlipped(AssetResponseDto response) {
final int orientation =
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
return orientation != 0 &&
(isRotated90CW(orientation) || isRotated270CW(orientation));
}
+3 -27
View File
@@ -23,7 +23,6 @@ class ExifInfo {
String? state;
String? country;
String? description;
String? orientation;
@ignore
bool get hasCoordinates =>
@@ -46,12 +45,6 @@ class ExifInfo {
@ignore
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
@ignore
bool? _isFlipped;
@ignore
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
@ignore
double? get latitude => lat;
@@ -74,8 +67,7 @@ class ExifInfo {
city = dto.city,
state = dto.state,
country = dto.country,
description = dto.description,
orientation = dto.orientation;
description = dto.description;
ExifInfo({
this.id,
@@ -95,7 +87,6 @@ class ExifInfo {
this.state,
this.country,
this.description,
this.orientation,
});
ExifInfo copyWith({
@@ -116,7 +107,6 @@ class ExifInfo {
String? state,
String? country,
String? description,
String? orientation,
}) =>
ExifInfo(
id: id ?? this.id,
@@ -136,7 +126,6 @@ class ExifInfo {
state: state ?? this.state,
country: country ?? this.country,
description: description ?? this.description,
orientation: orientation ?? this.orientation,
);
@override
@@ -158,8 +147,7 @@ class ExifInfo {
city == other.city &&
state == other.state &&
country == other.country &&
description == other.description &&
orientation == other.orientation;
description == other.description;
}
@override
@@ -181,8 +169,7 @@ class ExifInfo {
city.hashCode ^
state.hashCode ^
country.hashCode ^
description.hashCode ^
orientation.hashCode;
description.hashCode;
@override
String toString() {
@@ -205,21 +192,10 @@ class ExifInfo {
state: $state,
country: $country,
description: $description,
orientation: $orientation
}""";
}
}
bool _isOrientationFlipped(String? orientation) {
final value = orientation != null ? int.tryParse(orientation) : null;
if (value == null) {
return false;
}
final isRotated90CW = value == 5 || value == 6 || value == 90;
final isRotated270CW = value == 7 || value == 8 || value == -90;
return isRotated90CW || isRotated270CW;
}
double? _exposureTimeToSeconds(String? s) {
if (s == null) {
return null;
+6 -207
View File
@@ -87,18 +87,13 @@ const ExifInfoSchema = CollectionSchema(
name: r'model',
type: IsarType.string,
),
r'orientation': PropertySchema(
id: 14,
name: r'orientation',
type: IsarType.string,
),
r'state': PropertySchema(
id: 15,
id: 14,
name: r'state',
type: IsarType.string,
),
r'timeZone': PropertySchema(
id: 16,
id: 15,
name: r'timeZone',
type: IsarType.string,
)
@@ -159,12 +154,6 @@ int _exifInfoEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.orientation;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.state;
if (value != null) {
@@ -200,9 +189,8 @@ void _exifInfoSerialize(
writer.writeString(offsets[11], object.make);
writer.writeFloat(offsets[12], object.mm);
writer.writeString(offsets[13], object.model);
writer.writeString(offsets[14], object.orientation);
writer.writeString(offsets[15], object.state);
writer.writeString(offsets[16], object.timeZone);
writer.writeString(offsets[14], object.state);
writer.writeString(offsets[15], object.timeZone);
}
ExifInfo _exifInfoDeserialize(
@@ -227,9 +215,8 @@ ExifInfo _exifInfoDeserialize(
make: reader.readStringOrNull(offsets[11]),
mm: reader.readFloatOrNull(offsets[12]),
model: reader.readStringOrNull(offsets[13]),
orientation: reader.readStringOrNull(offsets[14]),
state: reader.readStringOrNull(offsets[15]),
timeZone: reader.readStringOrNull(offsets[16]),
state: reader.readStringOrNull(offsets[14]),
timeZone: reader.readStringOrNull(offsets[15]),
);
return object;
}
@@ -273,8 +260,6 @@ P _exifInfoDeserializeProp<P>(
return (reader.readStringOrNull(offset)) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
case 16:
return (reader.readStringOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
@@ -1924,155 +1909,6 @@ extension ExifInfoQueryFilter
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'orientation',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'orientation',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'orientation',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'orientation',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'orientation',
value: '',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'orientation',
value: '',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@@ -2541,18 +2377,6 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientation() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientationDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'state', Sort.asc);
@@ -2760,18 +2584,6 @@ extension ExifInfoQuerySortThenBy
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientation() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientationDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'state', Sort.asc);
@@ -2889,13 +2701,6 @@ extension ExifInfoQueryWhereDistinct
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByOrientation(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive);
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -3004,12 +2809,6 @@ extension ExifInfoQueryProperty
});
}
QueryBuilder<ExifInfo, String?, QQueryOperations> orientationProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'orientation');
});
}
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'state');
+25 -1
View File
@@ -10,6 +10,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:share_handler/share_handler.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
@@ -197,6 +198,29 @@ class ImmichAppState extends ConsumerState<ImmichApp>
// needs to be delayed so that EasyLocalization is working
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
});
initPlatformState();
}
SharedMedia? media;
// Platform messages are asynchronous, so we initialize in an async method.
Future<void> initPlatformState() async {
final handler = ShareHandlerPlatform.instance;
media = await handler.getInitialSharedMedia();
handler.sharedMediaStream.listen((SharedMedia media) {
if (!mounted) return;
setState(() {
this.media = media;
print("Received shared media: $media");
});
});
if (!mounted) return;
setState(() {
// _platformVersion = platformVersion;
});
}
@override
@@ -214,7 +238,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
debugShowCheckedModeBanner: true,
debugShowCheckedModeBanner: false,
home: MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: false,
@@ -12,7 +12,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/pages/common/native_video_loader.dart';
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
@@ -56,14 +56,18 @@ class GalleryViewerPage extends HookConsumerWidget {
final settings = ref.watch(appSettingsServiceProvider);
final loadAsset = renderList.loadAsset;
final totalAssets = useState(renderList.totalAssets);
final shouldLoopVideo =
useState(settings.getSetting<bool>(AppSettingsEnum.loopVideo));
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
final isZoomed = useState(false);
final isPlayingVideo = useState(false);
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
final localPosition = useState<Offset?>(null);
final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value);
// Update is playing motion video
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
isPlayingVideo.value = state == VideoPlaybackState.playing;
});
final stackIndex = useState(-1);
final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset))
@@ -77,26 +81,28 @@ class GalleryViewerPage extends HookConsumerWidget {
: stackElements.elementAt(stackIndex.value);
final isMotionPhoto = asset.livePhotoVideoId != null;
// Update is playing motion video
if (isMotionPhoto) {
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
isPlayingVideo.value = state == VideoPlaybackState.playing;
});
}
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
ref.listen(currentAssetProvider, (_, __) {});
useEffect(
() {
// Delay state update to after the execution of build method
ref.read(currentAssetProvider.notifier).set(asset);
// Future.microtask(
// () => ref.read(currentAssetProvider.notifier).set(asset),
// );
Future.microtask(
() => ref.read(currentAssetProvider.notifier).set(asset),
);
return null;
},
[asset],
);
useEffect(
() {
shouldLoopVideo.value =
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
return null;
},
[],
);
Future<void> precacheNextImage(int index) async {
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
@@ -105,7 +111,6 @@ class GalleryViewerPage extends HookConsumerWidget {
try {
if (index < totalAssets.value && index >= 0) {
log.info('Precaching next image at index $index');
final asset = loadAsset(index);
await precacheImage(
ImmichImage.imageProvider(asset: asset),
@@ -185,9 +190,7 @@ class GalleryViewerPage extends HookConsumerWidget {
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
if (isMotionPhoto) {
isPlayingVideo.value = false;
}
isPlayingVideo.value = false;
return null;
},
[],
@@ -273,8 +276,6 @@ class GalleryViewerPage extends HookConsumerWidget {
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
},
// wantKeepAlive: true,
// gaplessPlayback: true,
loadingBuilder: (context, event, index) => ClipRect(
child: Stack(
fit: StackFit.expand,
@@ -302,19 +303,13 @@ class GalleryViewerPage extends HookConsumerWidget {
itemCount: totalAssets.value,
scrollDirection: Axis.horizontal,
onPageChanged: (value) async {
log.info('Page changed to $value');
final next = currentIndex.value < value ? value + 1 : value - 1;
ref.read(hapticFeedbackProvider.notifier).selectionClick();
log.info('Setting current index to $value');
currentIndex.value = value;
if (stackIndex.value != -1) {
stackIndex.value = -1;
}
if (isMotionPhoto) {
isPlayingVideo.value = false;
}
stackIndex.value = -1;
isPlayingVideo.value = false;
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
@@ -328,23 +323,17 @@ class GalleryViewerPage extends HookConsumerWidget {
final ImageProvider provider =
ImmichImage.imageProvider(asset: a);
ref.read(videoPlaybackValueProvider.notifier).reset();
if (a.isImage && !isPlayingVideo.value) {
ref.read(showControlsProvider.notifier).show = false;
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) {
log.info('Drag start');
localPosition.value = details.localPosition;
},
onDragUpdate: (_, details, __) {
log.info('Drag update');
handleSwipeUpDown(details);
},
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
onTapDown: (_, __, ___) {
ref.read(showControlsProvider.notifier).toggle();
},
onLongPressStart: (_, __, ___) {
if (isMotionPhoto) {
if (asset.livePhotoVideoId != null) {
isPlayingVideo.value = true;
}
},
@@ -364,26 +353,24 @@ class GalleryViewerPage extends HookConsumerWidget {
),
);
} else {
log.info('Loading asset ${a.id} (index $index) as video');
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
// heroAttributes: PhotoViewHeroAttributes(
// tag: isFromDto
// ? '${currentAsset.remoteId}-$heroOffset'
// : currentAsset.id + heroOffset,
// ),
heroAttributes: PhotoViewHeroAttributes(
tag: isFromDto
? '${currentAsset.remoteId}-$heroOffset'
: currentAsset.id + heroOffset,
),
filterQuality: FilterQuality.high,
initialScale: 1.0,
maxScale: 1.0,
minScale: 1.0,
basePosition: Alignment.center,
child: NativeVideoLoader(
key: ValueKey(a.id),
child: VideoViewerPage(
key: ValueKey(a),
asset: a,
isMotionVideo: isMotionPhoto,
isMotionVideo: a.livePhotoVideoId != null,
loopVideo: shouldLoopVideo.value,
placeholder: Image(
image: provider,
@@ -1,207 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:photo_manager/photo_manager.dart';
class NativeVideoLoader extends HookConsumerWidget {
final Asset asset;
final bool isMotionVideo;
final Widget placeholder;
final bool showControls;
final Duration hideControlsTimer;
final bool loopVideo;
const NativeVideoLoader({
super.key,
required this.asset,
required this.placeholder,
this.isMotionVideo = false,
this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5),
this.loopVideo = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final log = Logger('NativeVideoLoader');
log.info('Building NativeVideoLoader');
// fast path for aspect ratio
// final initAspectRatio = useMemoized(
// () {
// if (asset.exifInfo == null) {
// return null;
// }
// final width = asset.orientatedWidth?.toDouble();
// final height = asset.orientatedHeight?.toDouble();
// return width != null && height != null && width > 0 && height > 0
// ? width / height
// : null;
// },
// );
// final localEntity = useMemoized(
// () => asset.isLocal ? AssetEntity.fromId(asset.localId!) : null,
// );
Future<double> calculateAspectRatio(AssetEntity? localEntity) async {
log.info('Calculating aspect ratio');
late final double? orientatedWidth;
late final double? orientatedHeight;
if (asset.exifInfo != null) {
orientatedWidth = asset.orientatedWidth?.toDouble();
orientatedHeight = asset.orientatedHeight?.toDouble();
} else if (localEntity != null) {
orientatedWidth = localEntity.orientatedWidth.toDouble();
orientatedHeight = localEntity.orientatedHeight.toDouble();
} else {
final entity = await ref.read(assetServiceProvider).loadExif(asset);
orientatedWidth = entity.orientatedWidth?.toDouble();
orientatedHeight = entity.orientatedHeight?.toDouble();
}
log.info('Calculated aspect ratio');
if (orientatedWidth != null &&
orientatedHeight != null &&
orientatedWidth > 0 &&
orientatedHeight > 0) {
return orientatedWidth / orientatedHeight;
}
return 1.0;
}
// final aspectRatioFuture = useMemoized(() => calculateAspectRatio());
Future<VideoSource> createLocalSource(AssetEntity? localEntity) async {
log.info('Loading video from local storage');
if (localEntity == null) {
throw Exception('No entity found for the video');
}
final file = await localEntity.file;
if (file == null) {
throw Exception('No file found for the video');
}
final source = await VideoSource.init(
path: file.path,
type: VideoSourceType.file,
);
log.info('Loaded video from local storage');
return source;
}
Future<VideoSource> createRemoteSource() async {
log.info('Loading video from server');
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
log.info('Loaded video from server');
return source;
}
Future<VideoSource> createSource(AssetEntity? localEntity) {
if (localEntity != null && asset.livePhotoVideoId == null) {
return createLocalSource(localEntity);
}
return createRemoteSource();
}
// final createSourceFuture = useMemoized(() => createSource());
final combinedFuture = useMemoized(
() => Future.delayed(Duration(milliseconds: 1), () async {
if (!context.mounted) {
return null;
}
final entity =
asset.isLocal ? await AssetEntity.fromId(asset.localId!) : null;
return (createSource(entity), calculateAspectRatio(entity)).wait;
}),
);
final doCleanup = useState(false);
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
(_, value) {
if (value == VideoPlaybackState.initializing) {
log.info('Cleaning up video');
doCleanup.value = true;
}
});
// useEffect(() {
// Future.microtask(() {
// if (!context.mounted) {
// return Future.value(null);
// }
// return (createSourceFuture, aspectRatioFuture).wait;
// });
// return () {
// }
// }, [asset.id]);
final size = MediaQuery.sizeOf(context);
return SizedBox(
height: size.height,
width: size.width,
child: GestureDetector(
behavior: HitTestBehavior.deferToChild,
child: PopScope(
onPopInvokedWithResult: (didPop, _) =>
ref.read(videoPlaybackValueProvider.notifier).reset(),
child: SizedBox(
height: size.height,
width: size.width,
child: doCleanup.value
? placeholder
: FutureBuilder(
key: ValueKey(asset.id),
future: combinedFuture,
// initialData: initAspectRatio,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return placeholder;
}
return NativeVideoViewerPage(
videoSource: snapshot.data!.$1,
aspectRatio: snapshot.data!.$2,
duration: asset.duration,
isMotionVideo: isMotionVideo,
hideControlsTimer: hideControlsTimer,
loopVideo: loopVideo,
);
},
),
),
),
),
);
}
}
@@ -1,317 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class NativeVideoViewerPage extends HookConsumerWidget {
final VideoSource videoSource;
final double aspectRatio;
final Duration duration;
final bool isMotionVideo;
final bool showControls;
final Duration hideControlsTimer;
final bool loopVideo;
const NativeVideoViewerPage({
super.key,
required this.videoSource,
required this.aspectRatio,
required this.duration,
this.isMotionVideo = false,
this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5),
this.loopVideo = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useRef<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
final log = Logger('NativeVideoViewerPage');
log.info('Building NativeVideoViewerPage');
void checkIfBuffering([Timer? timer]) {
if (!context.mounted) {
return timer?.cancel();
}
log.info('Checking if buffering');
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value ||
videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
log.info('Marking video as buffering');
ref.read(videoPlaybackValueProvider.notifier).value =
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
}
}
// timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the volume changes, set the volume
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
(_, mute) {
final playerController = controller.value;
if (playerController == null) {
log.info('No controller to seek to');
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return;
}
try {
if (mute && playbackInfo.volume != 0.0) {
log.info('Muting video');
playerController.setVolume(0.0);
} else if (!mute && playbackInfo.volume != 0.7) {
log.info('Unmuting video');
playerController.setVolume(0.7);
}
} catch (error) {
log.severe('Error setting volume: $error');
}
});
// When the position changes, seek to the position
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
(_, position) {
final playerController = controller.value;
if (playerController == null) {
log.info('No controller to seek to');
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return;
}
// Find the position to seek to
final int seek = (duration * (position / 100.0)).inSeconds;
if (seek != playbackInfo.position) {
log.info('Seeking to position: $seek from ${playbackInfo.position}');
try {
playerController.seekTo(seek);
} catch (error) {
log.severe('Error seeking to position $position: $error');
}
}
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: seek);
});
// // When the custom video controls pause or play
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
(_, pause) {
try {
if (pause) {
log.info('Pausing video');
controller.value?.pause();
WakelockPlus.disable();
} else {
log.info('Playing video');
controller.value?.play();
WakelockPlus.enable();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
});
void onPlaybackReady() {
try {
log.info('onPlaybackReady: Playing video');
controller.value?.play();
controller.value?.setVolume(0.9);
WakelockPlus.enable();
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
log.info('No controller to update');
return;
}
final videoPlayback =
VideoPlaybackValue.fromNativeController(controller.value!);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
log.info('Video is playing; enabled wakelock');
} else {
// Sync with the controls pause
WakelockPlus.disable();
log.info('Video is not playing; disabled wakelock');
}
}
void onPlaybackPositionChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
log.info('No controller to update');
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return;
}
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
log.info('Updating playing video position');
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
log.info('Updating non-playing video position');
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void onPlaybackEnded() {
log.info('onPlaybackEnded: Video ended');
if (loopVideo) {
log.info('onPlaybackEnded: Looping video');
try {
controller.value?.play();
} catch (error) {
log.severe('Error looping video: $error');
}
} else {
WakelockPlus.disable();
}
}
void initController(NativeVideoPlayerController nc) {
if (controller.value != null) {
log.info('initController: Controller already initialized');
return;
}
log.info('initController: adding onPlaybackPositionChanged listener');
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
log.info('initController: adding onPlaybackStatusChanged listener');
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
log.info('initController: adding onPlaybackReady listener');
nc.onPlaybackReady.addListener(onPlaybackReady);
log.info('initController: adding onPlaybackEnded listener');
nc.onPlaybackEnded.addListener(onPlaybackEnded);
log.info('initController: loading video source');
nc.loadVideoSource(videoSource);
log.info('initController: setting controller');
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
useEffect(
() {
log.info('useEffect: resetting video player controls');
ref.read(videoPlayerControlsProvider.notifier).reset();
if (isMotionVideo) {
// ignore: prefer-extracting-callbacks
log.info('useEffect: disabling showing video player controls');
ref.read(showControlsProvider.notifier).show = false;
}
return () {
final playerController = controller.value;
if (playerController == null) {
log.info('No controller to dispose');
return;
}
try {
log.info('Stopping video');
playerController.stop();
log.info('Removing onPlaybackPositionChanged listener');
playerController.onPlaybackPositionChanged
.removeListener(onPlaybackPositionChanged);
log.info('Removing onPlaybackStatusChanged listener');
playerController.onPlaybackStatusChanged
.removeListener(onPlaybackStatusChanged);
log.info('Removing onPlaybackReady listener');
playerController.onPlaybackReady.removeListener(onPlaybackReady);
log.info('Removing onPlaybackEnded listener');
playerController.onPlaybackEnded.removeListener(onPlaybackEnded);
} catch (error) {
log.severe('Error during useEffect cleanup: $error');
}
log.info('Disposing controller');
controller.value = null;
log.info('Disabling Wakelock');
WakelockPlus.disable();
};
},
[videoSource],
);
return Stack(
children: [
Center(
child: AspectRatio(
aspectRatio: aspectRatio,
child: NativeVideoPlayerView(
onViewReady: initController,
),
),
),
if (showControls)
Center(
child: CustomVideoPlayerControls(
hideTimerDuration: hideControlsTimer,
),
),
// Visibility(
// visible: controller.value == null,
// child: const Positioned.fill(
// child: Center(
// child: DelayedLoadingIndicator(
// fadeInDuration: Duration(milliseconds: 500),
// ),
// ),
// ),
// ),
],
);
}
}
@@ -124,7 +124,8 @@ class VideoViewerPage extends HookConsumerWidget {
return PopScope(
onPopInvokedWithResult: (didPop, _) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).value =
VideoPlaybackValue.uninitialized();
},
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
+4 -18
View File
@@ -84,48 +84,34 @@ class AssetNotifier extends StateNotifier<bool> {
_deleteInProgress = true;
state = true;
try {
// Filter the assets based on the backed-up status
final assets = onlyBackedUp
? deleteAssets.where((e) => e.storage == AssetState.merged)
: deleteAssets;
if (assets.isEmpty) {
return false; // No assets to delete
}
// Proceed with local deletion of the filtered assets
final localDeleted = await _deleteLocalAssets(assets);
if (localDeleted.isNotEmpty) {
final localOnlyIds = assets
final localOnlyIds = deleteAssets
.where((e) => e.storage == AssetState.local)
.map((e) => e.id)
.toList();
// Update merged assets to remote-only
// Update merged assets to remote only
final mergedAssets =
assets.where((e) => e.storage == AssetState.merged).map((e) {
deleteAssets.where((e) => e.storage == AssetState.merged).map((e) {
e.localId = null;
return e;
}).toList();
// Update the local database
await _db.writeTxn(() async {
if (mergedAssets.isNotEmpty) {
await _db.assets
.putAll(mergedAssets); // Use the filtered merged assets
await _db.assets.putAll(mergedAssets);
}
await _db.exifInfos.deleteAll(localOnlyIds);
await _db.assets.deleteAll(localOnlyIds);
});
return true;
}
} finally {
_deleteInProgress = false;
state = false;
}
return false;
}
@@ -1,7 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
class VideoPlaybackControls {
const VideoPlaybackControls({
VideoPlaybackControls({
required this.position,
required this.mute,
required this.pause,
@@ -17,14 +17,15 @@ final videoPlayerControlsProvider =
return VideoPlayerControls(ref);
});
const videoPlayerControlsDefault = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
VideoPlayerControls(this.ref)
: super(
VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
),
);
final Ref ref;
@@ -35,17 +36,17 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void reset() {
state = videoPlayerControlsDefault;
state = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
}
double get position => state.position;
bool get mute => state.mute;
set position(double value) {
if (state.position == value) {
return;
}
state = VideoPlaybackControls(
position: value,
mute: state.mute,
@@ -54,10 +55,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
set mute(bool value) {
if (state.mute == value) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: value,
@@ -74,10 +71,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void pause() {
if (state.pause) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
@@ -86,10 +79,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void play() {
if (!state.pause) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
@@ -106,6 +95,12 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void restart() {
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
pause: true,
);
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
@@ -1,5 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:video_player/video_player.dart';
enum VideoPlaybackState {
@@ -23,44 +22,13 @@ class VideoPlaybackValue {
/// The volume of the video
final double volume;
const VideoPlaybackValue({
VideoPlaybackValue({
required this.position,
required this.duration,
required this.state,
required this.volume,
});
factory VideoPlaybackValue.fromNativeController(
NativeVideoPlayerController controller,
) {
final playbackInfo = controller.playbackInfo;
final videoInfo = controller.videoInfo;
if (playbackInfo == null || videoInfo == null) {
return videoPlaybackValueDefault;
}
late final VideoPlaybackState status;
switch (playbackInfo.status) {
case PlaybackStatus.playing:
status = VideoPlaybackState.playing;
break;
case PlaybackStatus.paused:
status = VideoPlaybackState.paused;
break;
case PlaybackStatus.stopped:
status = VideoPlaybackState.completed;
break;
}
return VideoPlaybackValue(
position: Duration(seconds: playbackInfo.position),
duration: Duration(seconds: videoInfo.duration),
state: status,
volume: playbackInfo.volume,
);
}
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
final video = controller?.value;
late VideoPlaybackState s;
@@ -84,35 +52,26 @@ class VideoPlaybackValue {
);
}
VideoPlaybackValue copyWith({
Duration? position,
Duration? duration,
VideoPlaybackState? state,
double? volume,
}) {
factory VideoPlaybackValue.uninitialized() {
return VideoPlaybackValue(
position: position ?? this.position,
duration: duration ?? this.duration,
state: state ?? this.state,
volume: volume ?? this.volume,
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
}
}
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
final videoPlaybackValueProvider =
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref);
});
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
VideoPlaybackValueState(this.ref)
: super(
VideoPlaybackValue.uninitialized(),
);
final Ref ref;
@@ -123,7 +82,6 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
}
set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue(
position: value,
duration: state.duration,
@@ -131,8 +89,4 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
volume: state.volume,
);
}
void reset() {
state = videoPlaybackValueDefault;
}
}
-18
View File
@@ -1,18 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter_hooks/flutter_hooks.dart';
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
void useInterval(Duration delay, VoidCallback callback) {
final savedCallback = useRef(callback);
savedCallback.value = callback;
useEffect(
() {
final timer = Timer.periodic(delay, (_) => savedCallback.value());
return timer.cancel;
},
[delay],
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart';
const int targetVersion = 7;
const int targetVersion = 6;
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);
@@ -203,30 +203,18 @@ class MultiselectGrid extends HookConsumerWidget {
void onDeleteLocal(bool onlyBackedUp) async {
processing.value = true;
try {
// Select only the local assets from the selection
final localIds = selection.value.where((a) => a.isLocal).toList();
// Delete only the backed-up assets if 'onlyBackedUp' is true
final isDeleted = await ref
.read(assetProvider.notifier)
.deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp);
if (isDeleted) {
// Show a toast with the correct number of deleted assets
final deletedCount = localIds
.where(
(e) => !onlyBackedUp || e.isRemote,
) // Only count backed-up assets
.length;
ImmichToast.show(
context: context,
msg: 'assets_removed_permanently_from_device'
.tr(args: ["$deletedCount"]),
.tr(args: ["${localIds.length}"]),
gravity: ToastGravity.BOTTOM,
);
// Reset the selection
selectionEnabledHook.value = false;
}
} finally {
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
class CustomVideoPlayerControls extends HookConsumerWidget {
final Duration hideTimerDuration;
@@ -28,9 +29,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
}
},
);
final showBuffering = useState(false);
final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final showBuffering = state == VideoPlaybackState.buffering;
ref.watch(videoPlaybackValueProvider).state;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
@@ -50,9 +52,16 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
showControlsAndStartHideTimer();
});
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
(_, state) {
// Show buffering
showBuffering.value = state == VideoPlaybackState.buffering;
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
final state = ref.read(videoPlaybackValueProvider).state;
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
@@ -69,7 +78,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
absorbing: !ref.watch(showControlsProvider),
child: Stack(
children: [
if (showBuffering)
if (showBuffering.value)
const Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
@@ -77,8 +86,12 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
)
else
GestureDetector(
onTap: () =>
ref.read(showControlsProvider.notifier).show = false,
onTap: () {
if (state != VideoPlaybackState.playing) {
togglePlay();
}
ref.read(showControlsProvider.notifier).show = false;
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
@@ -15,10 +15,9 @@ class FileInfo extends StatelessWidget {
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
String resolution =
asset.orientatedHeight != null && asset.orientatedWidth != null
? "${asset.orientatedHeight} x ${asset.orientatedWidth} "
: "";
String resolution = asset.width != null && asset.height != null
? "${asset.height} x ${asset.width} "
: "";
String fileSize = asset.exifInfo?.fileSize != null
? formatBytes(asset.exifInfo!.fileSize!)
: "";
+5 -4
View File
@@ -2,9 +2,9 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/native_video_loader.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
@@ -68,9 +68,10 @@ class MemoryCard extends StatelessWidget {
} else {
return Hero(
tag: 'memory-${asset.id}',
child: NativeVideoLoader(
key: ValueKey(asset.id),
child: VideoViewerPage(
key: ValueKey(asset),
asset: asset,
showDownloadingIndicator: false,
placeholder: SizedBox.expand(
child: ImmichImage(
asset,
+10 -1
View File
@@ -23,6 +23,7 @@ class SystemConfigFFmpegDto {
required this.crf,
required this.gopSize,
required this.maxBitrate,
required this.npl,
required this.preferredHwDevice,
required this.preset,
required this.refs,
@@ -61,6 +62,9 @@ class SystemConfigFFmpegDto {
String maxBitrate;
/// Minimum value: 0
int npl;
String preferredHwDevice;
String preset;
@@ -98,6 +102,7 @@ class SystemConfigFFmpegDto {
other.crf == crf &&
other.gopSize == gopSize &&
other.maxBitrate == maxBitrate &&
other.npl == npl &&
other.preferredHwDevice == preferredHwDevice &&
other.preset == preset &&
other.refs == refs &&
@@ -123,6 +128,7 @@ class SystemConfigFFmpegDto {
(crf.hashCode) +
(gopSize.hashCode) +
(maxBitrate.hashCode) +
(npl.hashCode) +
(preferredHwDevice.hashCode) +
(preset.hashCode) +
(refs.hashCode) +
@@ -136,7 +142,7 @@ class SystemConfigFFmpegDto {
(twoPass.hashCode);
@override
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, npl=$npl, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -150,6 +156,7 @@ class SystemConfigFFmpegDto {
json[r'crf'] = this.crf;
json[r'gopSize'] = this.gopSize;
json[r'maxBitrate'] = this.maxBitrate;
json[r'npl'] = this.npl;
json[r'preferredHwDevice'] = this.preferredHwDevice;
json[r'preset'] = this.preset;
json[r'refs'] = this.refs;
@@ -183,6 +190,7 @@ class SystemConfigFFmpegDto {
crf: mapValueOfType<int>(json, r'crf')!,
gopSize: mapValueOfType<int>(json, r'gopSize')!,
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
npl: mapValueOfType<int>(json, r'npl')!,
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
preset: mapValueOfType<String>(json, r'preset')!,
refs: mapValueOfType<int>(json, r'refs')!,
@@ -251,6 +259,7 @@ class SystemConfigFFmpegDto {
'crf',
'gopSize',
'maxBitrate',
'npl',
'preferredHwDevice',
'preset',
'refs',
+232 -238
View File
File diff suppressed because it is too large Load Diff
+4 -6
View File
@@ -6,7 +6,7 @@ version: 1.119.1+164
environment:
sdk: '>=3.3.0 <4.0.0'
flutter: 3.24.4
flutter: 3.24.3
dependencies:
flutter:
@@ -42,7 +42,7 @@ dependencies:
path_provider: ^2.1.2
collection: ^1.18.0
http_parser: ^4.0.2
flutter_web_auth: ^0.6.0
flutter_web_auth: ^0.5.0
easy_image_viewer: ^1.4.0
isar: ^3.1.0+1
isar_flutter_libs: ^3.1.0+1
@@ -57,10 +57,8 @@ dependencies:
async: ^2.11.0
dynamic_color: ^1.7.0 #package to apply system theme
background_downloader: ^8.5.5
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: feat/headers
share_handler: ^0.0.21
share_handler_ios:
#image editing packages
crop_image: ^1.0.13
+5
View File
@@ -11621,6 +11621,10 @@
"maxBitrate": {
"type": "string"
},
"npl": {
"minimum": 0,
"type": "integer"
},
"preferredHwDevice": {
"type": "string"
},
@@ -11669,6 +11673,7 @@
"crf",
"gopSize",
"maxBitrate",
"npl",
"preferredHwDevice",
"preset",
"refs",
+14 -15
View File
@@ -1,18 +1,18 @@
{
"name": "@immich/sdk",
"version": "1.119.1",
"version": "1.111.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.119.1",
"version": "1.111.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.1",
"@types/node": "^20.14.12",
"typescript": "^5.3.3"
}
},
@@ -22,19 +22,19 @@
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
},
"node_modules/@types/node": {
"version": "22.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
"version": "20.14.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz",
"integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.8"
"undici-types": "~5.26.4"
}
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -46,11 +46,10 @@
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true,
"license": "MIT"
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
}
}
}
@@ -1104,6 +1104,7 @@ export type SystemConfigFFmpegDto = {
crf: number;
gopSize: number;
maxBitrate: string;
npl: number;
preferredHwDevice: string;
preset: string;
refs: number;
+19 -15
View File
@@ -6,12 +6,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ClsModule } from 'nestjs-cls';
import { OpenTelemetryModule } from 'nestjs-otel';
import { commands } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { entities } from 'src/entities';
import { ImmichWorker } from 'src/enum';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import { AuthGuard } from 'src/middleware/auth.guard';
@@ -58,25 +56,23 @@ const imports = [
TypeOrmModule.forFeature(entities),
];
class BaseModule implements OnModuleInit, OnModuleDestroy {
abstract class BaseModule implements OnModuleInit, OnModuleDestroy {
private get worker() {
return this.getWorker();
}
constructor(
@Inject(IWorker) private worker: ImmichWorker,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ITelemetryRepository) private telemetryRepository: ITelemetryRepository,
) {
logger.setAppName(this.worker);
}
abstract getWorker(): ImmichWorker;
async onModuleInit() {
this.telemetryRepository.setup({ repositories: repositories.map(({ useClass }) => useClass) });
this.jobRepository.setup({ services });
if (this.worker === ImmichWorker.MICROSERVICES) {
this.jobRepository.startWorkers();
}
this.eventRepository.setup({ services });
await this.eventRepository.emit('app.bootstrap', this.worker);
}
@@ -90,15 +86,23 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
@Module({
imports: [...imports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.API }],
providers: [...common, ...middleware],
})
export class ApiModule extends BaseModule {}
export class ApiModule extends BaseModule {
getWorker() {
return ImmichWorker.API;
}
}
@Module({
imports: [...imports],
providers: [...common, { provide: IWorker, useValue: ImmichWorker.MICROSERVICES }, SchedulerRegistry],
providers: [...common, SchedulerRegistry],
})
export class MicroservicesModule extends BaseModule {}
export class MicroservicesModule extends BaseModule {
getWorker() {
return ImmichWorker.MICROSERVICES;
}
}
@Module({
imports: [...imports],
+2 -2
View File
@@ -3,7 +3,7 @@ import { ImmichWorker } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
const main = async () => {
const { host, workers, port } = new ConfigRepository().getEnv();
const { workers, port } = new ConfigRepository().getEnv();
if (!workers.includes(ImmichWorker.API)) {
process.exit();
}
@@ -11,7 +11,7 @@ const main = async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`http://${host || 'localhost'}:${port}/api/server/ping`, {
const response = await fetch(`http://localhost:${port}/api/server/ping`, {
signal: controller.signal,
});
+2
View File
@@ -36,6 +36,7 @@ export interface SystemConfig {
bframes: number;
refs: number;
gopSize: number;
npl: number;
temporalAQ: boolean;
cqMode: CQMode;
twoPass: boolean;
@@ -177,6 +178,7 @@ export const defaults = Object.freeze<SystemConfig>({
bframes: -1,
refs: 0,
gopSize: 0,
npl: 0,
temporalAQ: false,
cqMode: CQMode.AUTO,
twoPass: false,
-2
View File
@@ -13,8 +13,6 @@ export const ADDED_IN_PREFIX = 'This property was added in ';
export const SALT_ROUNDS = 10;
export const IWorker = 'IWorker';
const { version } = JSON.parse(readFileSync('./package.json', 'utf8'));
export const serverVersion = new SemVer(version);
-7
View File
@@ -4,7 +4,6 @@ import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { MetadataKey } from 'src/enum';
import { EmitEvent } from 'src/interfaces/event.interface';
import { JobName, QueueName } from 'src/interfaces/job.interface';
import { setUnion } from 'src/utils/set';
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
@@ -123,12 +122,6 @@ export type EventConfig = {
};
export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config);
export type JobConfig = {
name: JobName;
queue: QueueName;
};
export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JOB_CONFIG, config);
type LifecycleRelease = 'NEXT_RELEASE' | string;
type LifecycleMetadata = {
addedAt?: LifecycleRelease;
+6
View File
@@ -134,6 +134,12 @@ export class SystemConfigFFmpegDto {
@ApiProperty({ type: 'integer' })
gopSize!: number;
@IsInt()
@Min(0)
@Type(() => Number)
@ApiProperty({ type: 'integer' })
npl!: number;
@ValidateBoolean()
temporalAQ!: boolean;
-1
View File
@@ -335,7 +335,6 @@ export enum MetadataKey {
SHARED_ROUTE = 'shared_route',
API_KEY_SECURITY = 'api_key',
EVENT_CONFIG = 'event_config',
JOB_CONFIG = 'job_config',
TELEMETRY_ENABLED = 'telemetry_enabled',
}
-3
View File
@@ -3,7 +3,6 @@ import { SystemConfig } from 'src/config';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ImmichWorker } from 'src/enum';
import { JobItem, QueueName } from 'src/interfaces/job.interface';
export const IEventRepository = 'IEventRepository';
@@ -39,8 +38,6 @@ type EventMap = {
'assets.delete': [{ assetIds: string[]; userId: string }];
'assets.restore': [{ assetIds: string[]; userId: string }];
'job.start': [QueueName, JobItem];
// session events
'session.delete': [{ sessionId: string }];
+7 -9
View File
@@ -1,4 +1,3 @@
import { ClassConstructor } from 'class-transformer';
import { EmailImageAttachment } from 'src/interfaces/notification.interface';
export enum QueueName {
@@ -239,8 +238,8 @@ export type JobItem =
// Migration
| { name: JobName.QUEUE_MIGRATION; data?: IBaseJob }
| { name: JobName.MIGRATE_ASSET; data: IEntityJob }
| { name: JobName.MIGRATE_PERSON; data: IEntityJob }
| { name: JobName.MIGRATE_ASSET; data?: IEntityJob }
| { name: JobName.MIGRATE_PERSON; data?: IEntityJob }
// Metadata Extraction
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
@@ -287,7 +286,7 @@ export type JobItem =
| { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob }
| { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob }
| { name: JobName.LIBRARY_SYNC_ASSET; data: IEntityJob }
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
@@ -306,15 +305,14 @@ export enum JobStatus {
FAILED = 'failed',
SKIPPED = 'skipped',
}
export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] };
export type JobOf<T extends JobName> = Jobs[T];
export type JobHandler<T = any> = (data: T) => Promise<JobStatus>;
export type JobItemHandler = (item: JobItem) => Promise<void>;
export const IJobRepository = 'IJobRepository';
export interface IJobRepository {
setup(options: { services: ClassConstructor<unknown>[] }): void;
startWorkers(): void;
run(job: JobItem): Promise<JobStatus>;
addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void;
addCronJob(name: string, expression: string, onTick: () => void, start?: boolean): void;
updateCronJob(name: string, expression?: string, start?: boolean): void;
setConcurrency(queueName: QueueName, concurrency: number): void;
-1
View File
@@ -59,7 +59,6 @@ export interface VideoStreamInfo {
frameCount: number;
isHDR: boolean;
bitrate: number;
pixelFormat: string;
}
export interface AudioStreamInfo {
@@ -1,12 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveNplFromSystemConfig1730227312171 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
update system_metadata
set value = value #- '{ffmpeg,npl}'
where key = 'system-config' and value->'ffmpeg'->'npl' is not null`);
}
public async down(): Promise<void> {}
}
+94 -96
View File
@@ -1,122 +1,124 @@
import { getQueueToken } from '@nestjs/bullmq';
import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import { ModuleRef } from '@nestjs/core';
import { SchedulerRegistry } from '@nestjs/schedule';
import { JobsOptions, Queue, Worker } from 'bullmq';
import { ClassConstructor } from 'class-transformer';
import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
import { CronJob, CronTime } from 'cron';
import { setTimeout } from 'node:timers/promises';
import { JobConfig } from 'src/decorators';
import { MetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
IEntityJob,
IJobRepository,
JobCounts,
JobItem,
JobName,
JobOf,
JobStatus,
QueueCleanType,
QueueName,
QueueStatus,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
type JobMapItem = {
jobName: JobName;
queueName: QueueName;
handler: (job: JobOf<any>) => Promise<JobStatus>;
label: string;
export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
// misc
[JobName.ASSET_DELETION]: QueueName.BACKGROUND_TASK,
[JobName.ASSET_DELETION_CHECK]: QueueName.BACKGROUND_TASK,
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
[JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
[JobName.CLEAN_OLD_AUDIT_LOGS]: QueueName.BACKGROUND_TASK,
[JobName.CLEAN_OLD_SESSION_TOKENS]: QueueName.BACKGROUND_TASK,
[JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK,
[JobName.USER_SYNC_USAGE]: QueueName.BACKGROUND_TASK,
// backups
[JobName.BACKUP_DATABASE]: QueueName.BACKUP_DATABASE,
// conversion
[JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,
[JobName.VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,
// thumbnails
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// tags
[JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK,
// metadata
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
[JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
[JobName.LINK_LIVE_PHOTOS]: QueueName.METADATA_EXTRACTION,
// storage template
[JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION,
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: QueueName.STORAGE_TEMPLATE_MIGRATION,
// migration
[JobName.QUEUE_MIGRATION]: QueueName.MIGRATION,
[JobName.MIGRATE_ASSET]: QueueName.MIGRATION,
[JobName.MIGRATE_PERSON]: QueueName.MIGRATION,
// facial recognition
[JobName.QUEUE_FACE_DETECTION]: QueueName.FACE_DETECTION,
[JobName.FACE_DETECTION]: QueueName.FACE_DETECTION,
[JobName.QUEUE_FACIAL_RECOGNITION]: QueueName.FACIAL_RECOGNITION,
[JobName.FACIAL_RECOGNITION]: QueueName.FACIAL_RECOGNITION,
// smart search
[JobName.QUEUE_SMART_SEARCH]: QueueName.SMART_SEARCH,
[JobName.SMART_SEARCH]: QueueName.SMART_SEARCH,
// duplicate detection
[JobName.QUEUE_DUPLICATE_DETECTION]: QueueName.DUPLICATE_DETECTION,
[JobName.DUPLICATE_DETECTION]: QueueName.DUPLICATE_DETECTION,
// XMP sidecars
[JobName.QUEUE_SIDECAR]: QueueName.SIDECAR,
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
[JobName.SIDECAR_SYNC]: QueueName.SIDECAR,
[JobName.SIDECAR_WRITE]: QueueName.SIDECAR,
// Library management
[JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY,
[JobName.LIBRARY_DELETE]: QueueName.LIBRARY,
[JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
// Notification
[JobName.SEND_EMAIL]: QueueName.NOTIFICATION,
[JobName.NOTIFY_ALBUM_INVITE]: QueueName.NOTIFICATION,
[JobName.NOTIFY_ALBUM_UPDATE]: QueueName.NOTIFICATION,
[JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION,
// Version check
[JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK,
// Trash
[JobName.QUEUE_TRASH_EMPTY]: QueueName.BACKGROUND_TASK,
};
@Injectable()
export class JobRepository implements IJobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private handlers: Partial<Record<JobName, JobMapItem>> = {};
constructor(
private moduleRef: ModuleRef,
private schedulerRegistry: SchedulerRegistry,
private moduleReference: ModuleRef,
private schedulerReqistry: SchedulerRegistry,
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(JobRepository.name);
}
setup({ services }: { services: ClassConstructor<unknown>[] }) {
const reflector = this.moduleRef.get(Reflector, { strict: false });
// discovery
for (const Service of services) {
const instance = this.moduleRef.get<any>(Service);
for (const methodName of getMethodNames(instance)) {
const handler = instance[methodName];
const config = reflector.get<JobConfig>(MetadataKey.JOB_CONFIG, handler);
if (!config) {
continue;
}
const { name: jobName, queue: queueName } = config;
const label = `${Service.name}.${handler.name}`;
// one handler per job
if (this.handlers[jobName]) {
const jobKey = getKeyByValue(JobName, jobName);
const errorMessage = `Failed to add job handler for ${label}`;
this.logger.error(
`${errorMessage}. JobName.${jobKey} is already handled by ${this.handlers[jobName].label}.`,
);
throw new ImmichStartupError(errorMessage);
}
this.handlers[jobName] = {
label,
jobName,
queueName,
handler: handler.bind(instance),
};
this.logger.verbose(`Added job handler: ${jobName} => ${label}`);
}
}
// no missing handlers
for (const [jobKey, jobName] of Object.entries(JobName)) {
const item = this.handlers[jobName];
if (!item) {
const errorMessage = `Failed to find job handler for Job.${jobKey} ("${jobName}")`;
this.logger.error(
`${errorMessage}. Make sure to add the @OnJob({ name: JobName.${jobKey}, queue: QueueName.XYZ }) decorator for the new job.`,
);
throw new ImmichStartupError(errorMessage);
}
}
}
startWorkers() {
addHandler(queueName: QueueName, concurrency: number, handler: (item: JobItem) => Promise<void>) {
const { bull } = this.configRepository.getEnv();
for (const queueName of Object.values(QueueName)) {
this.logger.debug(`Starting worker for queue: ${queueName}`);
this.workers[queueName] = new Worker(
queueName,
(job) => this.eventRepository.emit('job.start', queueName, job as JobItem),
{ ...bull.config, concurrency: 1 },
);
}
}
async run({ name, data }: JobItem) {
const item = this.handlers[name as JobName];
if (!item) {
this.logger.warn(`Skipping unknown job: "${name}"`);
return JobStatus.SKIPPED;
}
return item.handler(data);
const workerHandler: Processor = async (job: Job) => handler(job as JobItem);
const workerOptions: WorkerOptions = { ...bull.config, concurrency };
this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions);
}
addCronJob(name: string, expression: string, onTick: () => void, start = true): void {
@@ -139,11 +141,11 @@ export class JobRepository implements IJobRepository {
true,
);
this.schedulerRegistry.addCronJob(name, job);
this.schedulerReqistry.addCronJob(name, job);
}
updateCronJob(name: string, expression?: string, start?: boolean): void {
const job = this.schedulerRegistry.getCronJob(name);
const job = this.schedulerReqistry.getCronJob(name);
if (expression) {
job.setTime(new CronTime(expression));
}
@@ -202,10 +204,6 @@ export class JobRepository implements IJobRepository {
) as unknown as Promise<JobCounts>;
}
private getQueueName(name: JobName) {
return (this.handlers[name] as JobMapItem).queueName;
}
async queueAll(items: JobItem[]): Promise<void> {
if (items.length === 0) {
return;
@@ -214,7 +212,7 @@ export class JobRepository implements IJobRepository {
const promises = [];
const itemsByQueue = {} as Record<string, (JobItem & { data: any; options: JobsOptions | undefined })[]>;
for (const item of items) {
const queueName = this.getQueueName(item.name);
const queueName = JOBS_TO_QUEUE[item.name];
const job = {
name: item.name,
data: item.data || {},
@@ -275,11 +273,11 @@ export class JobRepository implements IJobRepository {
}
private getQueue(queue: QueueName): Queue {
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
return this.moduleReference.get<Queue>(getQueueToken(queue), { strict: false });
}
public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> {
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobId);
const existingJob = await this.getQueue(JOBS_TO_QUEUE[name]).getJob(jobId);
if (!existingJob) {
return;
}
@@ -126,7 +126,6 @@ export class MediaRepository implements IMediaRepository {
rotation: this.parseInt(stream.rotation),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: this.parseInt(stream.bit_rate),
pixelFormat: stream.pix_fmt || 'yuv420p',
})),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')
+2 -6
View File
@@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import _ from 'lodash';
import { DateTime, Duration } from 'luxon';
import { OnJob } from 'src/decorators';
import {
AssetResponseDto,
MemoryLaneResponseDto,
@@ -22,13 +21,12 @@ import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, Permission } from 'src/enum';
import {
IAssetDeleteJob,
ISidecarWriteJob,
JOBS_ASSET_PAGINATION_SIZE,
JobItem,
JobName,
JobOf,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
@@ -188,7 +186,6 @@ export class AssetService extends BaseService {
await this.assetRepository.updateAll(ids, options);
}
@OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK })
async handleAssetDeletionCheck(): Promise<JobStatus> {
const config = await this.getConfig({ withCache: false });
const trashedDays = config.trash.enabled ? config.trash.days : 0;
@@ -214,8 +211,7 @@ export class AssetService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.ASSET_DELETION, queue: QueueName.BACKGROUND_TASK })
async handleAssetDeletion(job: JobOf<JobName.ASSET_DELETION>): Promise<JobStatus> {
async handleAssetDeletion(job: IAssetDeleteJob): Promise<JobStatus> {
const { id, deleteOnDisk } = job;
const asset = await this.assetRepository.getById(id, {
+1 -3
View File
@@ -3,7 +3,6 @@ import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import {
AuditDeletesDto,
AuditDeletesResponseDto,
@@ -22,14 +21,13 @@ import {
StorageFolder,
UserPathType,
} from 'src/enum';
import { JobName, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination';
@Injectable()
export class AuditService extends BaseService {
@OnJob({ name: JobName.CLEAN_OLD_AUDIT_LOGS, queue: QueueName.BACKGROUND_TASK })
async handleCleanup(): Promise<JobStatus> {
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return JobStatus.SUCCESS;
+2 -3
View File
@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common';
import { default as path } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { ImmichWorker, StorageFolder } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { JobName, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { handlePromiseError } from 'src/utils/misc';
import { validateCronExpression } from 'src/validation';
@@ -75,7 +75,6 @@ export class BackupService extends BaseService {
this.logger.debug(`Database Backup Cleanup Finished, deleted ${toDelete.length} backups`);
}
@OnJob({ name: JobName.BACKUP_DATABASE, queue: QueueName.BACKUP_DATABASE })
async handleBackupDatabase(): Promise<JobStatus> {
this.logger.debug(`Database Backup Started`);
+2 -4
View File
@@ -1,10 +1,9 @@
import { BadRequestException, Inject, Optional } from '@nestjs/common';
import { BadRequestException, Inject } from '@nestjs/common';
import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
import { IWorker, SALT_ROUNDS } from 'src/constants';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { UserEntity } from 'src/entities/user.entity';
import { ImmichWorker } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
@@ -50,7 +49,6 @@ export class BaseService {
protected storageCore: StorageCore;
constructor(
@Inject(IWorker) @Optional() protected worker: ImmichWorker | undefined,
@Inject(ILoggerRepository) protected logger: ILoggerRepository,
@Inject(IAccessRepository) protected accessRepository: IAccessRepository,
@Inject(IActivityRepository) protected activityRepository: IActivityRepository,
+3 -6
View File
@@ -1,11 +1,10 @@
import { Injectable } from '@nestjs/common';
import { OnJob } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { WithoutProperty } from 'src/interfaces/asset.interface';
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { AssetDuplicateResult } from 'src/interfaces/search.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
@@ -20,8 +19,7 @@ export class DuplicateService extends BaseService {
return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true })));
}
@OnJob({ name: JobName.QUEUE_DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION })
async handleQueueSearchDuplicates({ force }: JobOf<JobName.QUEUE_DUPLICATE_DETECTION>): Promise<JobStatus> {
async handleQueueSearchDuplicates({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false });
if (!isDuplicateDetectionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
@@ -42,8 +40,7 @@ export class DuplicateService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION })
async handleSearchDuplicates({ id }: JobOf<JobName.DUPLICATE_DETECTION>): Promise<JobStatus> {
async handleSearchDuplicates({ id }: IEntityJob): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isDuplicateDetectionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
+2
View File
@@ -17,6 +17,7 @@ import { MapService } from 'src/services/map.service';
import { MediaService } from 'src/services/media.service';
import { MemoryService } from 'src/services/memory.service';
import { MetadataService } from 'src/services/metadata.service';
import { MicroservicesService } from 'src/services/microservices.service';
import { NotificationService } from 'src/services/notification.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
@@ -59,6 +60,7 @@ export const services = [
MediaService,
MemoryService,
MetadataService,
MicroservicesService,
NotificationService,
PartnerService,
PersonService,
+31 -28
View File
@@ -2,25 +2,37 @@ import { BadRequestException } from '@nestjs/common';
import { defaults } from 'src/config';
import { ImmichWorker } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import {
IJobRepository,
JobCommand,
JobHandler,
JobItem,
JobName,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { JobService } from 'src/services/job.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
import { Mocked, vitest } from 'vitest';
const makeMockHandlers = (status: JobStatus) => {
const mock = vitest.fn().mockResolvedValue(status);
return Object.fromEntries(Object.values(JobName).map((jobName) => [jobName, mock])) as unknown as Record<
JobName,
JobHandler
>;
};
describe(JobService.name, () => {
let sut: JobService;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let telemetryMock: Mocked<ITelemetryRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => {
({ sut, assetMock, jobMock, loggerMock, telemetryMock } = newTestService(JobService, {
worker: ImmichWorker.MICROSERVICES,
}));
({ sut, assetMock, jobMock, systemMock } = newTestService(JobService));
});
it('should work', () => {
@@ -29,6 +41,7 @@ describe(JobService.name, () => {
describe('onConfigUpdate', () => {
it('should update concurrency', () => {
sut.onBootstrap(ImmichWorker.MICROSERVICES);
sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults });
expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15);
@@ -212,19 +225,11 @@ describe(JobService.name, () => {
});
});
describe('onJobStart', () => {
it('should process a successful job', async () => {
jobMock.run.mockResolvedValue(JobStatus.SUCCESS);
await sut.onJobStart(QueueName.BACKGROUND_TASK, {
name: JobName.DELETE_FILES,
data: { files: ['path/to/file'] },
});
expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1);
expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1);
expect(telemetryMock.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1);
expect(loggerMock.error).not.toHaveBeenCalled();
describe('init', () => {
it('should register a handler for each queue', async () => {
await sut.init(makeMockHandlers(JobStatus.SUCCESS));
expect(systemMock.get).toHaveBeenCalled();
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
});
const tests: Array<{ item: JobItem; jobs: JobName[] }> = [
@@ -292,9 +297,8 @@ describe(JobService.name, () => {
}
}
jobMock.run.mockResolvedValue(JobStatus.SUCCESS);
await sut.onJobStart(QueueName.BACKGROUND_TASK, item);
await sut.init(makeMockHandlers(JobStatus.SUCCESS));
await jobMock.addHandler.mock.calls[0][2](item);
if (jobs.length > 1) {
expect(jobMock.queueAll).toHaveBeenCalledWith(
@@ -309,9 +313,8 @@ describe(JobService.name, () => {
});
it(`should not queue any jobs when ${item.name} fails`, async () => {
jobMock.run.mockResolvedValue(JobStatus.FAILED);
await sut.onJobStart(QueueName.BACKGROUND_TASK, item);
await sut.init(makeMockHandlers(JobStatus.FAILED));
await jobMock.addHandler.mock.calls[0][2](item);
expect(jobMock.queueAll).not.toHaveBeenCalled();
});
+45 -17
View File
@@ -4,10 +4,11 @@ import { OnEvent } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType, ImmichWorker, ManualJobName } from 'src/enum';
import { ArgOf, ArgsOf } from 'src/interfaces/event.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import {
ConcurrentQueueName,
JobCommand,
JobHandler,
JobItem,
JobName,
JobStatus,
@@ -38,9 +39,16 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
@Injectable()
export class JobService extends BaseService {
private isMicroservices = false;
@OnEvent({ name: 'app.bootstrap' })
onBootstrap(app: ArgOf<'app.bootstrap'>) {
this.isMicroservices = app === ImmichWorker.MICROSERVICES;
}
@OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig: config }: ArgOf<'config.update'>) {
if (this.worker !== ImmichWorker.MICROSERVICES) {
onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) {
if (!oldConfig || !this.isMicroservices) {
return;
}
@@ -169,21 +177,41 @@ export class JobService extends BaseService {
}
}
@OnEvent({ name: 'job.start' })
async onJobStart(...[queueName, job]: ArgsOf<'job.start'>) {
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
try {
const status = await this.jobRepository.run(job);
const jobMetric = `immich.jobs.${job.name.replaceAll('-', '_')}.${status}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) {
await this.onDone(job);
async init(jobHandlers: Record<JobName, JobHandler>) {
const config = await this.getConfig({ withCache: false });
for (const queueName of Object.values(QueueName)) {
let concurrency = 1;
if (this.isConcurrentQueue(queueName)) {
concurrency = config.job[queueName].concurrency;
}
} catch (error: Error | any) {
this.logger.error(`Unable to run job handler (${queueName}/${job.name}): ${error}`, error?.stack, job.data);
} finally {
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
this.logger.debug(`Registering ${queueName} with a concurrency of ${concurrency}`);
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
const { name, data } = item;
const handler = jobHandlers[name];
if (!handler) {
this.logger.warn(`Skipping unknown job: "${name}"`);
return;
}
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
try {
const status = await handler(data);
const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) {
await this.onDone(item);
}
} catch (error: Error | any) {
this.logger.error(`Unable to run job handler (${queueName}/${name}): ${error}`, error?.stack, data);
} finally {
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
}
});
}
}
+14 -14
View File
@@ -3,7 +3,7 @@ import { R_OK } from 'node:constants';
import path, { basename, isAbsolute, parse } from 'node:path';
import picomatch from 'picomatch';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import {
CreateLibraryDto,
LibraryResponseDto,
@@ -19,7 +19,14 @@ import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType, ImmichWorker } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { JobName, JobOf, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
import {
IEntityJob,
ILibraryAssetJob,
ILibraryFileJob,
JobName,
JOBS_LIBRARY_PAGINATION_SIZE,
JobStatus,
} from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { mimeTypes } from 'src/utils/mime-types';
import { handlePromiseError } from 'src/utils/misc';
@@ -216,7 +223,6 @@ export class LibraryService extends BaseService {
return libraries.map((library) => mapLibrary(library));
}
@OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY })
async handleQueueCleanup(): Promise<JobStatus> {
this.logger.debug('Cleaning up any pending library deletions');
const pendingDeletion = await this.libraryRepository.getAllDeleted();
@@ -334,8 +340,7 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } });
}
@OnJob({ name: JobName.LIBRARY_DELETE, queue: QueueName.LIBRARY })
async handleDeleteLibrary(job: JobOf<JobName.LIBRARY_DELETE>): Promise<JobStatus> {
async handleDeleteLibrary(job: IEntityJob): Promise<JobStatus> {
const libraryId = job.id;
const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) =>
@@ -369,8 +374,7 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.LIBRARY_SYNC_FILE, queue: QueueName.LIBRARY })
async handleSyncFile(job: JobOf<JobName.LIBRARY_SYNC_FILE>): Promise<JobStatus> {
async handleSyncFile(job: ILibraryFileJob): Promise<JobStatus> {
// Only needs to handle new assets
const assetPath = path.normalize(job.assetPath);
@@ -454,7 +458,6 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } });
}
@OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, queue: QueueName.LIBRARY })
async handleQueueSyncAll(): Promise<JobStatus> {
this.logger.debug(`Refreshing all external libraries`);
@@ -480,8 +483,7 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.LIBRARY_SYNC_ASSET, queue: QueueName.LIBRARY })
async handleSyncAsset(job: JobOf<JobName.LIBRARY_SYNC_ASSET>): Promise<JobStatus> {
async handleSyncAsset(job: ILibraryAssetJob): Promise<JobStatus> {
const asset = await this.assetRepository.getById(job.id);
if (!asset) {
return JobStatus.SKIPPED;
@@ -536,8 +538,7 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_FILES, queue: QueueName.LIBRARY })
async handleQueueSyncFiles(job: JobOf<JobName.LIBRARY_QUEUE_SYNC_FILES>): Promise<JobStatus> {
async handleQueueSyncFiles(job: IEntityJob): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.id);
if (!library) {
this.logger.debug(`Library ${job.id} not found, skipping refresh`);
@@ -588,8 +589,7 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, queue: QueueName.LIBRARY })
async handleQueueSyncAssets(job: JobOf<JobName.LIBRARY_QUEUE_SYNC_ASSETS>): Promise<JobStatus> {
async handleQueueSyncAssets(job: IEntityJob): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.id);
if (!library) {
return JobStatus.SKIPPED;
+25 -85
View File
@@ -9,6 +9,7 @@ import {
AudioCodec,
Colorspace,
ImageFormat,
ToneMapping,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
@@ -409,7 +410,7 @@ describe(MediaService.name, () => {
'-frames:v 1',
'-update 1',
'-v verbose',
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc`,
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=bt601:out_range=pc,format=yuv420p`,
],
twoPass: false,
}),
@@ -444,7 +445,7 @@ describe(MediaService.name, () => {
'-frames:v 1',
'-update 1',
'-v verbose',
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`,
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=601:m=470bg:range=pc,format=yuv420p`,
],
twoPass: false,
}),
@@ -481,7 +482,7 @@ describe(MediaService.name, () => {
'-frames:v 1',
'-update 1',
'-v verbose',
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`,
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=601:m=470bg:range=pc,format=yuv420p`,
],
twoPass: false,
}),
@@ -1327,7 +1328,7 @@ describe(MediaService.name, () => {
'-map 0:0',
'-map 0:1',
'-v verbose',
'-vf scale=-2:720',
'-vf scale=-2:720,format=yuv420p',
'-preset 12',
'-crf 23',
]),
@@ -1453,7 +1454,7 @@ describe(MediaService.name, () => {
'-map 0:1',
'-g 256',
'-v verbose',
'-vf hwupload_cuda,scale_cuda=-2:720:format=nv12',
'-vf format=nv12,hwupload_cuda,scale_cuda=-2:720',
'-preset p1',
'-cq:v 23',
]),
@@ -1585,7 +1586,7 @@ describe(MediaService.name, () => {
inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']),
outputOptions: expect.arrayContaining([
expect.stringContaining(
'tonemap_cuda=desat=0:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:tonemap_mode=lum:transfer=bt709:peak=100:format=nv12',
'tonemap_cuda=desat=0:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709:format=nv12',
),
]),
twoPass: false,
@@ -1593,24 +1594,6 @@ describe(MediaService.name, () => {
);
});
it('should set format to nv12 for nvenc if input is not yuv420p', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']),
outputOptions: expect.arrayContaining([expect.stringContaining('format=nv12')]),
twoPass: false,
}),
);
});
it('should set options for qsv', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
@@ -1633,7 +1616,7 @@ describe(MediaService.name, () => {
'-refs 5',
'-g 256',
'-v verbose',
'-vf hwupload=extra_hw_frames=64,scale_qsv=-1:720:mode=hq:format=nv12',
'-vf format=nv12,hwupload=extra_hw_frames=64,scale_qsv=-1:720:mode=hq',
'-preset 7',
'-global_quality:v 23',
'-maxrate 10000k',
@@ -1765,7 +1748,7 @@ describe(MediaService.name, () => {
]),
outputOptions: expect.arrayContaining([
expect.stringContaining(
'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:transfer=bt709:range=pc:tonemap=hable:tonemap_mode=lum:peak=100,hwmap=derive_device=qsv:reverse=1,format=qsv',
'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709,hwmap=derive_device=qsv:reverse=1,format=qsv',
),
]),
twoPass: false,
@@ -1793,32 +1776,6 @@ describe(MediaService.name, () => {
);
});
it('should set format to nv12 for qsv if input is not yuv420p', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel qsv',
'-hwaccel_output_format qsv',
'-async_depth 4',
'-threads 1',
]),
outputOptions: expect.arrayContaining([expect.stringContaining('format=nv12')]),
twoPass: false,
}),
);
});
it('should set options for vaapi', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
@@ -1842,7 +1799,7 @@ describe(MediaService.name, () => {
'-map 0:1',
'-g 256',
'-v verbose',
'-vf hwupload=extra_hw_frames=64,scale_vaapi=-2:720:mode=hq:out_range=pc:format=nv12',
'-vf format=nv12,hwupload,scale_vaapi=-2:720:mode=hq:out_range=pc',
'-compression_level 7',
'-rc_mode 1',
]),
@@ -2013,7 +1970,7 @@ describe(MediaService.name, () => {
);
});
it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => {
it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({
@@ -2030,7 +1987,7 @@ describe(MediaService.name, () => {
inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']),
outputOptions: expect.arrayContaining([
expect.stringContaining(
'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:transfer=bt709:range=pc:tonemap=hable:tonemap_mode=lum:peak=100,hwmap=derive_device=vaapi:reverse=1,format=vaapi',
'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709,hwmap=derive_device=vaapi:reverse=1,format=vaapi',
),
]),
twoPass: false,
@@ -2038,27 +1995,6 @@ describe(MediaService.name, () => {
);
});
it('should set format to nv12 for vaapi if input is not yuv420p', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']),
outputOptions: expect.arrayContaining([expect.stringContaining('format=nv12')]),
twoPass: false,
}),
);
});
it('should use preferred device for vaapi when hardware decoding', async () => {
storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
@@ -2204,7 +2140,7 @@ describe(MediaService.name, () => {
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([
expect.stringContaining(
'scale_rkrga=-2:720:format=p010:afbc=1,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0:tonemap_mode=lum:peak=100,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime',
'scale_rkrga=-2:720:format=p010:afbc=1,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime',
),
]),
twoPass: false,
@@ -2228,7 +2164,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: expect.arrayContaining([
expect.stringContaining(
'tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',
'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p',
),
]),
twoPass: false,
@@ -2252,7 +2188,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: expect.arrayContaining([
expect.stringContaining(
'tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',
'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p',
),
]),
twoPass: false,
@@ -2273,7 +2209,7 @@ describe(MediaService.name, () => {
outputOptions: expect.arrayContaining([
'-c:v h264',
'-c:a copy',
'-vf tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p',
]),
twoPass: false,
}),
@@ -2293,16 +2229,16 @@ describe(MediaService.name, () => {
outputOptions: expect.arrayContaining([
'-c:v h264',
'-c:a copy',
'-vf tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p',
]),
twoPass: false,
}),
);
});
it('should transcode when policy is required and video is not yuv420p', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@@ -2310,7 +2246,11 @@ describe(MediaService.name, () => {
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy', '-vf format=yuv420p']),
outputOptions: expect.arrayContaining([
'-c:v h264',
'-c:a copy',
'-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p',
]),
twoPass: false,
}),
);
+8 -14
View File
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { dirname } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import {
@@ -20,10 +19,11 @@ import {
} from 'src/enum';
import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
import {
IBaseJob,
IEntityJob,
JOBS_ASSET_PAGINATION_SIZE,
JobItem,
JobName,
JobOf,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
@@ -39,8 +39,7 @@ export class MediaService extends BaseService {
private maliOpenCL?: boolean;
private devices?: string[];
@OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> {
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination, {
@@ -91,7 +90,6 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION })
async handleQueueMigration(): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination),
@@ -122,8 +120,7 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.MIGRATE_ASSET, queue: QueueName.MIGRATION })
async handleAssetMigration({ id }: JobOf<JobName.MIGRATE_ASSET>): Promise<JobStatus> {
async handleAssetMigration({ id }: IEntityJob): Promise<JobStatus> {
const { image } = await this.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id], { files: true });
if (!asset) {
@@ -137,8 +134,7 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
async handleGenerateThumbnails({ id }: JobOf<JobName.GENERATE_THUMBNAILS>): Promise<JobStatus> {
async handleGenerateThumbnails({ id }: IEntityJob): Promise<JobStatus> {
const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true });
if (!asset) {
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
@@ -261,8 +257,7 @@ export class MediaService extends BaseService {
return { previewPath, thumbnailPath, thumbhash };
}
@OnJob({ name: JobName.QUEUE_VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION })
async handleQueueVideoConversion(job: JobOf<JobName.QUEUE_VIDEO_CONVERSION>): Promise<JobStatus> {
async handleQueueVideoConversion(job: IBaseJob): Promise<JobStatus> {
const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
@@ -280,8 +275,7 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION })
async handleVideoConversion({ id }: JobOf<JobName.VIDEO_CONVERSION>): Promise<JobStatus> {
async handleVideoConversion({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || asset.type !== AssetType.VIDEO) {
return JobStatus.FAILED;
@@ -413,7 +407,7 @@ export class MediaService extends BaseService {
const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec);
const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p');
const isRequired = !isTargetVideoCodec || stream.isHDR;
switch (ffmpegConfig.transcode) {
case TranscodePolicy.DISABLED: {
+19 -33
View File
@@ -7,7 +7,7 @@ import { constants } from 'node:fs/promises';
import path from 'node:path';
import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
@@ -16,7 +16,15 @@ import { AssetType, ImmichWorker, SourceType } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
import {
IBaseJob,
IEntityJob,
ISidecarWriteJob,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { ReverseGeocodeResult } from 'src/interfaces/map.interface';
import { ImmichTags } from 'src/interfaces/metadata.interface';
import { BaseService } from 'src/services/base.service';
@@ -116,8 +124,7 @@ export class MetadataService extends BaseService {
}
}
@OnJob({ name: JobName.LINK_LIVE_PHOTOS, queue: QueueName.METADATA_EXTRACTION })
async handleLivePhotoLinking(job: JobOf<JobName.LINK_LIVE_PHOTOS>): Promise<JobStatus> {
async handleLivePhotoLinking(job: IEntityJob): Promise<JobStatus> {
const { id } = job;
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
if (!asset?.exifInfo) {
@@ -152,8 +159,7 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.QUEUE_METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleQueueMetadataExtraction(job: JobOf<JobName.QUEUE_METADATA_EXTRACTION>): Promise<JobStatus> {
async handleQueueMetadataExtraction(job: IBaseJob): Promise<JobStatus> {
const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
@@ -170,8 +176,7 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleMetadataExtraction({ id }: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } });
if (!asset) {
@@ -187,8 +192,6 @@ export class MetadataService extends BaseService {
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
const { width, height } = this.getImageDimensions(exifTags);
const exifData: Partial<ExifEntity> = {
assetId: asset.id,
@@ -206,8 +209,8 @@ export class MetadataService extends BaseService {
// image/file
fileSizeInByte: stats.size,
exifImageHeight: validate(height),
exifImageWidth: validate(width),
exifImageHeight: validate(exifTags.ImageHeight),
exifImageWidth: validate(exifTags.ImageWidth),
orientation: validate(exifTags.Orientation)?.toString() ?? null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
bitsPerSample: this.getBitsPerSample(exifTags),
@@ -257,8 +260,7 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.QUEUE_SIDECAR, queue: QueueName.SIDECAR })
async handleQueueSidecar(job: JobOf<JobName.QUEUE_SIDECAR>): Promise<JobStatus> {
async handleQueueSidecar(job: IBaseJob): Promise<JobStatus> {
const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
@@ -278,13 +280,11 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.SIDECAR_SYNC, queue: QueueName.SIDECAR })
handleSidecarSync({ id }: JobOf<JobName.SIDECAR_SYNC>): Promise<JobStatus> {
handleSidecarSync({ id }: IEntityJob): Promise<JobStatus> {
return this.processSidecar(id, true);
}
@OnJob({ name: JobName.SIDECAR_DISCOVERY, queue: QueueName.SIDECAR })
handleSidecarDiscovery({ id }: JobOf<JobName.SIDECAR_DISCOVERY>): Promise<JobStatus> {
handleSidecarDiscovery({ id }: IEntityJob): Promise<JobStatus> {
return this.processSidecar(id, false);
}
@@ -298,8 +298,7 @@ export class MetadataService extends BaseService {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}
@OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR })
async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> {
async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const [asset] = await this.assetRepository.getByIds([id], { tags: true });
if (!asset) {
@@ -335,19 +334,6 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS;
}
private getImageDimensions(exifTags: ImmichTags): { width?: number; height?: number } {
/*
* The "true" values for width and height are a bit hidden, depending on the camera model and file format.
* For RAW images in the CR2 or RAF format, the "ImageSize" value seems to be correct,
* but ImageWidth and ImageHeight are not correct (they contain the dimensions of the preview image).
*/
let [width, height] = exifTags.ImageSize?.split('x').map((dim) => Number.parseInt(dim) || undefined) || [];
if (!width || !height) {
[width, height] = [exifTags.ImageWidth, exifTags.ImageHeight];
}
return { width, height };
}
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
const mediaTags = await this.metadataRepository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.metadataRepository.readTags(asset.sidecarPath) : {};
@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators';
import { ImmichWorker } from 'src/enum';
import { ArgOf } from 'src/interfaces/event.interface';
import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { BackupService } from 'src/services/backup.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MediaService } from 'src/services/media.service';
import { MetadataService } from 'src/services/metadata.service';
import { NotificationService } from 'src/services/notification.service';
import { PersonService } from 'src/services/person.service';
import { SessionService } from 'src/services/session.service';
import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { TagService } from 'src/services/tag.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
@Injectable()
export class MicroservicesService {
constructor(
private auditService: AuditService,
private assetService: AssetService,
private backupService: BackupService,
private jobService: JobService,
private libraryService: LibraryService,
private mediaService: MediaService,
private metadataService: MetadataService,
private notificationService: NotificationService,
private personService: PersonService,
private smartInfoService: SmartInfoService,
private sessionService: SessionService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private tagService: TagService,
private trashService: TrashService,
private userService: UserService,
private duplicateService: DuplicateService,
private versionService: VersionService,
) {}
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== ImmichWorker.MICROSERVICES) {
return;
}
await this.jobService.init({
[JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
[JobName.BACKUP_DATABASE]: () => this.backupService.handleBackupDatabase(),
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
[JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(),
[JobName.CLEAN_OLD_SESSION_TOKENS]: () => this.sessionService.handleCleanup(),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(),
[JobName.QUEUE_SMART_SEARCH]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
[JobName.SMART_SEARCH]: (data) => this.smartInfoService.handleEncodeClip(data),
[JobName.QUEUE_DUPLICATE_DETECTION]: (data) => this.duplicateService.handleQueueSearchDuplicates(data),
[JobName.DUPLICATE_DETECTION]: (data) => this.duplicateService.handleSearchDuplicates(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
[JobName.QUEUE_MIGRATION]: () => this.mediaService.handleQueueMigration(),
[JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data),
[JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data),
[JobName.METADATA_EXTRACTION]: (data) => this.metadataService.handleMetadataExtraction(data),
[JobName.LINK_LIVE_PHOTOS]: (data) => this.metadataService.handleLivePhotoLinking(data),
[JobName.QUEUE_FACE_DETECTION]: (data) => this.personService.handleQueueDetectFaces(data),
[JobName.FACE_DETECTION]: (data) => this.personService.handleDetectFaces(data),
[JobName.QUEUE_FACIAL_RECOGNITION]: (data) => this.personService.handleQueueRecognizeFaces(data),
[JobName.FACIAL_RECOGNITION]: (data) => this.personService.handleRecognizeFaces(data),
[JobName.GENERATE_PERSON_THUMBNAIL]: (data) => this.personService.handleGeneratePersonThumbnail(data),
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data),
[JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data),
[JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(),
[JobName.LIBRARY_QUEUE_SYNC_FILES]: (data) => this.libraryService.handleQueueSyncFiles(data), //Queues all files paths on disk
[JobName.LIBRARY_SYNC_FILE]: (data) => this.libraryService.handleSyncFile(data), //Handles a single path on disk //Watcher calls for new files
[JobName.LIBRARY_QUEUE_SYNC_ASSETS]: (data) => this.libraryService.handleQueueSyncAssets(data), //Queues all library assets
[JobName.LIBRARY_SYNC_ASSET]: (data) => this.libraryService.handleSyncAsset(data), //Handles all library assets // Watcher calls for unlink and changed
[JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data),
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
[JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
[JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data),
[JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data),
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
[JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(),
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
[JobName.QUEUE_TRASH_EMPTY]: () => this.trashService.handleQueueEmptyTrash(),
});
}
}
+8 -11
View File
@@ -1,16 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { ArgOf } from 'src/interfaces/event.interface';
import {
IEmailJob,
IEntityJob,
INotifyAlbumInviteJob,
INotifyAlbumUpdateJob,
INotifySignupJob,
JobItem,
JobName,
JobOf,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { EmailImageAttachment, EmailTemplate } from 'src/interfaces/notification.interface';
import { BaseService } from 'src/services/base.service';
@@ -175,8 +176,7 @@ export class NotificationService extends BaseService {
return { messageId };
}
@OnJob({ name: JobName.NOTIFY_SIGNUP, queue: QueueName.NOTIFICATION })
async handleUserSignup({ id, tempPassword }: JobOf<JobName.NOTIFY_SIGNUP>) {
async handleUserSignup({ id, tempPassword }: INotifySignupJob) {
const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) {
return JobStatus.SKIPPED;
@@ -207,8 +207,7 @@ export class NotificationService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.NOTIFY_ALBUM_INVITE, queue: QueueName.NOTIFICATION })
async handleAlbumInvite({ id, recipientId }: JobOf<JobName.NOTIFY_ALBUM_INVITE>) {
async handleAlbumInvite({ id, recipientId }: INotifyAlbumInviteJob) {
const album = await this.albumRepository.getById(id, { withAssets: false });
if (!album) {
return JobStatus.SKIPPED;
@@ -255,8 +254,7 @@ export class NotificationService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.NOTIFY_ALBUM_UPDATE, queue: QueueName.NOTIFICATION })
async handleAlbumUpdate({ id, recipientIds }: JobOf<JobName.NOTIFY_ALBUM_UPDATE>) {
async handleAlbumUpdate({ id, recipientIds }: INotifyAlbumUpdateJob) {
const album = await this.albumRepository.getById(id, { withAssets: false });
if (!album) {
@@ -314,8 +312,7 @@ export class NotificationService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.SEND_EMAIL, queue: QueueName.NOTIFICATION })
async handleSendEmail(data: JobOf<JobName.SEND_EMAIL>): Promise<JobStatus> {
async handleSendEmail(data: IEmailJob): Promise<JobStatus> {
const { notifications } = await this.getConfig({ withCache: false });
if (!notifications.smtp.enabled) {
return JobStatus.SKIPPED;
+10 -15
View File
@@ -1,7 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -34,10 +33,13 @@ import {
} from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface';
import {
IBaseJob,
IDeferrableJob,
IEntityJob,
INightlyJob,
JOBS_ASSET_PAGINATION_SIZE,
JobItem,
JobName,
JobOf,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
@@ -229,15 +231,13 @@ export class PersonService extends BaseService {
this.logger.debug(`Deleted ${people.length} people`);
}
@OnJob({ name: JobName.PERSON_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.personRepository.getAllWithoutFaces();
await this.delete(people);
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.QUEUE_FACE_DETECTION, queue: QueueName.FACE_DETECTION })
async handleQueueDetectFaces({ force }: JobOf<JobName.QUEUE_FACE_DETECTION>): Promise<JobStatus> {
async handleQueueDetectFaces({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
@@ -272,8 +272,7 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.FACE_DETECTION, queue: QueueName.FACE_DETECTION })
async handleDetectFaces({ id }: JobOf<JobName.FACE_DETECTION>): Promise<JobStatus> {
async handleDetectFaces({ id }: IEntityJob): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
@@ -377,8 +376,7 @@ export class PersonService extends BaseService {
return intersection / union;
}
@OnJob({ name: JobName.QUEUE_FACIAL_RECOGNITION, queue: QueueName.FACIAL_RECOGNITION })
async handleQueueRecognizeFaces({ force, nightly }: JobOf<JobName.QUEUE_FACIAL_RECOGNITION>): Promise<JobStatus> {
async handleQueueRecognizeFaces({ force, nightly }: INightlyJob): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
@@ -428,8 +426,7 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.FACIAL_RECOGNITION, queue: QueueName.FACIAL_RECOGNITION })
async handleRecognizeFaces({ id, deferred }: JobOf<JobName.FACIAL_RECOGNITION>): Promise<JobStatus> {
async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
@@ -512,8 +509,7 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.MIGRATE_PERSON, queue: QueueName.MIGRATION })
async handlePersonMigration({ id }: JobOf<JobName.MIGRATE_PERSON>): Promise<JobStatus> {
async handlePersonMigration({ id }: IEntityJob): Promise<JobStatus> {
const person = await this.personRepository.getById(id);
if (!person) {
return JobStatus.FAILED;
@@ -524,8 +520,7 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION })
async handleGeneratePersonThumbnail(data: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> {
async handleGeneratePersonThumbnail(data: IEntityJob): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
return JobStatus.SKIPPED;
+2 -4
View File
@@ -1,16 +1,14 @@
import { Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { Permission } from 'src/enum';
import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class SessionService extends BaseService {
@OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK })
async handleCleanup(): Promise<JobStatus> {
async handleCleanup() {
const sessions = await this.sessionRepository.search({
updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(),
});
+11 -6
View File
@@ -1,11 +1,18 @@
import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { ImmichWorker } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
import {
IBaseJob,
IEntityJob,
JOBS_ASSET_PAGINATION_SIZE,
JobName,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
@@ -79,8 +86,7 @@ export class SmartInfoService extends BaseService {
});
}
@OnJob({ name: JobName.QUEUE_SMART_SEARCH, queue: QueueName.SMART_SEARCH })
async handleQueueEncodeClip({ force }: JobOf<JobName.QUEUE_SMART_SEARCH>): Promise<JobStatus> {
async handleQueueEncodeClip({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false });
if (!isSmartSearchEnabled(machineLearning)) {
return JobStatus.SKIPPED;
@@ -105,8 +111,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.SMART_SEARCH, queue: QueueName.SMART_SEARCH })
async handleEncodeClip({ id }: JobOf<JobName.SMART_SEARCH>): Promise<JobStatus> {
async handleEncodeClip({ id }: IEntityJob): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isSmartSearchEnabled(machineLearning)) {
return JobStatus.SKIPPED;
@@ -4,13 +4,13 @@ import { DateTime } from 'luxon';
import path from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { getLivePhotoMotionFilename } from 'src/utils/file';
import { usePagination } from 'src/utils/pagination';
@@ -108,8 +108,7 @@ export class StorageTemplateService extends BaseService {
return { ...storageTokens, presetOptions: storagePresets };
}
@OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, queue: QueueName.STORAGE_TEMPLATE_MIGRATION })
async handleMigrationSingle({ id }: JobOf<JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE>): Promise<JobStatus> {
async handleMigrationSingle({ id }: IEntityJob): Promise<JobStatus> {
const config = await this.getConfig({ withCache: true });
const storageTemplateEnabled = config.storageTemplate.enabled;
if (!storageTemplateEnabled) {
@@ -138,7 +137,6 @@ export class StorageTemplateService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION, queue: QueueName.STORAGE_TEMPLATE_MIGRATION })
async handleMigration(): Promise<JobStatus> {
this.logger.log('Starting storage template migration');
const { storageTemplate } = await this.getConfig({ withCache: true });
+1 -2
View File
@@ -3,8 +3,7 @@ import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service';
import { ImmichStartupError } from 'src/utils/misc';
import { ImmichStartupError, StorageService } from 'src/services/storage.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
+6 -5
View File
@@ -1,13 +1,15 @@
import { Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { SystemFlags } from 'src/entities/system-metadata.entity';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { ImmichStartupError } from 'src/utils/misc';
export class ImmichStartupError extends Error {}
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
@@ -64,8 +66,7 @@ export class StorageService extends BaseService {
});
}
@OnJob({ name: JobName.DELETE_FILES, queue: QueueName.BACKGROUND_TASK })
async handleDeleteFiles(job: JobOf<JobName.DELETE_FILES>): Promise<JobStatus> {
async handleDeleteFiles(job: IDeleteFilesJob) {
const { files } = job;
// TODO: one job per file
@@ -65,6 +65,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
bframes: -1,
refs: 0,
gopSize: 0,
npl: 0,
temporalAQ: false,
cqMode: CQMode.AUTO,
twoPass: false,
+1 -3
View File
@@ -1,5 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { OnJob } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -13,7 +12,7 @@ import {
} from 'src/dtos/tag.dto';
import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum';
import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { AssetTagItem } from 'src/interfaces/tag.interface';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -132,7 +131,6 @@ export class TagService extends BaseService {
return results;
}
@OnJob({ name: JobName.TAG_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async handleTagCleanup() {
await this.tagRepository.deleteEmptyTags();
return JobStatus.SUCCESS;
+2 -3
View File
@@ -1,9 +1,9 @@
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum';
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { usePagination } from 'src/utils/pagination';
@@ -44,7 +44,6 @@ export class TrashService extends BaseService {
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
}
@OnJob({ name: JobName.QUEUE_TRASH_EMPTY, queue: QueueName.BACKGROUND_TASK })
async handleQueueEmptyTrash() {
let count = 0;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
+2 -6
View File
@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
@@ -11,7 +10,7 @@ import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUse
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum';
import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { IEntityJob, JobName, JobStatus } from 'src/interfaces/job.interface';
import { UserFindOptions } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { ImmichFileResponse } from 'src/utils/file';
@@ -164,13 +163,11 @@ export class UserService extends BaseService {
return licenseData;
}
@OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK })
async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage();
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.USER_DELETE_CHECK, queue: QueueName.BACKGROUND_TASK })
async handleUserDeleteCheck(): Promise<JobStatus> {
const users = await this.userRepository.getDeletedUsers();
const config = await this.getConfig({ withCache: false });
@@ -184,8 +181,7 @@ export class UserService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.USER_DELETION, queue: QueueName.BACKGROUND_TASK })
async handleUserDelete({ id, force }: JobOf<JobName.USER_DELETION>): Promise<JobStatus> {
async handleUserDelete({ id, force }: IEntityJob): Promise<JobStatus> {
const config = await this.getConfig({ withCache: false });
const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) {
+2 -3
View File
@@ -2,13 +2,13 @@ import { Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { ImmichEnvironment, SystemMetadataKey } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { JobName, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
@@ -48,7 +48,6 @@ export class VersionService extends BaseService {
await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} });
}
@OnJob({ name: JobName.VERSION_CHECK, queue: QueueName.BACKGROUND_TASK })
async handleVersionCheck(): Promise<JobStatus> {
try {
this.logger.debug('Running version check');
+1 -1
View File
@@ -16,7 +16,7 @@ export const logGlobalError = (logger: ILoggerRepository, error: Error) => {
}
if (error instanceof Error) {
logger.error(`Unknown error: ${error}`, error?.stack);
logger.error(`Unknown error: ${error}`);
return;
}
};
+100 -56
View File
@@ -149,11 +149,7 @@ export class BaseConfig implements VideoCodecSWConfig {
options.push(`scale=${this.getScaling(videoStream)}`);
}
options.push(...this.getToneMapping(videoStream));
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push(`format=yuv420p`);
}
options.push(...this.getToneMapping(videoStream), 'format=yuv420p');
return options;
}
@@ -275,20 +271,33 @@ export class BaseConfig implements VideoCodecSWConfig {
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
primaries: '709',
transfer: '709',
matrix: '709',
};
}
getNPL() {
if (this.config.npl <= 0) {
// since hable already outputs a darker image, we use a lower npl value for it
return this.config.tonemap === ToneMapping.HABLE ? 100 : 250;
} else {
return this.config.npl;
}
}
getToneMapping(videoStream: VideoStreamInfo) {
if (!this.shouldToneMap(videoStream)) {
return [];
}
const { primaries, transfer, matrix } = this.getColors();
const options = `tonemapx=tonemap=${this.config.tonemap}:desat=0:p=${primaries}:t=${transfer}:m=${matrix}:r=pc:peak=100:format=yuv420p`;
return [options];
const colors = this.getColors();
return [
`zscale=t=linear:npl=${this.getNPL()}`,
`tonemap=${this.config.tonemap}:desat=0`,
`zscale=p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:range=pc`,
];
}
getAudioCodec(): string {
@@ -386,14 +395,19 @@ export class ThumbnailConfig extends BaseConfig {
}
getFilterOptions(videoStream: VideoStreamInfo): string[] {
return [
const options = [
'fps=12:eof_action=pass:round=down',
'thumbnail=12',
String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
'trim=end_frame=2',
'reverse',
...super.getFilterOptions(videoStream),
];
if (this.shouldScale(videoStream)) {
options.push(`scale=${this.getScaling(videoStream)}`);
}
options.push(...this.getToneMapping(videoStream), 'format=yuv420p');
return options;
}
getPresetOptions() {
@@ -409,7 +423,19 @@ export class ThumbnailConfig extends BaseConfig {
}
getScaling(videoStream: VideoStreamInfo) {
return super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc';
let options = super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int';
if (!this.shouldToneMap(videoStream)) {
options += ':out_color_matrix=bt601:out_range=pc';
}
return options;
}
getColors() {
return {
primaries: '709',
transfer: '601',
matrix: '470bg',
};
}
}
@@ -533,9 +559,9 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('hwupload_cuda');
options.push('format=nv12', 'hwupload_cuda');
if (this.shouldScale(videoStream)) {
options.push(`scale_cuda=${this.getScaling(videoStream)}:format=nv12`);
options.push(`scale_cuda=${this.getScaling(videoStream)}`);
}
return options;
@@ -596,8 +622,6 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
options.push(...this.getToneMapping(videoStream));
if (options.length > 0) {
options[options.length - 1] += ':format=nv12';
} else if (!videoStream.pixelFormat.endsWith('420p')) {
options.push('format=nv12');
}
return options;
}
@@ -607,16 +631,14 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
return [];
}
const { matrix, primaries, transfer } = this.getColors();
const colors = this.getColors();
const tonemapOptions = [
'desat=0',
`matrix=${matrix}`,
`primaries=${primaries}`,
`matrix=${colors.matrix}`,
`primaries=${colors.primaries}`,
'range=pc',
`tonemap=${this.config.tonemap}`,
'tonemap_mode=lum',
`transfer=${transfer}`,
'peak=100',
`transfer=${colors.transfer}`,
];
return [`tonemap_cuda=${tonemapOptions.join(':')}`];
@@ -629,6 +651,14 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
getOutputThreadOptions() {
return [];
}
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
};
}
}
export class QsvSwDecodeConfig extends BaseHWConfig {
@@ -657,9 +687,9 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('hwupload=extra_hw_frames=64');
options.push('format=nv12', 'hwupload=extra_hw_frames=64');
if (this.shouldScale(videoStream)) {
options.push(`scale_qsv=${this.getScaling(videoStream)}:mode=hq:format=nv12`);
options.push(`scale_qsv=${this.getScaling(videoStream)}:mode=hq`);
}
return options;
}
@@ -734,18 +764,15 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
getFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
const tonemapOptions = this.getToneMapping(videoStream);
if (this.shouldScale(videoStream) || tonemapOptions.length === 0) {
if (this.shouldScale(videoStream) || !this.shouldToneMap(videoStream)) {
let scaling = `scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq`;
if (tonemapOptions.length === 0) {
if (!this.shouldToneMap(videoStream)) {
scaling += ':format=nv12';
}
options.push(scaling);
}
options.push(...tonemapOptions);
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push('format=nv12');
}
options.push(...this.getToneMapping(videoStream));
return options;
}
@@ -754,17 +781,15 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
return [];
}
const { matrix, primaries, transfer } = this.getColors();
const colors = this.getColors();
const tonemapOptions = [
'desat=0',
'format=nv12',
`matrix=${matrix}`,
`primaries=${primaries}`,
`transfer=${transfer}`,
`matrix=${colors.matrix}`,
`primaries=${colors.primaries}`,
'range=pc',
`tonemap=${this.config.tonemap}`,
'tonemap_mode=lum',
'peak=100',
`transfer=${colors.transfer}`,
];
return [
@@ -777,6 +802,14 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
getInputThreadOptions() {
return [`-threads 1`];
}
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
};
}
}
export class VaapiSwDecodeConfig extends BaseHWConfig {
@@ -795,9 +828,9 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('hwupload=extra_hw_frames=64');
options.push('format=nv12', 'hwupload');
if (this.shouldScale(videoStream)) {
options.push(`scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc:format=nv12`);
options.push(`scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc`);
}
return options;
@@ -868,18 +901,15 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
getFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
const tonemapOptions = this.getToneMapping(videoStream);
if (this.shouldScale(videoStream) || tonemapOptions.length === 0) {
if (this.shouldScale(videoStream) || !this.shouldToneMap(videoStream)) {
let scaling = `scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc`;
if (tonemapOptions.length === 0) {
if (!this.shouldToneMap(videoStream)) {
scaling += ':format=nv12';
}
options.push(scaling);
}
options.push(...tonemapOptions);
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push('format=nv12');
}
options.push(...this.getToneMapping(videoStream));
return options;
}
@@ -888,17 +918,15 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
return [];
}
const { matrix, primaries, transfer } = this.getColors();
const colors = this.getColors();
const tonemapOptions = [
'desat=0',
'format=nv12',
`matrix=${matrix}`,
`primaries=${primaries}`,
`transfer=${transfer}`,
`matrix=${colors.matrix}`,
`primaries=${colors.primaries}`,
'range=pc',
`tonemap=${this.config.tonemap}`,
'tonemap_mode=lum',
'peak=100',
`transfer=${colors.transfer}`,
];
return [
@@ -911,6 +939,14 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
getInputThreadOptions() {
return [`-threads 1`];
}
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
};
}
}
export class RkmppSwDecodeConfig extends BaseHWConfig {
@@ -978,11 +1014,11 @@ export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
getFilterOptions(videoStream: VideoStreamInfo) {
if (this.shouldToneMap(videoStream)) {
const { primaries, transfer, matrix } = this.getColors();
const colors = this.getColors();
return [
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`,
'hwmap=derive_device=opencl:mode=read',
`tonemap_opencl=format=nv12:r=pc:p=${primaries}:t=${transfer}:m=${matrix}:tonemap=${this.config.tonemap}:desat=0:tonemap_mode=lum:peak=100`,
`tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`,
'hwmap=derive_device=rkmpp:mode=write:reverse=1',
'format=drm_prime',
];
@@ -991,4 +1027,12 @@ export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
}
return [];
}
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
};
}
}
-26
View File
@@ -15,32 +15,6 @@ import { CLIP_MODEL_INFO, serverVersion } from 'src/constants';
import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
export class ImmichStartupError extends Error {}
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
export const getKeyByValue = (object: Record<string, unknown>, value: unknown) =>
Object.keys(object).find((key) => object[key] === value);
export const getMethodNames = (instance: any) => {
const ctx = Object.getPrototypeOf(instance);
const methods: string[] = [];
for (const property of Object.getOwnPropertyNames(ctx)) {
const descriptor = Object.getOwnPropertyDescriptor(ctx, property);
if (!descriptor || descriptor.get || descriptor.set) {
continue;
}
const handler = instance[property];
if (typeof handler !== 'function') {
continue;
}
methods.push(property);
}
return methods;
};
export const getExternalDomain = (server: SystemConfig['server'], port: number) =>
server.externalDomain || `http://localhost:${port}`;
+2 -1
View File
@@ -13,7 +13,8 @@ import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service';
import { isStartUpError, useSwagger } from 'src/utils/misc';
import { isStartUpError } from 'src/services/storage.service';
import { useSwagger } from 'src/utils/misc';
async function bootstrap() {
process.title = 'immich-api';
+1 -1
View File
@@ -7,7 +7,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { isStartUpError } from 'src/utils/misc';
import { isStartUpError } from 'src/services/storage.service';
export async function bootstrap() {
const { telemetry } = new ConfigRepository().getEnv();
-25
View File
@@ -17,7 +17,6 @@ const probeStubDefaultVideoStream: VideoStreamInfo[] = [
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
},
];
@@ -44,7 +43,6 @@ export const probeStub = {
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
},
{
index: 1,
@@ -55,7 +53,6 @@ export const probeStub = {
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
},
],
}),
@@ -71,7 +68,6 @@ export const probeStub = {
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
},
],
}),
@@ -87,7 +83,6 @@ export const probeStub = {
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
},
],
}),
@@ -107,23 +102,6 @@ export const probeStub = {
rotation: 0,
isHDR: true,
bitrate: 0,
pixelFormat: 'yuv420p10le',
},
],
}),
videoStream10Bit: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [
{
index: 0,
height: 480,
width: 480,
codecName: 'h264',
frameCount: 100,
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p10le',
},
],
}),
@@ -139,7 +117,6 @@ export const probeStub = {
rotation: 90,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
},
],
}),
@@ -155,7 +132,6 @@ export const probeStub = {
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
},
],
}),
@@ -171,7 +147,6 @@ export const probeStub = {
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
},
],
}),
@@ -3,9 +3,7 @@ import { Mocked, vitest } from 'vitest';
export const newJobRepositoryMock = (): Mocked<IJobRepository> => {
return {
setup: vitest.fn(),
startWorkers: vitest.fn(),
run: vitest.fn(),
addHandler: vitest.fn(),
addCronJob: vitest.fn(),
updateCronJob: vitest.fn(),
setConcurrency: vitest.fn(),
+4 -9
View File
@@ -1,7 +1,6 @@
import { ChildProcessWithoutNullStreams } from 'node:child_process';
import { Writable } from 'node:stream';
import { PNG } from 'pngjs';
import { ImmichWorker } from 'src/enum';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { BaseService } from 'src/services/base.service';
import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
@@ -45,9 +44,8 @@ import { newViewRepositoryMock } from 'test/repositories/view.repository.mock';
import { Readable } from 'typeorm/platform/PlatformTools';
import { Mocked, vitest } from 'vitest';
type Overrides = {
worker?: ImmichWorker;
metadataRepository?: IMetadataRepository;
type RepositoryOverrides = {
metadataRepository: IMetadataRepository;
};
type BaseServiceArgs = ConstructorParameters<typeof BaseService>;
type Constructor<Type, Args extends Array<any>> = {
@@ -56,11 +54,9 @@ type Constructor<Type, Args extends Array<any>> = {
export const newTestService = <T extends BaseService>(
Service: Constructor<T, BaseServiceArgs>,
overrides?: Overrides,
overrides?: RepositoryOverrides,
) => {
const { metadataRepository, worker: workerOverride } = overrides || {};
const worker = workerOverride || ImmichWorker.API;
const { metadataRepository } = overrides || {};
const accessMock = newAccessRepositoryMock();
const loggerMock = newLoggerRepositoryMock();
@@ -102,7 +98,6 @@ export const newTestService = <T extends BaseService>(
const viewMock = newViewRepositoryMock();
const sut = new Service(
worker,
loggerMock,
accessMock,
activityMock,
+129 -121
View File
@@ -23,9 +23,9 @@
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"socket.io-client": "~4.7.5",
"socket.io-client": "^4.7.4",
"svelte-gestures": "^5.0.4",
"svelte-i18n": "^4.0.1",
"svelte-i18n": "^4.0.0",
"svelte-local-storage-store": "^0.6.4",
"svelte-maplibre": "^0.9.13",
"thumbhash": "^0.1.1"
@@ -35,12 +35,12 @@
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^9.0.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.3.0",
"@sveltejs/kit": "^2.7.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@sveltejs/kit": "^2.5.18",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.4",
"@testing-library/svelte": "^5.2.0",
"@testing-library/user-event": "^14.5.2",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
@@ -63,7 +63,7 @@
"prettier-plugin-sort-json": "^4.0.0",
"prettier-plugin-svelte": "^3.2.6",
"rollup-plugin-visualizer": "^5.12.0",
"svelte": "^5.1.5",
"svelte": "^4.2.19",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
@@ -80,7 +80,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.1",
"@types/node": "^22.8.0",
"typescript": "^5.3.3"
}
},
@@ -1994,42 +1994,43 @@
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.0.tgz",
"integrity": "sha512-kpVJwF+gNiMEsoHaw+FJL76IYiwBikkxYU83+BpqQLdVMff19KeRKLd2wisS8niNBMJ2omv5gG+iGDDwd8jzag==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
"integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
"debug": "^4.3.7",
"@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
"debug": "^4.3.4",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
"magic-string": "^0.30.12",
"vitefu": "^1.0.3"
"magic-string": "^0.30.10",
"svelte-hmr": "^0.16.0",
"vitefu": "^0.2.5"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22"
"node": "^18.0.0 || >=20"
},
"peerDependencies": {
"svelte": "^5.0.0-next.96 || ^5.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"vite": "^5.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
"integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
"integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.7"
"debug": "^4.3.4"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22"
"node": "^18.0.0 || >=20"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
"svelte": "^5.0.0-next.96 || ^5.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"vite": "^5.0.0"
}
},
@@ -2229,6 +2230,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.4.tgz",
"integrity": "sha512-EFdy73+lULQgMJ1WolAymrxWWrPv9DWyDuDFKKlUip2PA/EXuHptzfYOKWljccFWDKhhGOu3dqNmoc2f/h/Ecg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@testing-library/dom": "^10.0.0"
},
@@ -2795,15 +2797,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-typescript": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz",
"integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==",
"license": "MIT",
"peerDependencies": {
"acorn": ">=8.9.0"
}
},
"node_modules/agent-base": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
@@ -2890,7 +2883,6 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -2968,12 +2960,11 @@
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz",
"integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/balanced-match": {
@@ -3280,6 +3271,18 @@
"node": ">=6"
}
},
"node_modules/code-red": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"@types/estree": "^1.0.1",
"acorn": "^8.10.0",
"estree-walker": "^3.0.3",
"periscopic": "^3.1.0"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -3401,6 +3404,18 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dependencies": {
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -3490,12 +3505,11 @@
}
},
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
"dependencies": {
"ms": "^2.1.3"
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
@@ -3642,23 +3656,22 @@
"dev": true
},
"node_modules/engine.io-client": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz",
"integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==",
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz",
"integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.0.0"
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz",
"integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==",
"engines": {
"node": ">=10.0.0"
}
@@ -4138,7 +4151,8 @@
"node_modules/esm-env": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz",
"integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA=="
"integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==",
"dev": true
},
"node_modules/esniff": {
"version": "2.0.1",
@@ -4183,16 +4197,6 @@
"node": ">=0.10"
}
},
"node_modules/esrap": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz",
"integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"@types/estree": "^1.0.1"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -4218,7 +4222,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
@@ -4959,7 +4962,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
"integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
@@ -5327,9 +5329,9 @@
}
},
"node_modules/magic-string": {
"version": "0.30.12",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
"integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==",
"version": "0.30.11",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
"integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
@@ -5473,6 +5475,11 @@
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
},
"node_modules/memoizee": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz",
@@ -5594,10 +5601,9 @@
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
@@ -5937,6 +5943,16 @@
"pbf": "bin/pbf"
}
},
"node_modules/periscopic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^3.0.0",
"is-reference": "^3.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
@@ -6844,14 +6860,14 @@
}
},
"node_modules/socket.io-client": {
"version": "4.7.5",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
"integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.5.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
@@ -6914,7 +6930,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -7161,27 +7176,28 @@
}
},
"node_modules/svelte": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.1.5.tgz",
"integrity": "sha512-AyYondx6wS0g8mmBMfwJVnOYYBswjBv6L4bc99awfbET2KozWvVwxe8NSN7fhx7Pgr7pOfOXIv7K8+Impc0OoQ==",
"version": "4.2.19",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz",
"integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@types/estree": "^1.0.5",
"acorn": "^8.12.1",
"acorn-typescript": "^1.4.13",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"esm-env": "^1.0.0",
"esrap": "^1.2.2",
"is-reference": "^3.0.2",
"@ampproject/remapping": "^2.2.1",
"@jridgewell/sourcemap-codec": "^1.4.15",
"@jridgewell/trace-mapping": "^0.3.18",
"@types/estree": "^1.0.1",
"acorn": "^8.9.0",
"aria-query": "^5.3.0",
"axobject-query": "^4.0.0",
"code-red": "^1.0.3",
"css-tree": "^2.3.1",
"estree-walker": "^3.0.3",
"is-reference": "^3.0.1",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
"zimmerframe": "^1.1.2"
"magic-string": "^0.30.4",
"periscopic": "^3.1.0"
},
"engines": {
"node": ">=18"
"node": ">=16"
}
},
"node_modules/svelte-check": {
@@ -7302,6 +7318,18 @@
"integrity": "sha512-kElJnoZrQtlkXE0O/RcKioz9NP0Sxx05j31ohyosNkydo6NOEsZB85mhoaCxOQNjxN+QPumYWfmIUsznYFjihA==",
"license": "MIT"
},
"node_modules/svelte-hmr": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
"integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
"dev": true,
"engines": {
"node": "^12.20 || ^14.13.1 || >= 16"
},
"peerDependencies": {
"svelte": "^3.19.0 || ^4.0.0"
}
},
"node_modules/svelte-i18n": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/svelte-i18n/-/svelte-i18n-4.0.1.tgz",
@@ -7793,15 +7821,6 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1"
}
},
"node_modules/svelte/node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -8401,17 +8420,12 @@
}
},
"node_modules/vitefu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.3.tgz",
"integrity": "sha512-iKKfOMBHob2WxEJbqbJjHAkmYgvFDPhuqrO82om83S8RLk+17FtyMBfcyeH8GqD0ihShtkMW/zzJgiA51hCNCQ==",
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
"integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
"dev": true,
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0-beta.0"
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"vite": {
@@ -8743,9 +8757,9 @@
"peer": true
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.1.tgz",
"integrity": "sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==",
"engines": {
"node": ">=0.4.0"
}
@@ -8806,12 +8820,6 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"license": "MIT"
}
}
}
+8 -8
View File
@@ -8,7 +8,7 @@
"build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore'",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
"check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript",
@@ -27,12 +27,12 @@
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^9.0.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.3.0",
"@sveltejs/kit": "^2.7.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@sveltejs/kit": "^2.5.18",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.4",
"@testing-library/svelte": "^5.2.0",
"@testing-library/user-event": "^14.5.2",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
@@ -55,7 +55,7 @@
"prettier-plugin-sort-json": "^4.0.0",
"prettier-plugin-svelte": "^3.2.6",
"rollup-plugin-visualizer": "^5.12.0",
"svelte": "^5.1.5",
"svelte": "^4.2.19",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
@@ -79,9 +79,9 @@
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"socket.io-client": "~4.7.5",
"socket.io-client": "^4.7.4",
"svelte-gestures": "^5.0.4",
"svelte-i18n": "^4.0.1",
"svelte-i18n": "^4.0.0",
"svelte-local-storage-store": "^0.6.4",
"svelte-maplibre": "^0.9.13",
"thumbhash": "^0.1.1"
-17
View File
@@ -1,17 +0,0 @@
import { tick } from 'svelte';
import { vi } from 'vitest';
export const getAnimateMock = () =>
vi.fn().mockImplementation(() => {
let onfinish: (() => void) | null = null;
void tick().then(() => onfinish?.());
return {
set onfinish(fn: () => void) {
onfinish = fn;
},
cancel() {
onfinish = null;
},
};
});
@@ -14,14 +14,14 @@
mdiPlay,
mdiSelectionSearch,
} from '@mdi/js';
import { type Component } from 'svelte';
import { type ComponentType } from 'svelte';
import { t } from 'svelte-i18n';
import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte';
export let title: string;
export let subtitle: string | undefined;
export let description: Component | undefined;
export let description: ComponentType | undefined;
export let jobCounts: JobCountsDto;
export let queueStatus: QueueStatusDto;
export let icon: string;
@@ -19,7 +19,7 @@
mdiTagFaces,
mdiVideo,
} from '@mdi/js';
import type { Component } from 'svelte';
import type { ComponentType } from 'svelte';
import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
@@ -30,7 +30,7 @@
interface JobDetails {
title: string;
subtitle?: string;
description?: Component;
description?: ComponentType;
allText?: string;
refreshText?: string;
missingText: string;
@@ -56,7 +56,6 @@
await handleCommand(jobId, dto);
};
// svelte-ignore reactive_declaration_non_reactive_property
$: jobDetails = <Partial<Record<JobName, JobDetails>>>{
[JobName.ThumbnailGeneration]: {
icon: mdiFileJpgBox,
@@ -343,6 +343,15 @@
subtitle={$t('admin.transcoding_advanced_options_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_tone_mapping_npl')}
desc={$t('admin.transcoding_tone_mapping_npl_description')}
bind:value={config.ffmpeg.npl}
isEdited={config.ffmpeg.npl !== savedConfig.ffmpeg.npl}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_b_frames')}
@@ -87,7 +87,6 @@
}
}
// svelte-ignore reactive_declaration_non_reactive_property
$: {
if (selectedGroupOption.id === AlbumGroupBy.None) {
groupIcon = mdiFolderRemoveOutline;
@@ -97,10 +96,8 @@
}
}
// svelte-ignore reactive_declaration_non_reactive_property
$: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin;
// svelte-ignore reactive_declaration_non_reactive_property
$: albumFilterNames = ((): Record<AlbumFilter, string> => {
return {
[AlbumFilter.All]: $t('all'),
@@ -109,7 +106,6 @@
};
})();
// svelte-ignore reactive_declaration_non_reactive_property
$: albumSortByNames = ((): Record<AlbumSortBy, string> => {
return {
[AlbumSortBy.Title]: $t('sort_title'),
@@ -121,7 +117,6 @@
};
})();
// svelte-ignore reactive_declaration_non_reactive_property
$: albumGroupByNames = ((): Record<AlbumGroupBy, string> => {
return {
[AlbumGroupBy.None]: $t('group_no'),
@@ -135,7 +135,6 @@
let isOpen = false;
// Step 1: Filter between Owned and Shared albums, or both.
// svelte-ignore reactive_declaration_non_reactive_property
$: {
switch (userSettings.filter) {
case AlbumFilter.Owned: {
@@ -13,7 +13,7 @@
$albumViewSettings.sortOrder = option.defaultOrder;
}
};
// svelte-ignore reactive_declaration_non_reactive_property
$: albumSortByNames = ((): Record<AlbumSortBy, string> => {
return {
[AlbumSortBy.Title]: $t('sort_title'),
@@ -293,7 +293,7 @@
class="h-[18px] {disabled
? 'cursor-not-allowed'
: ''} w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
></textarea>
/>
</div>
{#if isSendingMessage}
<div class="flex items-end place-items-center pb-2 ml-0">
@@ -15,31 +15,13 @@ describe('AssetViewerNavBar component', () => {
showShareButton: false,
onZoomImage: () => {},
onCopyImage: () => {},
onAction: () => {},
onRunJob: () => {},
onPlaySlideshow: () => {},
onShowDetail: () => {},
onClose: () => {},
};
beforeAll(() => {
Element.prototype.animate = vi.fn().mockImplementation(() => ({
cancel: () => {},
}));
vi.stubGlobal(
'ResizeObserver',
vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
);
});
afterEach(() => {
vi.resetAllMocks();
resetSavedUser();
});
afterAll(() => {
vi.restoreAllMocks();
});
it('shows back button', () => {
const asset = assetFactory.build({ isTrashed: false });
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
@@ -61,7 +61,6 @@
const sharedLink = getSharedLink();
$: isOwner = $user && asset.ownerId === $user?.id;
// svelte-ignore reactive_declaration_non_reactive_property
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
// $: showEditorButton =
// isOwner &&
@@ -598,7 +598,7 @@
{#if stackedAsset.id == asset.id}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-[2px]"></div>
<div class="w-2 h-2 bg-white rounded-full flex mt-[2px]" />
</div>
{/if}
</div>
@@ -154,7 +154,7 @@
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>
{#if $user?.isAdmin}
{$t('admin.asset_offline_description')}
<p>{$t('admin.asset_offline_description')}</p>
{:else}
{$t('asset_offline_description')}
{/if}
@@ -345,45 +345,43 @@
</Portal>
{/if}
<div class="flex gap-4 py-4">
<div><Icon path={mdiImageOutline} size="24" /></div>
{#if asset.exifInfo?.fileSizeInByte}
<div class="flex gap-4 py-4">
<div><Icon path={mdiImageOutline} size="24" /></div>
<div>
<p class="break-all flex place-items-center gap-2">
{asset.originalFileName}
{#if isOwner}
<CircleIconButton
icon={mdiInformationOutline}
title={$t('show_file_location')}
size="16"
padding="2"
on:click={toggleAssetPath}
/>
{/if}
</p>
{#if showAssetPath}
<p class="text-xs opacity-50 break-all pb-2" transition:slide={{ duration: 250 }}>
{asset.originalPath}
<div>
<p class="break-all flex place-items-center gap-2">
{asset.originalFileName}
{#if isOwner}
<CircleIconButton
icon={mdiInformationOutline}
title={$t('show_file_location')}
size="16"
padding="2"
on:click={toggleAssetPath}
/>
{/if}
</p>
{/if}
{#if (asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth) || asset.exifInfo?.fileSizeInByte}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth}
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
{/if}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
</div>
{/if}
{#if showAssetPath}
<p class="text-xs opacity-50 break-all" transition:slide={{ duration: 250 }}>
{asset.originalPath}
</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber}
<div class="flex gap-4 py-4">

Some files were not shown because too many files have changed in this diff Show More