Compare commits

..

4 Commits

Author SHA1 Message Date
izzy
452943f342 chore: type fix
Signed-off-by: izzy <me@insrt.uk>
2026-01-29 16:15:48 +00:00
izzy
87f389afb4 refactor: no need to restore database since it's not technically possible
chore: late fallback for username in parameter builder

Signed-off-by: izzy <me@insrt.uk>
2026-01-29 16:15:06 +00:00
izzy
ab2036f289 chore: add db switch back but with comments
Signed-off-by: izzy <me@insrt.uk>
2026-01-29 16:10:52 +00:00
izzy
9208512648 fix(server): use provided database name/username for restore & ensure name is not mangled
fixes #25633

Signed-off-by: izzy <me@insrt.uk>
2026-01-29 15:59:32 +00:00
53 changed files with 151 additions and 1471 deletions

View File

@@ -1,6 +1,6 @@
[tools]
terragrunt = "0.98.0"
opentofu = "1.11.4"
opentofu = "1.10.7"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"

View File

@@ -88,7 +88,7 @@ The easiest option is to have both extensions installed during the migration:
<details>
<summary>Migration steps (automatic)</summary>
1. Ensure you still have pgvecto.rs installed
2. Install `pgvector` (`>= 0.7, < 0.9`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`)
2. Install `pgvector` (`>= 0.7.0, < 1.0.0`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`)
3. [Install VectorChord][vchord-install]
4. Add `shared_preload_libraries= 'vchord.so, vectors.so'` to your `postgresql.conf`, making sure to include _both_ `vchord.so` and `vectors.so`. You may include other libraries here as well if needed
5. Restart the Postgres database

View File

@@ -18,8 +18,8 @@ node = "24.13.0"
flutter = "3.35.7"
pnpm = "10.28.0"
terragrunt = "0.98.0"
opentofu = "1.11.4"
java = "21.0.2"
opentofu = "1.10.7"
java = "25.0.1"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"

3
mobile/.fvmrc Normal file
View File

@@ -0,0 +1,3 @@
{
"flutter": "3.35.7"
}

5
mobile/.gitignore vendored
View File

@@ -55,5 +55,8 @@ default.isar
default.isar.lock
libisar.so
# FVM Version
.fvm/
# Translation file
lib/generated/
lib/generated/

View File

@@ -2,9 +2,7 @@
"dart.flutterSdkPath": ".fvm/versions/3.35.7",
"dart.lineLength": 120,
"[dart]": {
"editor.rulers": [
120
]
"editor.rulers": [120]
},
"search.exclude": {
"**/.fvm": true

View File

@@ -4,12 +4,10 @@ The Immich mobile app is a Flutter-based solution leveraging the Isar Database f
## Setup
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
2. Change to the immich directory and trust the mise config with `mise trust`.
3. Install tools with mise: `mise install`.
4. Run `flutter pub get` to install the dependencies.
5. Run `make translation` to generate the translation file.
6. Run `flutter run` to start the app.
1. Setup Flutter toolchain using FVM.
2. Run `flutter pub get` to install the dependencies.
3. Run `make translation` to generate the translation file.
4. Run `fvm flutter run` to start the app.
## Translation
@@ -31,7 +29,7 @@ dcm analyze lib
```
[DCM](https://dcm.dev/) is a vendor tool that needs to be downloaded manually to run locally.
Immich was provided an open source license.
Immich was provided an open source license.
To use it, it is important that you do not have an active free tier license (can be verified with `dcm license`).
If you have write-access to the Immich repository directly, running dcm in your clone should just work.
If you are working on a clone of a fork, you need to connect to the main Immich repository as remote first:

View File

@@ -20,7 +20,7 @@ enum VersionStatus {
class ServerInfo {
final ServerVersion serverVersion;
final ServerVersion? latestVersion;
final ServerVersion latestVersion;
final ServerFeatures serverFeatures;
final ServerConfig serverConfig;
final ServerDiskInfo serverDiskInfo;

View File

@@ -15,7 +15,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
: super(
const ServerInfo(
serverVersion: ServerVersion(major: 0, minor: 0, patch: 0),
latestVersion: null,
latestVersion: ServerVersion(major: 0, minor: 0, patch: 0),
serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true),
serverConfig: ServerConfig(
trashDays: 30,
@@ -43,7 +43,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
try {
final serverVersion = await _serverInfoService.getServerVersion();
// using isClientOutOfDate since that will show to users regardless of if they are an admin
// using isClientOutOfDate since that will show to users reguardless of if they are an admin
if (serverVersion == null) {
state = state.copyWith(versionStatus: VersionStatus.error);
return;
@@ -76,7 +76,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
state = state.copyWith(versionStatus: VersionStatus.upToDate);
}
handleReleaseInfo(ServerVersion serverVersion, ServerVersion? latestVersion) {
handleReleaseInfo(ServerVersion serverVersion, ServerVersion latestVersion) {
// Update local server version
_checkServerVersionMismatch(serverVersion, latestVersion: latestVersion);
}

View File

@@ -170,52 +170,50 @@ class AppBarServerInfo extends HookConsumerWidget {
),
],
),
if (serverInfoState.latestVersion != null) ...[
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Row(
children: [
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
const Padding(
padding: EdgeInsets.only(right: 5.0),
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12),
),
Text(
"latest_version".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Row(
children: [
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
const Padding(
padding: EdgeInsets.only(right: 5.0),
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12),
),
Text(
"latest_version".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
],
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
serverInfoState.latestVersion!.major > 0
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
style: TextStyle(
fontSize: contentFontSize,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
),
],
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
serverInfoState.latestVersion.major > 0
? "${serverInfoState.latestVersion.major}.${serverInfoState.latestVersion.minor}.${serverInfoState.latestVersion.patch}"
: "--",
style: TextStyle(
fontSize: contentFontSize,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
],
),
],
),
),

View File

@@ -414,7 +414,6 @@ class LoginForm extends HookConsumerWidget {
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
autoCorrect: false,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
),
),

View File

@@ -12,7 +12,6 @@ class ImmichTextInput extends StatefulWidget {
final List<String>? autofillHints;
final Widget? suffixIcon;
final bool obscureText;
final bool autoCorrect;
const ImmichTextInput({
super.key,
@@ -27,7 +26,6 @@ class ImmichTextInput extends StatefulWidget {
this.autofillHints,
this.suffixIcon,
this.obscureText = false,
this.autoCorrect = true,
});
@override
@@ -81,7 +79,6 @@ class _ImmichTextInputState extends State<ImmichTextInput> {
validator: _validateInput,
keyboardType: widget.keyboardType,
textInputAction: widget.keyboardAction,
autocorrect: widget.autoCorrect,
autofillHints: widget.autofillHints,
onTap: () => setState(() => _error = null),
onTapOutside: (_) => _focusNode.unfocus(),

103
pnpm-lock.yaml generated
View File

@@ -9,7 +9,7 @@ overrides:
canvas: 2.11.2
sharp: ^0.34.5
packageExtensionsChecksum: sha256-vheqqqBU5SU8N8ma3OjzLM07nd511Xmy+mOvgxie+Ts=
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
@@ -421,9 +421,6 @@ importers:
bcrypt:
specifier: ^6.0.0
version: 6.0.0
better-sqlite3:
specifier: ^12.6.2
version: 12.6.2
body-parser:
specifier: ^2.2.0
version: 2.2.2
@@ -608,9 +605,6 @@ importers:
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/body-parser':
specifier: ^1.19.6
version: 1.19.6
@@ -5086,9 +5080,6 @@ packages:
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -6061,10 +6052,6 @@ packages:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
better-sqlite3@12.6.2:
resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@@ -6072,9 +6059,6 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bits-ui@2.14.4:
resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==}
engines: {node: '>=20'}
@@ -7601,10 +7585,6 @@ packages:
resolution: {integrity: sha512-CpNH1FAhIQG5AlKndlTf05mNbuFxINyzG9629ZI/CKwr+39zWo8swxpnXc3GUfUvUfxkCCxumDPy2QVmi3XJkQ==}
engines: {node: '>=20.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -7720,9 +7700,6 @@ packages:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
engines: {node: '>=20'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -7923,9 +7900,6 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
github-slugger@1.5.0:
resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==}
@@ -9557,9 +9531,6 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
@@ -9622,10 +9593,6 @@ packages:
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-abi@3.87.0:
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -10572,11 +10539,6 @@ packages:
potpack@2.1.0:
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -11283,9 +11245,6 @@ packages:
simple-get@3.1.1:
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-icons@15.22.0:
resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==}
engines: {node: '>=0.12.18'}
@@ -11982,9 +11941,6 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
@@ -17899,10 +17855,6 @@ snapshots:
dependencies:
'@types/node': 24.10.9
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 24.10.9
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -19087,23 +19039,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
better-sqlite3@12.6.2:
dependencies:
bindings: 1.5.0
node-addon-api: 8.5.0
node-gyp: 12.1.0
prebuild-install: 7.1.3
transitivePeerDependencies:
- supports-color
big.js@5.2.2: {}
binary-extensions@2.3.0: {}
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
dependencies:
'@floating-ui/core': 1.7.3
@@ -20869,8 +20808,6 @@ snapshots:
optionalDependencies:
exiftool-vendored.exe: 13.45.0
expand-template@2.0.3: {}
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
@@ -21048,8 +20985,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
file-uri-to-path@1.0.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -21266,8 +21201,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0: {}
github-slugger@1.5.0: {}
gl-matrix@3.4.4: {}
@@ -23383,8 +23316,6 @@ snapshots:
nanoid@5.1.6: {}
napi-build-utils@2.0.0: {}
natural-compare-lite@1.4.0: {}
natural-compare@1.4.0: {}
@@ -23449,10 +23380,6 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
node-abi@3.87.0:
dependencies:
semver: 7.7.3
node-abort-controller@3.1.1: {}
node-addon-api@4.3.0: {}
@@ -24441,21 +24368,6 @@ snapshots:
potpack@2.1.0: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.87.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
@@ -25392,7 +25304,8 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-concat@1.0.1:
optional: true
simple-get@3.1.1:
dependencies:
@@ -25401,12 +25314,6 @@ snapshots:
simple-concat: 1.0.1
optional: true
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-icons@15.22.0: {}
simple-icons@16.4.0: {}
@@ -26243,10 +26150,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
tweetnacl@0.14.5: {}
type-check@0.4.0:

View File

@@ -29,7 +29,6 @@ onlyBuiltDependencies:
- sharp
- '@tailwindcss/oxide'
- bcrypt
- better-sqlite3
overrides:
canvas: 2.11.2
sharp: ^0.34.5
@@ -60,10 +59,6 @@ packageExtensions:
dependencies:
node-addon-api: '*'
node-gyp: '*'
better-sqlite3:
dependencies:
node-addon-api: '*'
node-gyp: '*'
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true

View File

@@ -6,4 +6,4 @@ if [[ "$IMMICH_ENV" == "production" ]]; then
fi
cd /usr/src/app || exit
exec pnpm --filter immich exec nest start --no-shell --debug "0.0.0.0:9230" --watch -- "$@"
pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"

View File

@@ -61,7 +61,6 @@
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"body-parser": "^2.2.0",
"bullmq": "^5.51.0",
"chokidar": "^4.0.3",
@@ -125,7 +124,6 @@
"@types/archiver": "^7.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/body-parser": "^1.19.6",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",

View File

@@ -89,10 +89,8 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
}
async onModuleDestroy() {
console.log(`[${this.worker}] onModuleDestroy called - emitting AppShutdown`);
await this.eventRepository.emit('AppShutdown');
await teardownTelemetry();
console.log(`[${this.worker}] onModuleDestroy complete`);
}
}
@@ -150,8 +148,6 @@ export class ImmichAdminModule implements OnModuleDestroy {
constructor(private service: CliService) {}
async onModuleDestroy() {
console.log('[ImmichAdmin] onModuleDestroy called');
await this.service.cleanup();
console.log('[ImmichAdmin] onModuleDestroy complete');
}
}

View File

@@ -6,10 +6,6 @@ import {
PromptConfirmMoveQuestions,
PromptMediaLocationQuestions,
} from 'src/commands/media-location.command';
import {
MigrateThumbnailsCommand,
PromptConfirmMigrationQuestion,
} from 'src/commands/migrate-thumbnails.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
@@ -32,6 +28,4 @@ export const commandsAndQuestions = [
ChangeMediaLocationCommand,
PromptMediaLocationQuestions,
PromptConfirmMoveQuestions,
MigrateThumbnailsCommand,
PromptConfirmMigrationQuestion,
];

View File

@@ -1,88 +0,0 @@
import { Command, CommandRunner, InquirerService, Option, Question, QuestionSet } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
@Command({
name: 'migrate-thumbnails-to-sqlite',
description: 'Migrate thumbnails from filesystem to SQLite storage',
})
export class MigrateThumbnailsCommand extends CommandRunner {
constructor(
private service: CliService,
private inquirer: InquirerService,
) {
super();
}
@Option({
flags: '-p, --path <path>',
description: 'Absolute path to the SQLite database file',
})
parsePath(value: string) {
return value;
}
@Option({
flags: '-y, --yes',
description: 'Skip confirmation prompt',
})
parseYes() {
return true;
}
async run(passedParams: string[], options: { path?: string; yes?: boolean }): Promise<void> {
try {
const sqlitePath = options.path ?? this.service.getDefaultThumbnailStoragePath();
console.log(`\nMigration settings:`);
console.log(` SQLite path: ${sqlitePath}`);
console.log(`\nThis will read all thumbnail files from the filesystem and store them in SQLite.`);
console.log(`Existing entries in SQLite will be skipped.\n`);
if (!options.yes) {
const { confirmed } = await this.inquirer.ask<{ confirmed: boolean }>('prompt-confirm-migration', {});
if (!confirmed) {
console.log('Migration cancelled.');
return;
}
}
console.log('\nStarting migration...\n');
let lastProgressUpdate = 0;
const result = await this.service.migrateThumbnailsToSqlite({
sqlitePath,
onProgress: ({ current, migrated, skipped, errors }) => {
const now = Date.now();
if (now - lastProgressUpdate > 500 || current === 1) {
lastProgressUpdate = now;
process.stdout.write(
`\rProcessed: ${current} | Migrated: ${migrated} | Skipped: ${skipped} | Errors: ${errors}`,
);
}
},
});
console.log(`\n\nMigration complete!`);
console.log(` Total processed: ${result.total}`);
console.log(` Migrated: ${result.migrated}`);
console.log(` Skipped: ${result.skipped}`);
console.log(` Errors: ${result.errors}`);
} catch (error) {
console.error(error);
console.error('Migration failed.');
}
}
}
@QuestionSet({ name: 'prompt-confirm-migration' })
export class PromptConfirmMigrationQuestion {
@Question({
message: 'Do you want to proceed with the migration? [Y/n]',
name: 'confirmed',
})
parseConfirmed(value: string): boolean {
return ['yes', 'y', ''].includes((value || 'y').toLowerCase());
}
}

View File

@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types';
import { ImmichBufferResponse, ImmichFileResponse, sendBuffer, sendFile } from 'src/utils/file';
import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets)
@@ -163,29 +163,26 @@ export class AssetMediaController {
if (viewThumbnailRes instanceof ImmichFileResponse) {
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
return;
}
if (viewThumbnailRes instanceof ImmichBufferResponse) {
await sendBuffer(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
return;
}
const { targetSize } = viewThumbnailRes;
const [reqPath, reqSearch] = req.url.split('?');
let redirPath: string;
const redirSearchParams = new URLSearchParams(reqSearch);
if (targetSize === 'original') {
redirPath = 'original';
redirSearchParams.delete('size');
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
redirPath = reqPath;
redirSearchParams.set('size', targetSize);
} else {
throw new Error('Invalid targetSize: ' + targetSize);
// viewThumbnailRes is a AssetMediaRedirectResponse
// which redirects to the original asset or a specific size to make better use of caching
const { targetSize } = viewThumbnailRes;
const [reqPath, reqSearch] = req.url.split('?');
let redirPath: string;
const redirSearchParams = new URLSearchParams(reqSearch);
if (targetSize === 'original') {
// relative path to this.downloadAsset
redirPath = 'original';
redirSearchParams.delete('size');
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
redirPath = reqPath;
redirSearchParams.set('size', targetSize);
} else {
throw new Error('Invalid targetSize: ' + targetSize);
}
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
return res.redirect(finalRedirPath);
}
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
return res.redirect(finalRedirPath);
}
@Get(':id/video/playback')

View File

@@ -120,10 +120,6 @@ export class StorageCore {
);
}
static getThumbnailStoragePath(): string {
return join(StorageCore.getMediaLocation(), 'thumbnails.sqlite3');
}
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
}

View File

@@ -140,10 +140,6 @@ export class EnvDto {
@Optional()
IMMICH_WORKERS_EXCLUDE?: string;
@Optional()
@Matches(/^\//, { message: 'IMMICH_THUMBNAIL_SQLITE_PATH must be an absolute path' })
IMMICH_THUMBNAIL_SQLITE_PATH?: string;
@IsString()
@Optional()
DB_DATABASE_NAME?: string;

View File

@@ -25,16 +25,10 @@ class Workers {
*/
restarting = false;
/**
* Whether we're in graceful shutdown
*/
shuttingDown = false;
/**
* Boot all enabled workers
*/
async bootstrap() {
this.setupSignalHandlers();
const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv();
@@ -120,12 +114,7 @@ class Workers {
} else {
const worker = new Worker(workerFile);
kill = () => {
// Post a shutdown message to allow graceful cleanup
worker.postMessage({ type: 'shutdown' });
// Force terminate after timeout if worker doesn't exit
setTimeout(() => void worker.terminate(), 5000);
};
kill = async () => void (await worker.terminate());
anyWorker = worker;
}
@@ -135,53 +124,17 @@ class Workers {
this.workers[name] = { kill };
}
private setupSignalHandlers() {
const shutdown = async (signal: NodeJS.Signals) => {
if (this.shuttingDown) {
return;
}
this.shuttingDown = true;
console.log(`Received ${signal}, initiating graceful shutdown...`);
const workerNames = Object.keys(this.workers) as ImmichWorker[];
for (const name of workerNames) {
console.log(`Sending ${signal} to ${name} worker`);
await this.workers[name]?.kill(signal);
}
// Give workers time to shutdown gracefully
setTimeout(() => {
console.log('Shutdown timeout reached, forcing exit');
process.exit(0);
}, 10_000);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
}
onError(name: ImmichWorker, error: Error) {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}
onExit(name: ImmichWorker, exitCode: number | null) {
console.log(`${name} worker exited with code ${exitCode}`);
delete this.workers[name];
// graceful shutdown in progress
if (this.shuttingDown) {
if (Object.keys(this.workers).length === 0) {
console.log('All workers have exited, shutting down main process');
process.exit(0);
}
return;
}
// restart immich server
if (exitCode === ExitCode.AppRestart || this.restarting) {
this.restarting = true;
console.info(`${name} worker shutdown for restart`);
delete this.workers[name];
// once all workers shut down, bootstrap again
if (Object.keys(this.workers).length === 0) {
@@ -192,9 +145,11 @@ class Workers {
return;
}
// unexpected exit - shutdown the entire process
// shutdown the entire process
delete this.workers[name];
if (exitCode !== 0) {
console.error(`${name} worker exited unexpectedly`);
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Killing api process');

View File

@@ -423,13 +423,4 @@ export class AssetJobRepository {
streamForMigrationJob() {
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();
}
@GenerateSql({ params: [], stream: true })
streamAllThumbnailFiles() {
return this.db
.selectFrom('asset_file')
.select(['asset_file.assetId', 'asset_file.type', 'asset_file.path', 'asset_file.isEdited'])
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview])
.stream();
}
}

View File

@@ -107,10 +107,6 @@ export interface EnvData {
mediaLocation?: string;
};
thumbnailStorage: {
sqlitePath?: string;
};
workers: ImmichWorker[];
plugins: {
@@ -335,10 +331,6 @@ const getEnv = (): EnvData => {
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
},
thumbnailStorage: {
sqlitePath: dto.IMMICH_THUMBNAIL_SQLITE_PATH,
},
telemetry: {
apiPort: dto.IMMICH_API_METRICS_PORT || 8081,
microservicesPort: dto.IMMICH_MICROSERVICES_METRICS_PORT || 8082,

View File

@@ -44,7 +44,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -99,7 +98,6 @@ export const repositories = [
SystemMetadataRepository,
TagRepository,
TelemetryRepository,
ThumbnailStorageRepository,
TrashRepository,
UserRepository,
ViewRepository,

View File

@@ -2,7 +2,7 @@ import sharp from 'sharp';
import { AssetFace } from 'src/database';
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ImageFormat, SourceType } from 'src/enum';
import { SourceType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { MediaRepository } from 'src/repositories/media.repository';
@@ -664,51 +664,4 @@ describe(MediaRepository.name, () => {
});
});
});
describe('generateThumbnailToBuffer', () => {
it('should return a buffer instead of writing to file', async () => {
const inputBuffer = await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.png()
.toBuffer();
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
format: ImageFormat.Webp,
quality: 80,
size: 50,
colorspace: 'srgb',
processInvalidImages: false,
});
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBeGreaterThan(0);
const metadata = await sharp(result).metadata();
expect(metadata.format).toBe('webp');
expect(metadata.width).toBeLessThanOrEqual(50);
});
it('should apply same options as generateThumbnail', async () => {
const inputBuffer = await sharp({
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
})
.png()
.toBuffer();
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
format: ImageFormat.Jpeg,
quality: 90,
size: 75,
colorspace: 'srgb',
processInvalidImages: false,
progressive: true,
});
const metadata = await sharp(result).metadata();
expect(metadata.format).toBe('jpeg');
expect(metadata.width).toBe(75);
expect(metadata.height).toBe(75);
});
});
});

View File

@@ -182,17 +182,6 @@ export class MediaRepository {
await decoded.toFile(output);
}
async generateThumbnailToBuffer(input: string | Buffer, options: GenerateThumbnailOptions): Promise<Buffer> {
const pipeline = await this.getImageDecodingPipeline(input, options);
return pipeline
.toFormat(options.format, {
quality: options.quality,
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive,
})
.toBuffer();
}
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
let pipeline = sharp(input, {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes

View File

@@ -1,305 +0,0 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AssetFileType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { automock } from 'test/utils';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe(ThumbnailStorageRepository.name, () => {
let sut: ThumbnailStorageRepository;
let testDbPath: string;
beforeEach(() => {
testDbPath = join(tmpdir(), `immich-test-thumbnails-${randomUUID()}.db`);
const logger = automock(LoggingRepository, { args: [undefined, { getEnv: () => ({}) }], strict: false });
sut = new ThumbnailStorageRepository(logger);
});
afterEach(() => {
sut.close();
if (existsSync(testDbPath)) {
rmSync(testDbPath);
}
});
describe('initialize', () => {
it('should create database and schema', () => {
sut.initialize(testDbPath);
expect(sut.isEnabled()).toBe(true);
const db = new Database(testDbPath, { readonly: true });
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='thumbnails'").all();
db.close();
expect(tables).toHaveLength(1);
});
it('should enable WAL mode', () => {
sut.initialize(testDbPath);
const db = new Database(testDbPath, { readonly: true });
const result = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
db.close();
expect(result.journal_mode).toBe('wal');
});
});
describe('store', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(() => {
sut.initialize(testDbPath);
});
it('should store thumbnail data', async () => {
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
expect(result!.mimeType).toBe(mimeType);
expect(result!.size).toBe(testData.length);
});
it('should replace existing thumbnail on conflict', async () => {
const newData = Buffer.from('updated thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: newData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result!.data).toEqual(newData);
expect(result!.size).toBe(newData.length);
});
it('should store with correct mime type and size', async () => {
const jpegData = Buffer.from('jpeg thumbnail data with different size');
const jpegMimeType = 'image/jpeg';
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: jpegData,
mimeType: jpegMimeType,
});
const result = await sut.get(assetId, AssetFileType.Preview, false);
expect(result!.mimeType).toBe(jpegMimeType);
expect(result!.size).toBe(jpegData.length);
});
});
describe('get', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
});
it('should return stored thumbnail data', async () => {
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
expect(result!.mimeType).toBe(mimeType);
});
it('should return null for non-existent thumbnail', async () => {
const result = await sut.get(randomUUID(), AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
it('should distinguish between edited and non-edited', async () => {
const editedData = Buffer.from('edited thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const nonEditedResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
const editedResult = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(nonEditedResult!.data).toEqual(testData);
expect(editedResult!.data).toEqual(editedData);
});
it('should distinguish between different thumbnail types', async () => {
const previewData = Buffer.from('preview data');
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: previewData,
mimeType,
});
const thumbnailResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
expect(thumbnailResult!.data).toEqual(testData);
expect(previewResult!.data).toEqual(previewData);
});
it('should fall back to non-edited when edited is requested but not found', async () => {
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
});
it('should return edited when both exist and edited is requested', async () => {
const editedData = Buffer.from('edited thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(result!.data).toEqual(editedData);
});
it('should not fall back to edited when non-edited is requested but not found', async () => {
const newAssetId = randomUUID();
const editedData = Buffer.from('edited only data');
await sut.store({
assetId: newAssetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const result = await sut.get(newAssetId, AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
});
describe('delete', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: testData,
mimeType,
});
});
it('should delete specific thumbnail', async () => {
await sut.delete(assetId, AssetFileType.Thumbnail, false);
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
it('should not affect other thumbnails for same asset', async () => {
await sut.delete(assetId, AssetFileType.Thumbnail, false);
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
expect(previewResult).not.toBeNull();
});
});
describe('deleteByAsset', () => {
const assetId = randomUUID();
const otherAssetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
await sut.store({ assetId, type: AssetFileType.Preview, isEdited: false, data: testData, mimeType });
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: true, data: testData, mimeType });
await sut.store({ assetId: otherAssetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
});
it('should delete all thumbnails for an asset', async () => {
await sut.deleteByAsset(assetId);
expect(await sut.get(assetId, AssetFileType.Thumbnail, false)).toBeNull();
expect(await sut.get(assetId, AssetFileType.Preview, false)).toBeNull();
expect(await sut.get(assetId, AssetFileType.Thumbnail, true)).toBeNull();
});
it('should not affect other assets', async () => {
await sut.deleteByAsset(assetId);
const otherAssetResult = await sut.get(otherAssetId, AssetFileType.Thumbnail, false);
expect(otherAssetResult).not.toBeNull();
});
});
describe('isEnabled', () => {
it('should return false when database is not initialized', () => {
expect(sut.isEnabled()).toBe(false);
});
it('should return true when database is initialized', () => {
sut.initialize(testDbPath);
expect(sut.isEnabled()).toBe(true);
});
});
});

View File

@@ -1,160 +0,0 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Database, { Database as DatabaseType, Statement } from 'better-sqlite3';
import { AssetFileType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
export interface ThumbnailData {
assetId: string;
type: AssetFileType;
isEdited: boolean;
data: Buffer;
mimeType: string;
}
export interface ThumbnailResult {
data: Buffer;
mimeType: string;
size: number;
}
interface ThumbnailRow {
data: Buffer;
mime_type: string;
size: number;
}
@Injectable()
export class ThumbnailStorageRepository implements OnModuleDestroy {
private database: DatabaseType | null = null;
private insertStatement: Statement | null = null;
private selectStatement: Statement | null = null;
private deleteStatement: Statement | null = null;
private deleteByAssetStatement: Statement | null = null;
constructor(private logger: LoggingRepository) {
this.logger.setContext(ThumbnailStorageRepository.name);
}
initialize(databasePath: string): void {
this.database = new Database(databasePath);
this.database.pragma('page_size = 32768');
this.database.pragma('journal_mode = WAL');
this.database.pragma('synchronous = NORMAL');
this.database.pragma('cache_size = -131072');
this.database.pragma('mmap_size = 2147483648');
this.database.pragma('temp_store = MEMORY');
this.database.pragma('wal_autocheckpoint = 10000');
this.database.exec(`
CREATE TABLE IF NOT EXISTS thumbnails (
asset_id TEXT NOT NULL,
type TEXT NOT NULL,
is_edited INTEGER NOT NULL DEFAULT 0,
data BLOB NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
PRIMARY KEY (asset_id, type, is_edited)
)
`);
this.insertStatement = this.database.prepare(`
INSERT OR REPLACE INTO thumbnails (asset_id, type, is_edited, data, mime_type, size)
VALUES (?, ?, ?, ?, ?, ?)
`);
this.selectStatement = this.database.prepare(`
SELECT data, mime_type, size FROM thumbnails
WHERE asset_id = ? AND type = ? AND is_edited = ?
`);
this.deleteStatement = this.database.prepare(`
DELETE FROM thumbnails WHERE asset_id = ? AND type = ? AND is_edited = ?
`);
this.deleteByAssetStatement = this.database.prepare(`
DELETE FROM thumbnails WHERE asset_id = ?
`);
this.logger.log(`SQLite thumbnail storage initialized at ${databasePath}`);
}
isEnabled(): boolean {
return this.database !== null;
}
store(thumbnail: ThumbnailData): void {
if (!this.insertStatement) {
throw new Error('SQLite thumbnail storage not initialized');
}
const isEditedInt = thumbnail.isEdited ? 1 : 0;
this.insertStatement.run(
thumbnail.assetId,
thumbnail.type,
isEditedInt,
thumbnail.data,
thumbnail.mimeType,
thumbnail.data.length,
);
}
get(assetId: string, type: AssetFileType, isEdited: boolean): ThumbnailResult | null {
if (!this.selectStatement) {
return null;
}
const isEditedInt = isEdited ? 1 : 0;
let result = this.selectStatement.get(assetId, type, isEditedInt) as ThumbnailRow | undefined;
if (!result && isEdited) {
result = this.selectStatement.get(assetId, type, 0) as ThumbnailRow | undefined;
}
if (!result) {
return null;
}
return {
data: result.data,
mimeType: result.mime_type,
size: result.size,
};
}
delete(assetId: string, type: AssetFileType, isEdited: boolean): void {
if (!this.deleteStatement) {
return;
}
const isEditedInt = isEdited ? 1 : 0;
this.deleteStatement.run(assetId, type, isEditedInt);
}
deleteByAsset(assetId: string): void {
if (!this.deleteByAssetStatement) {
return;
}
this.deleteByAssetStatement.run(assetId);
}
close(): void {
if (this.database) {
this.logger.log('Closing SQLite thumbnail storage database');
this.database.pragma('wal_checkpoint(TRUNCATE)');
this.database.close();
this.database = null;
this.insertStatement = null;
this.selectStatement = null;
this.deleteStatement = null;
this.deleteByAssetStatement = null;
this.logger.log('SQLite thumbnail storage database closed');
}
}
onModuleDestroy(): void {
this.logger.log('onModuleDestroy called - closing SQLite thumbnail storage');
this.close();
}
}

View File

@@ -14,7 +14,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadBody } from 'src/types';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { ImmichBufferResponse, ImmichFileResponse } from 'src/utils/file';
import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
@@ -762,128 +762,6 @@ describe(AssetMediaService.name, () => {
);
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
});
describe('with SQLite storage', () => {
it('should return ImmichBufferResponse when thumbnail exists in SQLite', async () => {
const thumbnailData = Buffer.from('thumbnail-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: thumbnailData,
mimeType: 'image/webp',
size: thumbnailData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichBufferResponse({
data: thumbnailData,
contentType: 'image/webp',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_thumbnail.webp',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
expect(mocks.asset.getForThumbnail).not.toHaveBeenCalled();
});
it('should fall back to filesystem when thumbnail not in SQLite', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue(null);
mocks.asset.getForThumbnail.mockResolvedValue({
...assetStub.image,
path: '/uploads/user-id/thumbs/path.jpg',
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/thumbs/path.jpg',
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
fileName: 'asset-id_thumbnail.jpg',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
});
it('should handle edited thumbnails from SQLite', async () => {
const thumbnailData = Buffer.from('edited-thumbnail-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: thumbnailData,
mimeType: 'image/webp',
size: thumbnailData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }),
).resolves.toEqual(
new ImmichBufferResponse({
data: thumbnailData,
contentType: 'image/webp',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_thumbnail.webp',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
});
it('should handle preview size from SQLite', async () => {
const previewData = Buffer.from('preview-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: previewData,
mimeType: 'image/jpeg',
size: previewData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
).resolves.toEqual(
new ImmichBufferResponse({
data: previewData,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_preview.jpg',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Preview, false);
});
it('should skip SQLite lookup when storage is disabled', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
mocks.asset.getForThumbnail.mockResolvedValue({
...assetStub.image,
path: '/uploads/user-id/thumbs/path.jpg',
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/thumbs/path.jpg',
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
fileName: 'asset-id_thumbnail.jpg',
}),
);
expect(mocks.thumbnailStorage.get).not.toHaveBeenCalled();
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
});
});
});
describe('playbackVideo', () => {

View File

@@ -37,12 +37,7 @@ import { UploadFile, UploadRequest } from 'src/types';
import { requireUploadAccess } from 'src/utils/access';
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import {
getFilenameExtension,
getFileNameWithoutExtension,
ImmichBufferResponse,
ImmichFileResponse,
} from 'src/utils/file';
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
@@ -224,7 +219,7 @@ export class AssetMediaService extends BaseService {
auth: AuthDto,
id: string,
dto: AssetMediaOptionsDto,
): Promise<ImmichFileResponse | ImmichBufferResponse | AssetMediaRedirectResponse> {
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
if (dto.size === AssetMediaSize.Original) {
@@ -236,30 +231,20 @@ export class AssetMediaService extends BaseService {
}
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
const isEdited = dto.edited ?? false;
if (this.thumbnailStorageRepository.isEnabled()) {
const thumbnail = this.thumbnailStorageRepository.get(id, size, isEdited);
if (thumbnail) {
// this.logger.log(`Thumbnail served from SQLite: assetId=${id}, type=${size}`);
const extension = mimeTypes.toExtension(thumbnail.mimeType) || '';
const fileName = `${id}_${size}${extension}`;
return new ImmichBufferResponse({
data: thumbnail.data,
contentType: thumbnail.mimeType,
cacheControl: CacheControl.PrivateWithCache,
fileName,
});
}
}
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(id, size, isEdited);
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
id,
size,
dto.edited ?? false,
);
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) {
// use original file for web supported images
return { targetSize: 'original' };
}
if (dto.size === AssetMediaSize.FULLSIZE && !path) {
// downgrade to preview if fullsize is not available.
// e.g. disabled or not yet (re)generated
return { targetSize: AssetMediaSize.PREVIEW };
}

View File

@@ -51,7 +51,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -109,7 +108,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
SystemMetadataRepository,
TagRepository,
TelemetryRepository,
ThumbnailStorageRepository,
TrashRepository,
UserRepository,
VersionHistoryRepository,
@@ -169,7 +167,6 @@ export class BaseService {
protected systemMetadataRepository: SystemMetadataRepository,
protected tagRepository: TagRepository,
protected telemetryRepository: TelemetryRepository,
protected thumbnailStorageRepository: ThumbnailStorageRepository,
protected trashRepository: TrashRepository,
protected userRepository: UserRepository,
protected versionRepository: VersionHistoryRepository,

View File

@@ -1,14 +1,12 @@
import { Injectable } from '@nestjs/common';
import { isAbsolute } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { AssetFileType, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
import { mimeTypes } from 'src/utils/mime-types';
@Injectable()
export class CliService extends BaseService {
@@ -188,88 +186,4 @@ export class CliService extends BaseService {
cleanup() {
return this.databaseRepository.shutdown();
}
getDefaultThumbnailStoragePath(): string {
const envData = this.configRepository.getEnv();
if (envData.thumbnailStorage.sqlitePath) {
return envData.thumbnailStorage.sqlitePath;
}
const mediaLocation = envData.storage.mediaLocation ?? this.detectMediaLocation();
StorageCore.setMediaLocation(mediaLocation);
return StorageCore.getThumbnailStoragePath();
}
private detectMediaLocation(): string {
const candidates = ['/data', '/usr/src/app/upload'];
for (const candidate of candidates) {
if (this.storageRepository.existsSync(candidate)) {
return candidate;
}
}
return '/usr/src/app/upload';
}
async migrateThumbnailsToSqlite(options: {
sqlitePath: string;
onProgress: (progress: { current: number; migrated: number; skipped: number; errors: number }) => void;
}): Promise<{ total: number; migrated: number; skipped: number; errors: number }> {
const { sqlitePath, onProgress } = options;
if (!isAbsolute(sqlitePath)) {
throw new Error('SQLite path must be an absolute path');
}
this.thumbnailStorageRepository.initialize(sqlitePath);
let current = 0;
let migrated = 0;
let skipped = 0;
let errors = 0;
for await (const file of this.assetJobRepository.streamAllThumbnailFiles()) {
current++;
try {
const existingData = this.thumbnailStorageRepository.get(
file.assetId,
file.type as AssetFileType,
file.isEdited,
);
if (existingData) {
skipped++;
onProgress({ current, migrated, skipped, errors });
continue;
}
const fileExists = await this.storageRepository.checkFileExists(file.path);
if (!fileExists) {
skipped++;
onProgress({ current, migrated, skipped, errors });
continue;
}
const data = await this.storageRepository.readFile(file.path);
const mimeType = mimeTypes.lookup(file.path) || 'image/jpeg';
this.thumbnailStorageRepository.store({
assetId: file.assetId,
type: file.type as AssetFileType,
isEdited: file.isEdited,
data,
mimeType,
});
migrated++;
} catch (error) {
errors++;
this.logger.error(`Failed to migrate thumbnail for asset ${file.assetId}: ${error}`);
}
onProgress({ current, migrated, skipped, errors });
}
return { total: current, migrated, skipped, errors };
}
}

View File

@@ -1128,82 +1128,6 @@ describe(MediaService.name, () => {
expect.stringContaining('fullsize.jpeg'),
);
});
describe('with SQLite storage enabled', () => {
beforeEach(() => {
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
});
it('should store thumbnail in SQLite when enabled', async () => {
const thumbnailBuffer = Buffer.from('thumbnail-data');
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnailToBuffer).toHaveBeenCalledTimes(2);
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
assetId: assetStub.image.id,
type: AssetFileType.Thumbnail,
isEdited: false,
data: thumbnailBuffer,
mimeType: 'image/webp',
});
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
assetId: assetStub.image.id,
type: AssetFileType.Preview,
isEdited: false,
data: thumbnailBuffer,
mimeType: 'image/jpeg',
});
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should not write to filesystem when SQLite is enabled', async () => {
const thumbnailBuffer = Buffer.from('thumbnail-data');
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
});
it('should store with correct mime type for JPEG preview', async () => {
const thumbnailBuffer = Buffer.from('preview-data');
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { format: ImageFormat.Jpeg } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith(
expect.objectContaining({
type: AssetFileType.Preview,
mimeType: 'image/jpeg',
}),
);
});
});
describe('with SQLite storage disabled', () => {
beforeEach(() => {
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
});
it('should continue using filesystem when SQLite is disabled', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
expect(mocks.media.generateThumbnailToBuffer).not.toHaveBeenCalled();
expect(mocks.thumbnailStorage.store).not.toHaveBeenCalled();
});
});
});
describe('handleAssetEditThumbnailGeneration', () => {

View File

@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
AssetFileType,
@@ -35,7 +35,6 @@ import {
ImageDimensions,
JobItem,
JobOf,
RawImageInfo,
VideoFormat,
VideoInterfaces,
VideoStreamInfo,
@@ -57,9 +56,6 @@ interface UpsertFileOptions {
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
// Feature flag: Enable fullsize thumbnail storage in SQLite
const SQLITE_STORE_FULLSIZE = true;
@Injectable()
export class MediaService extends BaseService {
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
@@ -324,27 +320,8 @@ export class MediaService extends BaseService {
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage;
// generate final images
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
if (this.thumbnailStorageRepository.isEnabled()) {
return this.generateImageThumbnailsToSqlite(
asset,
data,
image,
thumbnailOptions,
useEdits,
fullsizeDimensions,
extracted,
generateFullsize,
convertFullsize,
);
} else {
console.log('not enabled');
}
// generate final images to filesystem
const promises = [
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
@@ -399,6 +376,9 @@ export class MediaService extends BaseService {
await Promise.all(promises);
}
const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
return {
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
thumbhash: outputs[0] as Buffer,
@@ -406,82 +386,6 @@ export class MediaService extends BaseService {
};
}
private async generateImageThumbnailsToSqlite(
asset: ThumbnailAsset,
data: Buffer,
image: SystemConfig['image'],
thumbnailOptions: {
colorspace: Colorspace;
processInvalidImages: boolean;
raw: RawImageInfo;
edits: AssetEditActionItem[];
},
useEdits: boolean,
fullsizeDimensions: ImageDimensions,
extracted: { buffer: Buffer; format: RawExtractedFormat } | null,
generateFullsize: boolean,
convertFullsize: boolean,
) {
const [thumbnailBuffer, previewBuffer, thumbhash] = await Promise.all([
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.thumbnail, ...thumbnailOptions }),
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.preview, ...thumbnailOptions }),
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
]);
// Check if fullsize should be stored in SQLite
let fullsizeBuffer: Buffer | null = null;
let fullsizeMimeType: string | null = null;
if (SQLITE_STORE_FULLSIZE && generateFullsize) {
if (convertFullsize) {
// Convert scenario: generate fullsize from data buffer
fullsizeBuffer = await this.mediaRepository.generateThumbnailToBuffer(data, {
format: image.fullsize.format,
quality: image.fullsize.quality,
progressive: image.fullsize.progressive,
...thumbnailOptions,
});
fullsizeMimeType = `image/${image.fullsize.format}`;
} else if (extracted && extracted.format === RawExtractedFormat.Jpeg) {
// Extract scenario: use extracted buffer directly
fullsizeBuffer = extracted.buffer;
fullsizeMimeType = 'image/jpeg';
}
}
this.thumbnailStorageRepository.store({
assetId: asset.id,
type: AssetFileType.Thumbnail,
isEdited: useEdits,
data: thumbnailBuffer,
mimeType: `image/${image.thumbnail.format}`,
});
this.thumbnailStorageRepository.store({
assetId: asset.id,
type: AssetFileType.Preview,
isEdited: useEdits,
data: previewBuffer,
mimeType: `image/${image.preview.format}`,
});
// Store fullsize if generated
if (fullsizeBuffer && fullsizeMimeType) {
this.thumbnailStorageRepository.store({
assetId: asset.id,
type: AssetFileType.FullSize,
isEdited: useEdits,
data: fullsizeBuffer,
mimeType: fullsizeMimeType,
});
}
return {
files: [] as UpsertFileOptions[],
thumbhash,
fullsizeDimensions,
};
}
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });

View File

@@ -45,7 +45,6 @@ export class StorageService extends BaseService {
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService })
async onBootstrap() {
StorageCore.setMediaLocation(this.detectMediaLocation());
this.initializeThumbnailStorage();
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags =
@@ -134,12 +133,6 @@ export class StorageService extends BaseService {
});
}
private initializeThumbnailStorage(): void {
const { thumbnailStorage } = this.configRepository.getEnv();
const path = thumbnailStorage.sqlitePath ?? StorageCore.getThumbnailStoragePath();
this.thumbnailStorageRepository.initialize(path);
}
@OnJob({ name: JobName.FileDelete, queue: QueueName.BackgroundTask })
async handleDeleteFiles(job: JobOf<JobName.FileDelete>): Promise<JobStatus> {
const { files } = job;

View File

@@ -130,7 +130,7 @@ describe(VersionService.name, () => {
});
});
describe('onWebsocketConnection', () => {
describe('onWebsocketConnectionEvent', () => {
it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
@@ -143,12 +143,5 @@ describe(VersionService.name, () => {
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
});
it('should not send a release notification when the version check is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } });
await sut.onWebsocketConnection({ userId: '42' });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
});
});
});

View File

@@ -105,12 +105,6 @@ export class VersionService extends BaseService {
@OnEvent({ name: 'WebsocketConnect' })
async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) {
this.websocketRepository.clientSend('on_server_version', userId, serverVersion);
const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) {
return;
}
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
if (metadata) {
this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata));

View File

@@ -59,6 +59,7 @@ export async function buildPostgresLaunchArguments(
): Promise<{
bin: string;
args: string[];
databaseUsername: string;
databasePassword: string;
databaseVersion: string;
databaseMajorVersion?: number;
@@ -73,6 +74,7 @@ export async function buildPostgresLaunchArguments(
const databaseMajorVersion = databaseSemver?.major;
const args: string[] = [];
let databaseUsername;
if (isUrlConnection) {
if (bin !== 'pg_dump') {
@@ -85,18 +87,20 @@ export async function buildPostgresLaunchArguments(
// remove known bad parameters
parsedUrl.searchParams.delete('uselibpqcompat');
if (options.username) {
parsedUrl.username = options.username;
}
databaseUsername = parsedUrl.username;
url = parsedUrl.toString();
}
// assume typical values if we can't parse URL or not present
databaseUsername ??= 'postgres';
args.push(url);
} else {
databaseUsername = databaseConfig.username;
args.push(
'--username',
options.username ?? databaseConfig.username,
databaseConfig.username,
'--host',
databaseConfig.host,
'--port',
@@ -151,6 +155,7 @@ export async function buildPostgresLaunchArguments(
return {
bin: `/usr/lib/postgresql/${databaseMajorVersion}/bin/${bin}`,
args,
databaseUsername,
databasePassword: isUrlConnection ? new URL(databaseConfig.url).password : databaseConfig.password,
databaseVersion,
databaseMajorVersion,
@@ -207,44 +212,35 @@ const SQL_DROP_CONNECTIONS = `
AND pid <> pg_backend_pid();
`;
const SQL_RESET_SCHEMA = `
const SQL_RESET_SCHEMA = (username: string) => `
-- re-create the default schema
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
-- restore access to schema
GRANT ALL ON SCHEMA public TO postgres;
GRANT ALL ON SCHEMA public TO "${username}";
GRANT ALL ON SCHEMA public TO public;
`;
async function* sql(inputStream: Readable, isPgClusterDump: boolean) {
async function* sql(inputStream: Readable, databaseUsername: string, isPgClusterDump: boolean) {
yield SQL_DROP_CONNECTIONS;
yield isPgClusterDump
? String.raw`
? // it is likely the dump contains SQL to try to drop the currently active
// database to ensure we have a fresh slate; if the `postgres` database exists
// then prefer to switch before continuing otherwise this will just silently fail
String.raw`
\c postgres
`
: SQL_RESET_SCHEMA;
: SQL_RESET_SCHEMA(databaseUsername);
for await (const chunk of inputStream) {
yield chunk;
}
}
async function* sqlRollback(inputStream: Readable, isPgClusterDump: boolean) {
async function* sqlRollback(inputStream: Readable, databaseUsername: string) {
yield SQL_DROP_CONNECTIONS;
if (isPgClusterDump) {
yield String.raw`
-- try to create database
-- may fail but script will continue running
CREATE DATABASE immich;
-- switch to database / newly created database
\c immich
`;
}
yield SQL_RESET_SCHEMA;
yield SQL_RESET_SCHEMA(databaseUsername);
for await (const chunk of inputStream) {
yield chunk;
@@ -273,12 +269,11 @@ export async function restoreDatabaseBackup(
isPgClusterDump = true;
}
const { bin, args, databasePassword, databaseMajorVersion } = await buildPostgresLaunchArguments(
const { bin, args, databaseUsername, databasePassword, databaseMajorVersion } = await buildPostgresLaunchArguments(
{ logger, database: databaseRepository, ...pgRepos },
'psql',
{
singleTransaction: !isPgClusterDump,
username: isPgClusterDump ? 'postgres' : undefined,
},
);
@@ -301,7 +296,7 @@ export async function restoreDatabaseBackup(
inputStream = storage.createPlainReadStream(backupFilePath);
}
const sqlStream = Readable.from(sql(inputStream, isPgClusterDump));
const sqlStream = Readable.from(sql(inputStream, databaseUsername, isPgClusterDump));
const psql = processRepository.spawnDuplexStream(bin, args, {
env: {
PATH: process.env.PATH,
@@ -332,7 +327,7 @@ export async function restoreDatabaseBackup(
fileStream.pipe(gunzip);
inputStream = gunzip;
const sqlStream = Readable.from(sqlRollback(inputStream, isPgClusterDump));
const sqlStream = Readable.from(sqlRollback(inputStream, databaseUsername));
const psql = processRepository.spawnDuplexStream(bin, args, {
env: {
PATH: process.env.PATH,

View File

@@ -30,17 +30,6 @@ export class ImmichFileResponse {
Object.assign(this, response);
}
}
export class ImmichBufferResponse {
public readonly data!: Buffer;
public readonly contentType!: string;
public readonly cacheControl!: CacheControl;
public readonly fileName?: string;
constructor(response: ImmichBufferResponse) {
Object.assign(this, response);
}
}
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
@@ -93,40 +82,6 @@ export const sendFile = async (
}
};
export const sendBuffer = async (
res: Response,
next: NextFunction,
handler: () => Promise<ImmichBufferResponse> | ImmichBufferResponse,
logger: LoggingRepository,
): Promise<void> => {
try {
const file = await handler();
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
if (cacheControlHeader) {
res.set('Cache-Control', cacheControlHeader);
}
res.header('Content-Type', file.contentType);
res.header('Content-Length', file.data.length.toString());
if (file.fileName) {
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
}
res.send(file.data);
} catch (error: Error | any) {
if (isConnectionAborted(error) || res.headersSent) {
return;
}
if (error instanceof HttpException === false) {
logger.error(`Unable to send buffer: ${error}`, error.stack);
}
res.header('Cache-Control', 'none');
next(error);
}
};
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length });
};

View File

@@ -12,7 +12,6 @@ async function bootstrap() {
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
app.enableShutdownHooks();
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {

View File

@@ -11,7 +11,6 @@ async function bootstrap() {
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
app.enableShutdownHooks();
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {

View File

@@ -1,5 +1,5 @@
import { NestFactory } from '@nestjs/core';
import { isMainThread, parentPort } from 'node:worker_threads';
import { isMainThread } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
import { serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
@@ -16,7 +16,6 @@ export async function bootstrap() {
}
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
app.enableShutdownHooks();
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
app.get(AppRepository).setCloseFn(() => app.close());
@@ -30,24 +29,13 @@ export async function bootstrap() {
await (host ? app.listen(0, host) : app.listen(0));
logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `);
return app;
}
if (!isMainThread) {
bootstrap()
.then((app) => {
parentPort?.on('message', (message) => {
if (message?.type === 'shutdown') {
console.log('Microservices worker received shutdown message');
void app.close();
}
});
})
.catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
throw error;
});
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
throw error;
});
}

View File

@@ -84,10 +84,6 @@ const envData: EnvData = {
ignoreMountCheckErrors: false,
},
thumbnailStorage: {
sqlitePath: undefined,
},
telemetry: {
apiPort: 8081,
microservicesPort: 8082,

View File

@@ -5,7 +5,6 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbnailToBuffer: vitest.fn().mockResolvedValue(Buffer.from('')),
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),

View File

@@ -1,15 +0,0 @@
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
export const newThumbnailStorageRepositoryMock = (): Mocked<RepositoryInterface<ThumbnailStorageRepository>> => {
return {
initialize: vitest.fn(),
isEnabled: vitest.fn().mockReturnValue(false),
store: vitest.fn(),
get: vitest.fn(),
delete: vitest.fn(),
deleteByAsset: vitest.fn(),
close: vitest.fn(),
};
};

View File

@@ -60,7 +60,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -82,7 +81,6 @@ import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newThumbnailStorageRepositoryMock } from 'test/repositories/thumbnail-storage.repository.mock';
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { assert, Mock, Mocked, vitest } from 'vitest';
@@ -257,7 +255,6 @@ export type ServiceOverrides = {
systemMetadata: SystemMetadataRepository;
tag: TagRepository;
telemetry: TelemetryRepository;
thumbnailStorage: ThumbnailStorageRepository;
trash: TrashRepository;
user: UserRepository;
versionHistory: VersionHistoryRepository;
@@ -335,7 +332,6 @@ export const getMocks = () => {
// eslint-disable-next-line no-sparse-arrays
tag: automock(TagRepository, { args: [, loggerMock], strict: false }),
telemetry: newTelemetryRepositoryMock(),
thumbnailStorage: newThumbnailStorageRepositoryMock(),
trash: automock(TrashRepository),
user: automock(UserRepository, { strict: false }),
versionHistory: automock(VersionHistoryRepository),
@@ -401,7 +397,6 @@ export const newTestService = <T extends BaseService>(
overrides.systemMetadata || (mocks.systemMetadata as As<SystemMetadataRepository>),
overrides.tag || (mocks.tag as As<TagRepository>),
overrides.telemetry || (mocks.telemetry as unknown as TelemetryRepository),
overrides.thumbnailStorage || (mocks.thumbnailStorage as As<ThumbnailStorageRepository>),
overrides.trash || (mocks.trash as As<TrashRepository>),
overrides.user || (mocks.user as As<UserRepository>),
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),

View File

@@ -22,3 +22,5 @@
return assetViewerManager.on(events);
});
</script>
const event = name.slice(2) as keyof Events;

View File

@@ -194,7 +194,9 @@
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
console.log(asset);
const refreshedAsset = await getAssetInfo({ id: asset.id });
console.log(refreshedAsset);
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
}

View File

@@ -75,7 +75,7 @@
<Button
variant="outline"
onclick={() => editManager.resetAllChanges()}
disabled={!editManager.canReset}
disabled={!editManager.hasChanges}
class="self-start"
shape="round"
size="small"

View File

@@ -15,7 +15,6 @@ export interface EditToolManager {
onDeactivate: () => void;
resetAllChanges: () => Promise<void>;
hasChanges: boolean;
canReset: boolean;
edits: EditAction[];
}
@@ -42,22 +41,19 @@ export class EditManager {
currentAsset = $state<AssetResponseDto | null>(null);
selectedTool = $state<EditTool | null>(null);
hasChanges = $derived(this.tools.some((t) => t.manager.hasChanges));
// used to disable multiple confirm dialogs and mouse events while one is open
isShowingConfirmDialog = $state(false);
isApplyingEdits = $state(false);
hasAppliedEdits = $state(false);
hasUnsavedChanges = $derived(this.tools.some((t) => t.manager.hasChanges) && !this.hasAppliedEdits);
canReset = $derived(this.tools.some((t) => t.manager.canReset));
async closeConfirm(): Promise<boolean> {
// Prevent multiple dialogs (usually happens with rapid escape key presses)
if (this.isShowingConfirmDialog) {
return false;
}
if (!this.hasUnsavedChanges) {
if (!this.hasChanges || this.hasAppliedEdits) {
return true;
}

View File

@@ -38,8 +38,7 @@ type RegionConvertParams = {
};
class TransformManager implements EditToolManager {
canReset: boolean = $derived.by(() => this.checkEdits());
hasChanges: boolean = $state(false);
hasChanges: boolean = $derived.by(() => this.checkEdits());
darkenLevel = $state(0.65);
isInteracting = $state(false);
@@ -57,7 +56,7 @@ class TransformManager implements EditToolManager {
cropAspectRatio = $state('free');
originalImageSize = $state<ImageDimensions>({ width: 1000, height: 1000 });
region = $state({ x: 0, y: 0, width: 100, height: 100 });
previewImageSize = $derived({
preveiwImgSize = $derived({
width: this.cropImageSize.width * this.cropImageScale,
height: this.cropImageSize.height * this.cropImageScale,
});
@@ -74,7 +73,6 @@ class TransformManager implements EditToolManager {
edits = $derived.by(() => this.getEdits());
setAspectRatio(aspectRatio: string) {
this.hasChanges = true;
this.cropAspectRatio = aspectRatio;
if (!this.imgElement || !this.cropAreaEl) {
@@ -90,8 +88,8 @@ class TransformManager implements EditToolManager {
checkEdits() {
return (
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
Math.abs(this.previewImageSize.height - this.region.height) > 2 ||
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2 ||
this.mirrorHorizontal ||
this.mirrorVertical ||
this.normalizedRotation !== 0
@@ -100,8 +98,8 @@ class TransformManager implements EditToolManager {
checkCropEdits() {
return (
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
Math.abs(this.previewImageSize.height - this.region.height) > 2
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2
);
}
@@ -234,12 +232,9 @@ class TransformManager implements EditToolManager {
this.originalImageSize = { width: 1000, height: 1000 };
this.cropImageScale = 1;
this.cropAspectRatio = 'free';
this.hasChanges = false;
}
mirror(axis: 'horizontal' | 'vertical') {
this.hasChanges = true;
if (this.imageRotation % 180 !== 0) {
axis = axis === 'horizontal' ? 'vertical' : 'horizontal';
}
@@ -252,8 +247,6 @@ class TransformManager implements EditToolManager {
}
async rotate(angle: number) {
this.hasChanges = true;
this.imageRotation += angle;
await tick();
this.onImageLoad();
@@ -767,7 +760,6 @@ class TransformManager implements EditToolManager {
return;
}
this.hasChanges = true;
const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width));
const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height));
@@ -789,7 +781,6 @@ class TransformManager implements EditToolManager {
}
this.fadeOverlay(false);
this.hasChanges = true;
const { x, y, width, height } = crop;
const minSize = 50;
let newRegion = { ...crop };