Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2195c820 | ||
|
|
5e99f651ec | ||
|
|
0de15121f2 | ||
|
|
212ba35aef | ||
|
|
827ec1b63a | ||
|
|
e2a2c86a31 | ||
|
|
df31eb1214 | ||
|
|
0d6a4975a3 | ||
|
|
7de2665344 | ||
|
|
058ca28d88 | ||
|
|
b9593361a4 | ||
|
|
a54e01ef2f | ||
|
|
fb641c74be | ||
|
|
c642150b85 | ||
|
|
a8a7d29891 | ||
|
|
67e98ed313 | ||
|
|
47ef48e3c2 | ||
|
|
376feadb76 | ||
|
|
3d82005797 | ||
|
|
10aa00af21 | ||
|
|
1f8bdcdce7 | ||
|
|
98ebfc22f8 | ||
|
|
032b99fe93 | ||
|
|
07156135c2 | ||
|
|
9dbf5db72e | ||
|
|
52170423be | ||
|
|
ae095baad3 | ||
|
|
f99f289f74 | ||
|
|
476eea44df | ||
|
|
e84657192c | ||
|
|
5dda5d93f5 | ||
|
|
6260caf649 | ||
|
|
9e5c52b7b7 | ||
|
|
0e1311e3d3 | ||
|
|
216cca4383 | ||
|
|
cdc98de848 | ||
|
|
126cbeabe8 | ||
|
|
2e0c6f6fff | ||
|
|
81790ab166 | ||
|
|
69b948f3d0 | ||
|
|
4b2ed28b1a | ||
|
|
b8e6ae65b1 | ||
|
|
36bdbf93a7 | ||
|
|
3eee6c4dcf | ||
|
|
3a3676bc82 | ||
|
|
34fc572276 | ||
|
|
ef17c257ef | ||
|
|
4c69cb89d7 | ||
|
|
735455508c | ||
|
|
eba166a2f1 | ||
|
|
8cf8a2cb35 | ||
|
|
1767ed2192 | ||
|
|
3c15dae341 | ||
|
|
8568c2e8b9 | ||
|
|
d558ea819a | ||
|
|
60701d131e | ||
|
|
04808f8b5c | ||
|
|
8a866297f7 | ||
|
|
b5991c908e | ||
|
|
321c3ccfc6 | ||
|
|
05874bd84e | ||
|
|
79705dc58d | ||
|
|
71a132b0b8 | ||
|
|
d14f23497c | ||
|
|
a916df56ee | ||
|
|
73dcb9b452 |
23
.github/workflows/cli.yml
vendored
23
.github/workflows/cli.yml
vendored
@@ -1,16 +1,17 @@
|
||||
name: CLI Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "cli/**"
|
||||
- ".github/workflows/cli.yml"
|
||||
- 'cli/**'
|
||||
- '.github/workflows/cli.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "cli/**"
|
||||
- ".github/workflows/cli.yml"
|
||||
- 'cli/**'
|
||||
- '.github/workflows/cli.yml'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -32,8 +33,8 @@ jobs:
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Prepare SDK
|
||||
run: npm ci --prefix ../open-api/typescript-sdk/
|
||||
- name: Build SDK
|
||||
@@ -41,7 +42,7 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -83,15 +84,15 @@ jobs:
|
||||
images: |
|
||||
name=ghcr.io/${{ github.repository_owner }}/immich-cli
|
||||
tags: |
|
||||
type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.event_name == 'release' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5.4.0
|
||||
with:
|
||||
file: cli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
|
||||
45
Makefile
45
Makefile
@@ -35,3 +35,48 @@ sql:
|
||||
|
||||
attach-server:
|
||||
docker exec -it docker_immich-server_1 sh
|
||||
|
||||
MODULES = e2e server web cli sdk
|
||||
|
||||
audit-%:
|
||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
||||
install-%:
|
||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
|
||||
build-cli: build-sdk
|
||||
build-web: build-sdk
|
||||
build-%: install-%
|
||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'build' >/dev/null \
|
||||
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build || true
|
||||
format-%:
|
||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'format:fix' >/dev/null \
|
||||
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run format:fix || true
|
||||
lint-%:
|
||||
npm --prefix $* run lint:fix
|
||||
check-%:
|
||||
npm --prefix $* run check
|
||||
check-web:
|
||||
npm --prefix web run check:typescript
|
||||
npm --prefix web run check:svelte
|
||||
test-%:
|
||||
npm --prefix $* run test
|
||||
test-e2e:
|
||||
docker compose -f ./e2e/docker-compose.yml build
|
||||
npm --prefix e2e run test
|
||||
npm --prefix e2e run test:web
|
||||
|
||||
build-all: $(foreach M,$(MODULES),build-$M) ;
|
||||
install-all: $(foreach M,$(MODULES),install-$M) ;
|
||||
check-all: $(foreach M,$(MODULES),check-$M) ;
|
||||
lint-all: $(foreach M,$(MODULES),lint-$M) ;
|
||||
format-all: $(foreach M,$(MODULES),format-$M) ;
|
||||
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
||||
hygiene-all: lint-all format-all check-all sql audit-all;
|
||||
test-all: $(foreach M,$(MODULES),test-$M) ;
|
||||
|
||||
clean:
|
||||
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "dist" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "build" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "svelte-kit" -type d -prune -exec rm -rf '{}' +
|
||||
docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
|
||||
docker compose -f ./e2e/docker-compose.yml rm -v -f || true
|
||||
|
||||
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"fast-glob": "^3.3.2",
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.4",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
@@ -103,7 +103,7 @@ services:
|
||||
ports:
|
||||
- 5432:5432
|
||||
healthcheck:
|
||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
||||
interval: 5m
|
||||
start_interval: 30s
|
||||
start_period: 5m
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
ports:
|
||||
- 5432:5432
|
||||
healthcheck:
|
||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
||||
interval: 5m
|
||||
start_interval: 30s
|
||||
start_period: 5m
|
||||
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
volumes:
|
||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
||||
interval: 5m
|
||||
start_interval: 30s
|
||||
start_period: 5m
|
||||
|
||||
@@ -43,7 +43,7 @@ if [ -n "${quota:-}" ] && [ -n "${period:-}" ]; then
|
||||
cpus=1
|
||||
fi
|
||||
else
|
||||
cpus=$(grep -c processor /proc/cpuinfo)
|
||||
cpus=$(grep -c ^processor /proc/cpuinfo)
|
||||
fi
|
||||
|
||||
echo "$cpus"
|
||||
|
||||
@@ -133,40 +133,6 @@ For example, say you have existing transcodes with the policy "Videos higher tha
|
||||
|
||||
No. Our design principle is that the original assets should always be untouched.
|
||||
|
||||
### How can I move all data (photos, persons, albums, libraries) from one user to another?
|
||||
|
||||
This is not officially supported but can be accomplished with some database updates. You can do this on the command line (in the PostgreSQL container using the `psql` command), or you can add, for example, an [Adminer](https://www.adminer.org/) container to the `docker-compose.yml` file so that you can use a web interface.
|
||||
|
||||
<details>
|
||||
<summary>Steps</summary>
|
||||
|
||||
1. **MAKE A BACKUP** - See [backup and restore](/docs/administration/backup-and-restore.md).
|
||||
|
||||
2. Find the ID of both the 'source' and the 'destination' user (it's the id column in the `users` table)
|
||||
|
||||
3. Four tables need to be updated:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
-- reassign albums
|
||||
UPDATE albums SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
|
||||
|
||||
-- reassign people
|
||||
UPDATE person SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
|
||||
|
||||
-- reassign assets
|
||||
UPDATE assets SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>'
|
||||
AND CHECKSUM NOT IN (SELECT CHECKSUM FROM assets WHERE "ownerId" = '<destinationId>');
|
||||
|
||||
-- reassign external libraries
|
||||
UPDATE libraries SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
4. There might be left-over assets in the 'source' user's library if they are skipped by the last query because of duplicate checksums. These are probably duplicates anyway, and can probably be removed.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Albums
|
||||
|
||||
@@ -10,6 +10,59 @@ Viewing and modifying the system settings is restricted to the Administrator.
|
||||
You can always return to the default settings by clicking the `Reset to default` button.
|
||||
:::
|
||||
|
||||
## Authentication Settings
|
||||
|
||||
Manage password, OAuth, and other authentication settings
|
||||
|
||||
### OAuth Authentication
|
||||
|
||||
Immich supports OAuth Authentication. Read more about this feature and its configuration [here](/docs/administration/oauth).
|
||||
|
||||
### Password Authentication
|
||||
|
||||
The administrator can choose to disable login with username and password for the entire instance. This means that **no one**, including the system administrator, will be able to log using this method. If [OAuth Authentication](/docs/administration/oauth) is also disabled, no users will be able to login using **any** method. Changing this setting does not affect existing sessions, just new login attempts.
|
||||
|
||||
:::tip
|
||||
You can always use the [Server CLI](/docs/administration/server-commands) to re-enable password login.
|
||||
:::
|
||||
|
||||
## Image Settings (thumbnails and previews)
|
||||
|
||||
- Thumbnails - Used in the main timeline.
|
||||
- Previews - Used in the asset viewer.
|
||||
|
||||
By default Immich creates 3 thumbnails for each asset,
|
||||
Blurred (thumbhash) , Small - thumbnails (webp) , and Large - previews (jpeg/webp), using these settings you can change the quality for the thumbnails and previews files that are created.
|
||||
|
||||
**Thumbnail format**
|
||||
Allows you to choose the type of format you want for the Thumbnail images, Webp produces smaller files than jpeg, but is slower to encode.
|
||||
|
||||
:::tip
|
||||
You can read in detail about the advantages and disadvantages of using webp over jpeg on [Adobe's website](https://www.adobe.com/creativecloud/file-types/image/raster/webp-file.html)
|
||||
:::
|
||||
|
||||
**Thumbnail resolution**
|
||||
Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.
|
||||
|
||||
**Preview format**
|
||||
Allows you to choose the type of format you want for the Preview images, Webp produces smaller files than jpeg, but is slower to encode.
|
||||
|
||||
**Preview resolution**
|
||||
Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.
|
||||
|
||||
**Quality**
|
||||
Image quality from 1-100. Higher is better for quality but produces larger files, this option affects the Preview and Thumbnail images.
|
||||
|
||||
**Prefer wide gamut**
|
||||
Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts.
|
||||
|
||||
**Prefer embedded preview**
|
||||
Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts.
|
||||
|
||||
:::tip
|
||||
The default resolution for Large thumbnails can be lowered from 1440p (default) to 1080p or 720p to save storage space.
|
||||
:::
|
||||
|
||||
## Job Settings
|
||||
|
||||
Using these settings, you can determine the amount of work that will run concurrently for each task in microservices. Some tasks can be set to higher values on computers with powerful hardware and storage with good I/O capabilities.
|
||||
@@ -92,17 +145,9 @@ The map can be adjusted via [OpenMapTiles](https://openmaptiles.org/styles/) for
|
||||
|
||||
Immich supports [Reverse Geocoding](/docs/features/reverse-geocoding) using data from the [GeoNames](https://www.geonames.org/) geographical database.
|
||||
|
||||
## OAuth Authentication
|
||||
## Notification Settings
|
||||
|
||||
Immich supports OAuth Authentication. Read more about this feature and its configuration [here](/docs/administration/oauth).
|
||||
|
||||
## Password Authentication
|
||||
|
||||
The administrator can choose to disable login with username and password for the entire instance. This means that **no one**, including the system administrator, will be able to log using this method. If [OAuth Authentication](/docs/administration/oauth) is also disabled, no users will be able to login using **any** method. Changing this setting does not affect existing sessions, just new login attempts.
|
||||
|
||||
:::tip
|
||||
You can always use the [Server CLI](/docs/administration/server-commands) to re-enable password login.
|
||||
:::
|
||||
SMTP server setup, for user creation notifications, new albums, etc. More information can be found [here](/docs/administration/email-notification)
|
||||
|
||||
## Server Settings
|
||||
|
||||
@@ -130,27 +175,6 @@ p {
|
||||
}
|
||||
```
|
||||
|
||||
## Thumbnail Settings
|
||||
|
||||
By default Immich creates 3 thumbnails for each asset,
|
||||
Blurred (thumbhash) , Small (webp) , and Large (jpeg), using these settings you can change the quality for the thumbnail files that are created.
|
||||
|
||||
**Small thumbnail resolution**
|
||||
Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.
|
||||
|
||||
**Large thumbnail resolution**
|
||||
Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.
|
||||
|
||||
**Quality**
|
||||
Thumbnail quality from 1-100. Higher is better for quality but produces larger files.
|
||||
|
||||
**Prefer wide gamut**
|
||||
Use display p3 for thumbnails. This better preserves the vibrance of images with wide color spaces, but images may appear differently on old devices with an old browser version. Srgb images are kept as srgb to avoid color shifts.
|
||||
|
||||
:::tip
|
||||
The default resolution for Large thumbnails can be lowered from 1440p (default) to 1080p or 720p to save storage space.
|
||||
:::
|
||||
|
||||
## Trash Settings
|
||||
|
||||
In the system administrator's option to set a trash for deleted files, these files will remain in the trash until the deletion date 30 days (default) or as defined by the system administrator.
|
||||
|
||||
@@ -60,17 +60,17 @@ For RKMPP to work:
|
||||
#### Basic Setup
|
||||
|
||||
1. If you do not already have it, download the latest [`hwaccel.transcoding.yml`][hw-file] file and ensure it's in the same folder as the `docker-compose.yml`.
|
||||
2. In the `docker-compose.yml` under `immich-microservices`, uncomment the `extends` section and change `cpu` to the appropriate backend.
|
||||
2. In the `docker-compose.yml` under `immich-server`, uncomment the `extends` section and change `cpu` to the appropriate backend.
|
||||
|
||||
- For VAAPI on WSL2, be sure to use `vaapi-wsl` rather than `vaapi`
|
||||
|
||||
3. Redeploy the `immich-microservices` container with these updated settings.
|
||||
3. Redeploy the `immich-server` container with these updated settings.
|
||||
4. In the Admin page under `Video transcoding settings`, change the hardware acceleration setting to the appropriate option and save.
|
||||
5. (Optional) If using a compatible backend, you may enable hardware decoding for optimal performance.
|
||||
|
||||
#### Single Compose File
|
||||
|
||||
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-microservices` service directly.
|
||||
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly.
|
||||
|
||||
For example, the `qsv` section in this file is:
|
||||
|
||||
@@ -79,21 +79,22 @@ devices:
|
||||
- /dev/dri:/dev/dri
|
||||
```
|
||||
|
||||
You can add this to the `immich-microservices` service instead of extending from `hwaccel.transcoding.yml`:
|
||||
You can add this to the `immich-server` service instead of extending from `hwaccel.transcoding.yml`:
|
||||
|
||||
```yaml
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
# Note the lack of an `extends` section
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
command: ['start.sh', 'microservices']
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 2283:3001
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
|
||||
@@ -5,21 +5,21 @@ sidebar_position: 3
|
||||
# Quick Start
|
||||
|
||||
Here is a quick, no-choices path to install Immich and take it for a test drive.
|
||||
Once you've tried it, perhaps you'll use one of the many other ways
|
||||
Once you've tried it, you might use one of the many other ways
|
||||
to install and use it.
|
||||
|
||||
## Requirements
|
||||
|
||||
Check the [requirements page](/docs/install/requirements) to get started.
|
||||
|
||||
## Install and launch via Docker Compose
|
||||
## Install and Launch via Docker Compose
|
||||
|
||||
Follow the [Docker Compose (Recommended)](/docs/install/docker-compose) instructions
|
||||
to install the server.
|
||||
|
||||
- Where random passwords are required, `pwgen` is a handy utility.
|
||||
- `UPLOAD_LOCATION` should be set to some new directory on the server
|
||||
with free space.
|
||||
with enough free space.
|
||||
- You may ignore "Step 4 - Upgrading".
|
||||
|
||||
## Try the Web UI
|
||||
@@ -48,26 +48,26 @@ import MobileAppLogin from '/docs/partials/_mobile-app-login.md';
|
||||
|
||||
In the mobile app, you should see the photo you uploaded from the web UI.
|
||||
|
||||
### Transfer Photos from your Mobile Device
|
||||
### Transfer Photos from Your Mobile Device
|
||||
|
||||
import MobileAppBackup from '/docs/partials/_mobile-app-backup.md';
|
||||
|
||||
<MobileAppBackup />
|
||||
|
||||
Depending on how many photos are on your mobile device, this backup may
|
||||
The backup time differs depending on how many photos are on your mobile device. Large uploads may
|
||||
take quite a while.
|
||||
|
||||
You can select the Jobs tab to see Immich processing your photos.
|
||||
You can select the **Jobs** tab to see Immich processing your photos.
|
||||
|
||||
<img src={require('/docs/guides/img/jobs-tab.png').default} title="Jobs tab" />
|
||||
|
||||
## Set up your backups
|
||||
## Set up Your Backups
|
||||
|
||||
You may want to back up the content of your Immich instance
|
||||
along with other parts of your server; be sure to read about
|
||||
[database backup](/docs/administration/backup-and-restore).
|
||||
|
||||
## Where to go from here?
|
||||
## Where to Go From Here
|
||||
|
||||
You may decide you'd like to install the server a different way;
|
||||
the Install category on the left menu provides many options.
|
||||
|
||||
@@ -94,6 +94,10 @@ const config = {
|
||||
srcDark: 'img/immich-logo-inline-dark.png',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'custom-versionSwitcher',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
to: '/docs/overview/introduction',
|
||||
position: 'right',
|
||||
|
||||
59
docs/src/components/version-switcher.tsx
Normal file
59
docs/src/components/version-switcher.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import '@docusaurus/theme-classic/lib/theme/Unlisted/index';
|
||||
import { useWindowSize } from '@docusaurus/theme-common';
|
||||
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function VersionSwitcher(): JSX.Element {
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [label, setLabel] = useState('Versions');
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
useEffect(() => {
|
||||
async function getVersions() {
|
||||
try {
|
||||
let baseUrl = 'https://immich.app';
|
||||
if (window.location.origin === 'http://localhost:3005') {
|
||||
baseUrl = window.location.origin;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/archived-versions.json`);
|
||||
|
||||
const archiveVersions = await response.json();
|
||||
|
||||
const allVersions = [
|
||||
{ label: 'Next', url: 'https://main.preview.immich.app' },
|
||||
{ label: 'Latest', url: 'https://immich.app' },
|
||||
...archiveVersions,
|
||||
];
|
||||
setVersions(allVersions);
|
||||
|
||||
const activeVersion = allVersions.find((version) => new URL(version.url).origin === window.location.origin);
|
||||
if (activeVersion) {
|
||||
setLabel(activeVersion.label);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch versions', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (versions.length === 0) {
|
||||
getVersions();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
versions.length > 0 && (
|
||||
<DropdownNavbarItem
|
||||
className="navbar__item"
|
||||
label={label}
|
||||
mobile={windowSize === 'mobile'}
|
||||
items={versions.map(({ label, url }) => ({
|
||||
label,
|
||||
to: url,
|
||||
target: '_self',
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -63,12 +63,14 @@ import {
|
||||
mdiVectorCombine,
|
||||
mdiVideo,
|
||||
mdiWeb,
|
||||
mdiContentDuplicate,
|
||||
} from '@mdi/js';
|
||||
import Layout from '@theme/Layout';
|
||||
import React from 'react';
|
||||
import { Item, Timeline } from '../components/timeline';
|
||||
|
||||
const releases = {
|
||||
'v1.106.0': new Date(2024, 5, 11),
|
||||
'v1.104.0': new Date(2024, 4, 13),
|
||||
'v1.103.0': new Date(2024, 3, 29),
|
||||
'v1.102.0': new Date(2024, 3, 15),
|
||||
@@ -216,13 +218,19 @@ const roadmap: Item[] = [
|
||||
];
|
||||
|
||||
const milestones: Item[] = [
|
||||
// withRelease({
|
||||
// icon: mdiVectorCombine,
|
||||
// title: 'Container consolidation',
|
||||
// description:
|
||||
// 'The microservices container can be run as a worker within the server image, allowing us to remove it from the default stack.',
|
||||
// release: 'v1.106.0',
|
||||
// }),
|
||||
withRelease({
|
||||
icon: mdiContentDuplicate,
|
||||
title: 'Similar image detection',
|
||||
description: 'Detect duplicate assets that aren’t exactly identical',
|
||||
release: 'v1.106.0',
|
||||
}),
|
||||
withRelease({
|
||||
icon: mdiVectorCombine,
|
||||
title: 'Container consolidation',
|
||||
description:
|
||||
'The microservices container can be run as a worker within the server image, allowing us to remove it from the default stack.',
|
||||
release: 'v1.106.0',
|
||||
}),
|
||||
withRelease({
|
||||
icon: mdiPencil,
|
||||
iconColor: 'saddlebrown',
|
||||
|
||||
7
docs/src/theme/NavbarItem/ComponentTypes.js
Normal file
7
docs/src/theme/NavbarItem/ComponentTypes.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import ComponentTypes from '@theme-original/NavbarItem/ComponentTypes';
|
||||
import VersionSwitcher from '@site/src/components/version-switcher';
|
||||
|
||||
export default {
|
||||
...ComponentTypes,
|
||||
'custom-versionSwitcher': VersionSwitcher,
|
||||
};
|
||||
62
docs/static/archived-versions.json
vendored
Normal file
62
docs/static/archived-versions.json
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"label": "v1.106.4",
|
||||
"url": "https://v1.106.4.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.106.3",
|
||||
"url": "https://v1.106.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.106.2",
|
||||
"url": "https://v1.106.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.106.1",
|
||||
"url": "https://v1.106.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.105.1",
|
||||
"url": "https://v1.105.1.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.105.0",
|
||||
"url": "https://v1.105.0.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.104.0",
|
||||
"url": "https://v1.104.0.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.103.1",
|
||||
"url": "https://v1.103.1.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.103.0",
|
||||
"url": "https://v1.103.0.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.102.3",
|
||||
"url": "https://v1.102.3.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.102.2",
|
||||
"url": "https://v1.102.2.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.102.1",
|
||||
"url": "https://v1.102.1.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.102.0",
|
||||
"url": "https://v1.102.0.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.101.0",
|
||||
"url": "https://v1.101.0.archive.immich.app/"
|
||||
},
|
||||
{
|
||||
"label": "v1.100.0",
|
||||
"url": "https://v1.100.0.archive.immich.app/"
|
||||
}
|
||||
]
|
||||
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-e2e",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@immich/cli": "file:../cli",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"../cli": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.4",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1148,4 +1148,29 @@ describe('/asset', () => {
|
||||
expect(video.checksum).toStrictEqual(checksum);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /assets/exist', () => {
|
||||
it('ignores invalid deviceAssetIds', async () => {
|
||||
const response = await utils.checkExistingAssets(user1.accessToken, {
|
||||
deviceId: 'test-assets-exist',
|
||||
deviceAssetIds: ['invalid', 'INVALID'],
|
||||
});
|
||||
|
||||
expect(response.existingIds).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns the IDs of existing assets', async () => {
|
||||
await utils.createAsset(user1.accessToken, {
|
||||
deviceId: 'test-assets-exist',
|
||||
deviceAssetId: 'test-asset-0',
|
||||
});
|
||||
|
||||
const response = await utils.checkExistingAssets(user1.accessToken, {
|
||||
deviceId: 'test-assets-exist',
|
||||
deviceAssetIds: ['test-asset-0'],
|
||||
});
|
||||
|
||||
expect(response.existingIds).toEqual(['test-asset-0']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetResponseDto,
|
||||
CheckExistingAssetsDto,
|
||||
CreateAlbumDto,
|
||||
CreateLibraryDto,
|
||||
MetadataSearchDto,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
SharedLinkCreateDto,
|
||||
UserAdminCreateDto,
|
||||
ValidateLibraryDto,
|
||||
checkExistingAssets,
|
||||
createAlbum,
|
||||
createApiKey,
|
||||
createLibrary,
|
||||
@@ -374,6 +376,9 @@ export const utils = {
|
||||
|
||||
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>
|
||||
checkExistingAssets({ checkExistingAssetsDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
metadataSearch: async (accessToken: string, dto: MetadataSearchDto) => {
|
||||
return searchMetadata({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
|
||||
64
machine-learning/poetry.lock
generated
64
machine-learning/poetry.lock
generated
@@ -1236,13 +1236,13 @@ socks = ["socksio (==1.*)"]
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "0.23.2"
|
||||
version = "0.23.3"
|
||||
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
files = [
|
||||
{file = "huggingface_hub-0.23.2-py3-none-any.whl", hash = "sha256:48727a16e704d409c4bb5913613308499664f22a99743435dc3a13b23c485827"},
|
||||
{file = "huggingface_hub-0.23.2.tar.gz", hash = "sha256:f6829b62d5fdecb452a76fdbec620cba4c1573655a8d710c1df71735fd9edbd2"},
|
||||
{file = "huggingface_hub-0.23.3-py3-none-any.whl", hash = "sha256:22222c41223f1b7c209ae5511d2d82907325a0e3cdbce5f66949d43c598ff3bc"},
|
||||
{file = "huggingface_hub-0.23.3.tar.gz", hash = "sha256:1a1118a0b3dea3bab6c325d71be16f5ffe441d32f3ac7c348d6875911b694b5b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2054,18 +2054,18 @@ sympy = "*"
|
||||
|
||||
[[package]]
|
||||
name = "opencv-python-headless"
|
||||
version = "4.9.0.80"
|
||||
version = "4.10.0.82"
|
||||
description = "Wrapper package for OpenCV python bindings."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "opencv-python-headless-4.9.0.80.tar.gz", hash = "sha256:71a4cd8cf7c37122901d8e81295db7fb188730e33a0e40039a4e59c1030b0958"},
|
||||
{file = "opencv_python_headless-4.9.0.80-cp37-abi3-macosx_10_16_x86_64.whl", hash = "sha256:2ea8a2edc4db87841991b2fbab55fc07b97ecb602e0f47d5d485bd75cee17c1a"},
|
||||
{file = "opencv_python_headless-4.9.0.80-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0ee54e27be493e8f7850847edae3128e18b540dac1d7b2e4001b8944e11e1c6"},
|
||||
{file = "opencv_python_headless-4.9.0.80-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57ce2865e8fec431c6f97a81e9faaf23fa5be61011d0a75ccf47a3c0d65fa73d"},
|
||||
{file = "opencv_python_headless-4.9.0.80-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:976656362d68d9f40a5c66f83901430538002465f7db59142784f3893918f3df"},
|
||||
{file = "opencv_python_headless-4.9.0.80-cp37-abi3-win32.whl", hash = "sha256:11e3849d83e6651d4e7699aadda9ec7ed7c38957cbbcb99db074f2a2d2de9670"},
|
||||
{file = "opencv_python_headless-4.9.0.80-cp37-abi3-win_amd64.whl", hash = "sha256:a8056c2cb37cd65dfcdf4153ca16f7362afcf3a50d600d6bb69c660fc61ee29c"},
|
||||
{file = "opencv-python-headless-4.10.0.82.tar.gz", hash = "sha256:de9e742c1b9540816fbd115b0b03841d41ed0c65566b0d7a5371f98b131b7e6d"},
|
||||
{file = "opencv_python_headless-4.10.0.82-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a09ed50ba21cc5bf5d436cb0e784ad09c692d6b1d1454252772f6c8f2c7b4088"},
|
||||
{file = "opencv_python_headless-4.10.0.82-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:977a5fd21e1fe0d3d2134887db4441f8725abeae95150126302f31fcd9f548fa"},
|
||||
{file = "opencv_python_headless-4.10.0.82-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db4ec6755838b0be12510bfc9ffb014779c612418f11f4f7e6f505c36124a3aa"},
|
||||
{file = "opencv_python_headless-4.10.0.82-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a37fa5276967ecf6eb297295b16b28b7a2eb3b568ca0ee469fb1a5954de298"},
|
||||
{file = "opencv_python_headless-4.10.0.82-cp37-abi3-win32.whl", hash = "sha256:94736e9b322d13db4768fd35588ad5e8995e78e207263076bfbee18aac835ad5"},
|
||||
{file = "opencv_python_headless-4.10.0.82-cp37-abi3-win_amd64.whl", hash = "sha256:c1822fa23d1641c0249ed5eb906f4c385f7959ff1bd601a776d56b0c18914af4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2438,13 +2438,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.2.1"
|
||||
version = "8.2.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"},
|
||||
{file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"},
|
||||
{file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
|
||||
{file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2799,28 +2799,28 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.4.7"
|
||||
version = "0.4.8"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.4.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e089371c67892a73b6bb1525608e89a2aca1b77b5440acf7a71dda5dac958f9e"},
|
||||
{file = "ruff-0.4.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:10f973d521d910e5f9c72ab27e409e839089f955be8a4c8826601a6323a89753"},
|
||||
{file = "ruff-0.4.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59c3d110970001dfa494bcd95478e62286c751126dfb15c3c46e7915fc49694f"},
|
||||
{file = "ruff-0.4.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa9773c6c00f4958f73b317bc0fd125295110c3776089f6ef318f4b775f0abe4"},
|
||||
{file = "ruff-0.4.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07fc80bbb61e42b3b23b10fda6a2a0f5a067f810180a3760c5ef1b456c21b9db"},
|
||||
{file = "ruff-0.4.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fa4dafe3fe66d90e2e2b63fa1591dd6e3f090ca2128daa0be33db894e6c18648"},
|
||||
{file = "ruff-0.4.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7c0083febdec17571455903b184a10026603a1de078428ba155e7ce9358c5f6"},
|
||||
{file = "ruff-0.4.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad1b20e66a44057c326168437d680a2166c177c939346b19c0d6b08a62a37589"},
|
||||
{file = "ruff-0.4.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbf5d818553add7511c38b05532d94a407f499d1a76ebb0cad0374e32bc67202"},
|
||||
{file = "ruff-0.4.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50e9651578b629baec3d1513b2534de0ac7ed7753e1382272b8d609997e27e83"},
|
||||
{file = "ruff-0.4.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8874a9df7766cb956b218a0a239e0a5d23d9e843e4da1e113ae1d27ee420877a"},
|
||||
{file = "ruff-0.4.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b9de9a6e49f7d529decd09381c0860c3f82fa0b0ea00ea78409b785d2308a567"},
|
||||
{file = "ruff-0.4.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:13a1768b0691619822ae6d446132dbdfd568b700ecd3652b20d4e8bc1e498f78"},
|
||||
{file = "ruff-0.4.7-py3-none-win32.whl", hash = "sha256:769e5a51df61e07e887b81e6f039e7ed3573316ab7dd9f635c5afaa310e4030e"},
|
||||
{file = "ruff-0.4.7-py3-none-win_amd64.whl", hash = "sha256:9e3ab684ad403a9ed1226894c32c3ab9c2e0718440f6f50c7c5829932bc9e054"},
|
||||
{file = "ruff-0.4.7-py3-none-win_arm64.whl", hash = "sha256:10f2204b9a613988e3484194c2c9e96a22079206b22b787605c255f130db5ed7"},
|
||||
{file = "ruff-0.4.7.tar.gz", hash = "sha256:2331d2b051dc77a289a653fcc6a42cce357087c5975738157cd966590b18b5e1"},
|
||||
{file = "ruff-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7663a6d78f6adb0eab270fa9cf1ff2d28618ca3a652b60f2a234d92b9ec89066"},
|
||||
{file = "ruff-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eeceb78da8afb6de0ddada93112869852d04f1cd0f6b80fe464fd4e35c330913"},
|
||||
{file = "ruff-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aad360893e92486662ef3be0a339c5ca3c1b109e0134fcd37d534d4be9fb8de3"},
|
||||
{file = "ruff-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:284c2e3f3396fb05f5f803c9fffb53ebbe09a3ebe7dda2929ed8d73ded736deb"},
|
||||
{file = "ruff-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7354f921e3fbe04d2a62d46707e569f9315e1a613307f7311a935743c51a764"},
|
||||
{file = "ruff-0.4.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:72584676164e15a68a15778fd1b17c28a519e7a0622161eb2debdcdabdc71883"},
|
||||
{file = "ruff-0.4.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9678d5c9b43315f323af2233a04d747409d1e3aa6789620083a82d1066a35199"},
|
||||
{file = "ruff-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704977a658131651a22b5ebeb28b717ef42ac6ee3b11e91dc87b633b5d83142b"},
|
||||
{file = "ruff-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05f8d6f0c3cce5026cecd83b7a143dcad503045857bc49662f736437380ad45"},
|
||||
{file = "ruff-0.4.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ea874950daca5697309d976c9afba830d3bf0ed66887481d6bca1673fc5b66a"},
|
||||
{file = "ruff-0.4.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fc95aac2943ddf360376be9aa3107c8cf9640083940a8c5bd824be692d2216dc"},
|
||||
{file = "ruff-0.4.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:384154a1c3f4bf537bac69f33720957ee49ac8d484bfc91720cc94172026ceed"},
|
||||
{file = "ruff-0.4.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9d5ce97cacc99878aa0d084c626a15cd21e6b3d53fd6f9112b7fc485918e1fa"},
|
||||
{file = "ruff-0.4.8-py3-none-win32.whl", hash = "sha256:6d795d7639212c2dfd01991259460101c22aabf420d9b943f153ab9d9706e6a9"},
|
||||
{file = "ruff-0.4.8-py3-none-win_amd64.whl", hash = "sha256:e14a3a095d07560a9d6769a72f781d73259655919d9b396c650fc98a8157555d"},
|
||||
{file = "ruff-0.4.8-py3-none-win_arm64.whl", hash = "sha256:14019a06dbe29b608f6b7cbcec300e3170a8d86efaddb7b23405cb7f7dcaf780"},
|
||||
{file = "ruff-0.4.8.tar.gz", hash = "sha256:16d717b1d57b2e2fd68bd0bf80fb43931b79d05a7131aa477d66fc40fbd86268"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.106.0"
|
||||
version = "1.106.4"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
17
misc/release/archive-version.js
Executable file
17
misc/release/archive-version.js
Executable file
@@ -0,0 +1,17 @@
|
||||
#! /usr/bin/env node
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
|
||||
const nextVersion = process.argv[2];
|
||||
if (!nextVersion) {
|
||||
console.log('Usage: archive-version.js <version>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filename = './docs/static/archived-versions.json';
|
||||
const oldVersions = JSON.parse(readFileSync(filename));
|
||||
const newVersions = [
|
||||
{ label: `v${nextVersion}`, url: `https://v${nextVersion}.archive.immich.app` },
|
||||
...oldVersions,
|
||||
];
|
||||
|
||||
writeFileSync(filename, JSON.stringify(newVersions, null, 2) + '\n');
|
||||
@@ -66,10 +66,12 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
npm --prefix server run build
|
||||
make open-api
|
||||
npm --prefix open-api/typescript-sdk version "$SERVER_PUMP"
|
||||
npm --prefix web version "$SERVER_PUMP"
|
||||
npm --prefix e2e version "$SERVER_PUMP"
|
||||
npm --prefix web i --package-lock-only
|
||||
# TODO use $SERVER_PUMP once we pass 2.2.x
|
||||
npm --prefix cli version patch
|
||||
npm --prefix cli i --package-lock-only
|
||||
npm --prefix web version "$SERVER_PUMP"
|
||||
npm --prefix web i --package-lock-only
|
||||
npm --prefix e2e version "$SERVER_PUMP"
|
||||
npm --prefix e2e i --package-lock-only
|
||||
poetry --directory machine-learning version "$SERVER_PUMP"
|
||||
fi
|
||||
@@ -83,4 +85,6 @@ sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/
|
||||
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
|
||||
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||
|
||||
./misc/release/archive-version.js "$NEXT_SERVER"
|
||||
|
||||
echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 141,
|
||||
"android.injected.version.name" => "1.106.0",
|
||||
"android.injected.version.code" => 144,
|
||||
"android.injected.version.name" => "1.106.4",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000374">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000381">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="84.292464">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="52.832426">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="33.336934">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="27.616558">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -383,7 +383,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 157;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -525,7 +525,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 157;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -553,7 +553,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 157;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -58,11 +58,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.105.0</string>
|
||||
<string>1.106.3</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>157</string>
|
||||
<string>160</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.106.0"
|
||||
version_number: "1.106.4"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.020864">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000491">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.917777">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="39.414297">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.283943">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="32.521647">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.944748">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.511733">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="215.971639">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="202.628277">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="76.674601">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="73.861852">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -75,9 +75,7 @@ class VideoViewerPage extends HookConsumerWidget {
|
||||
// Also sets the error if there is an error in the playback
|
||||
void updateVideoPlayback() {
|
||||
final videoPlayback = VideoPlaybackValue.fromController(controller);
|
||||
if (!loopVideo) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
}
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
final state = videoPlayback.state;
|
||||
|
||||
// Enable the WakeLock while the video is playing
|
||||
@@ -110,7 +108,9 @@ class VideoViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
// Subscribes to listener
|
||||
controller.addListener(updateVideoPlayback);
|
||||
Future.microtask(() {
|
||||
controller.addListener(updateVideoPlayback);
|
||||
});
|
||||
return () {
|
||||
// Removes listener when we dispose
|
||||
controller.removeListener(updateVideoPlayback);
|
||||
|
||||
@@ -31,6 +31,9 @@ Future<VideoPlayerController> videoPlayerController(
|
||||
controller = VideoPlayerController.networkUrl(
|
||||
url,
|
||||
httpHeaders: {"x-immich-user-token": accessToken},
|
||||
videoPlayerOptions: asset.livePhotoVideoId != null
|
||||
? VideoPlayerOptions(mixWithOthers: true)
|
||||
: VideoPlayerOptions(mixWithOthers: false),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'video_player_controller_provider.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$videoPlayerControllerHash() =>
|
||||
r'40b31f7b1a73fab84c311b0f06bedf5322143cd9';
|
||||
r'642469a44287188a7c301f5cad3df3e23c84d85c';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -93,4 +93,18 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
pause: !state.pause,
|
||||
);
|
||||
}
|
||||
|
||||
void restart() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
pause: true,
|
||||
);
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
pause: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'map_state.provider.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mapStateNotifierHash() => r'87a8623f726d438d115d5a15609c71372726ee2f';
|
||||
String _$mapStateNotifierHash() => r'31fafe17aa85c48379a22ed3db3cc94af59ce5b8';
|
||||
|
||||
/// See also [MapStateNotifier].
|
||||
@ProviderFor(MapStateNotifier)
|
||||
|
||||
@@ -64,6 +64,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
||||
} else {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
duration: const Duration(seconds: 60),
|
||||
)..repeat();
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final warningMessage = useState<String>('');
|
||||
final warningMessage = useState<String?>(null);
|
||||
|
||||
final ValueNotifier<String?> serverEndpoint = useState<String?>(null);
|
||||
|
||||
@@ -67,16 +67,12 @@ class LoginForm extends HookConsumerWidget {
|
||||
final serverMajorVersion = serverInfo.serverVersion.major;
|
||||
final serverMinorVersion = serverInfo.serverVersion.minor;
|
||||
|
||||
final message = getVersionCompatibilityMessage(
|
||||
warningMessage.value = getVersionCompatibilityMessage(
|
||||
appMajorVersion,
|
||||
appMinorVersion,
|
||||
serverMajorVersion,
|
||||
serverMinorVersion,
|
||||
);
|
||||
|
||||
if (message != null) {
|
||||
warningMessage.value = message;
|
||||
}
|
||||
} catch (error) {
|
||||
warningMessage.value = 'Error checking version compatibility';
|
||||
}
|
||||
@@ -345,7 +341,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
buildVersionCompatWarning() {
|
||||
checkVersionMismatch();
|
||||
|
||||
if (warningMessage.value.isEmpty) {
|
||||
if (warningMessage.value == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -363,7 +359,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
warningMessage.value,
|
||||
warningMessage.value!,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.106.0
|
||||
- API version: 1.106.4
|
||||
- Generator version: 7.5.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
19
mobile/openapi/lib/model/asset_response_dto.dart
generated
19
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -31,6 +31,7 @@ class AssetResponseDto {
|
||||
this.livePhotoVideoId,
|
||||
required this.localDateTime,
|
||||
required this.originalFileName,
|
||||
this.originalMimeType,
|
||||
required this.originalPath,
|
||||
this.owner,
|
||||
required this.ownerId,
|
||||
@@ -91,6 +92,14 @@ class AssetResponseDto {
|
||||
|
||||
String originalFileName;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? originalMimeType;
|
||||
|
||||
String originalPath;
|
||||
|
||||
///
|
||||
@@ -151,6 +160,7 @@ class AssetResponseDto {
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.localDateTime == localDateTime &&
|
||||
other.originalFileName == originalFileName &&
|
||||
other.originalMimeType == originalMimeType &&
|
||||
other.originalPath == originalPath &&
|
||||
other.owner == owner &&
|
||||
other.ownerId == ownerId &&
|
||||
@@ -187,6 +197,7 @@ class AssetResponseDto {
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||
(localDateTime.hashCode) +
|
||||
(originalFileName.hashCode) +
|
||||
(originalMimeType == null ? 0 : originalMimeType!.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(owner == null ? 0 : owner!.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
@@ -203,7 +214,7 @@ class AssetResponseDto {
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]';
|
||||
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -241,6 +252,11 @@ class AssetResponseDto {
|
||||
}
|
||||
json[r'localDateTime'] = this.localDateTime.toUtc().toIso8601String();
|
||||
json[r'originalFileName'] = this.originalFileName;
|
||||
if (this.originalMimeType != null) {
|
||||
json[r'originalMimeType'] = this.originalMimeType;
|
||||
} else {
|
||||
// json[r'originalMimeType'] = null;
|
||||
}
|
||||
json[r'originalPath'] = this.originalPath;
|
||||
if (this.owner != null) {
|
||||
json[r'owner'] = this.owner;
|
||||
@@ -304,6 +320,7 @@ class AssetResponseDto {
|
||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||
localDateTime: mapDateTime(json, r'localDateTime', r'')!,
|
||||
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
|
||||
originalMimeType: mapValueOfType<String>(json, r'originalMimeType'),
|
||||
originalPath: mapValueOfType<String>(json, r'originalPath')!,
|
||||
owner: UserResponseDto.fromJson(json[r'owner']),
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
|
||||
@@ -53,7 +53,7 @@ class DuplicateDetectionConfig {
|
||||
|
||||
return DuplicateDetectionConfig(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
maxDistance: mapValueOfType<double>(json, r'maxDistance')!,
|
||||
maxDistance: (mapValueOfType<num>(json, r'maxDistance')!).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -74,9 +74,9 @@ class FacialRecognitionConfig {
|
||||
|
||||
return FacialRecognitionConfig(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
maxDistance: mapValueOfType<double>(json, r'maxDistance')!,
|
||||
maxDistance: (mapValueOfType<num>(json, r'maxDistance')!).toDouble(),
|
||||
minFaces: mapValueOfType<int>(json, r'minFaces')!,
|
||||
minScore: mapValueOfType<double>(json, r'minScore')!,
|
||||
minScore: (mapValueOfType<num>(json, r'minScore')!).toDouble(),
|
||||
modelName: mapValueOfType<String>(json, r'modelName')!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class ServerStorageResponseDto {
|
||||
diskAvailableRaw: mapValueOfType<int>(json, r'diskAvailableRaw')!,
|
||||
diskSize: mapValueOfType<String>(json, r'diskSize')!,
|
||||
diskSizeRaw: mapValueOfType<int>(json, r'diskSizeRaw')!,
|
||||
diskUsagePercentage: mapValueOfType<double>(json, r'diskUsagePercentage')!,
|
||||
diskUsagePercentage: (mapValueOfType<num>(json, r'diskUsagePercentage')!).toDouble(),
|
||||
diskUse: mapValueOfType<String>(json, r'diskUse')!,
|
||||
diskUseRaw: mapValueOfType<int>(json, r'diskUseRaw')!,
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 1.106.0+141
|
||||
version: 1.106.4+144
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
||||
@@ -6735,7 +6735,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -7708,6 +7708,9 @@
|
||||
"originalFileName": {
|
||||
"type": "string"
|
||||
},
|
||||
"originalMimeType": {
|
||||
"type": "string"
|
||||
},
|
||||
"originalPath": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -8146,7 +8149,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxDistance": {
|
||||
"format": "float",
|
||||
"format": "double",
|
||||
"maximum": 0.1,
|
||||
"minimum": 0.001,
|
||||
"type": "number"
|
||||
@@ -8347,7 +8350,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxDistance": {
|
||||
"format": "float",
|
||||
"format": "double",
|
||||
"maximum": 2,
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
@@ -8357,7 +8360,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"minScore": {
|
||||
"format": "float",
|
||||
"format": "double",
|
||||
"maximum": 1,
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
@@ -9797,7 +9800,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"diskUsagePercentage": {
|
||||
"format": "float",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"diskUse": {
|
||||
|
||||
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 1.106.0
|
||||
* 1.106.4
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -182,6 +182,7 @@ export type AssetResponseDto = {
|
||||
livePhotoVideoId?: string | null;
|
||||
localDateTime: string;
|
||||
originalFileName: string;
|
||||
originalMimeType?: string;
|
||||
originalPath: string;
|
||||
owner?: UserResponseDto;
|
||||
ownerId: string;
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"schedule": "on tuesday"
|
||||
}
|
||||
],
|
||||
"ignorePaths": ["mobile/openapi/pubspec.yaml"],
|
||||
"ignorePaths": ["mobile/openapi/pubspec.yaml", "mobile/ios", "mobile/android"],
|
||||
"ignoreDeps": ["http", "intl"],
|
||||
"labels": ["dependencies", "renovate"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:20240604@sha256:bb31fafb1e8fcb4338c2f7a8f424da3d9c5cf6dd6bdb266c54477c795dd07819 as dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:20240611@sha256:2047ec0f857a800675379c65404dfdf4ac02ab7684c759916e3256d3d9566027 as dev
|
||||
|
||||
RUN apt-get install --no-install-recommends -yqq tini
|
||||
WORKDIR /usr/src/app
|
||||
@@ -41,7 +41,7 @@ RUN npm run build
|
||||
|
||||
|
||||
# prod build
|
||||
FROM ghcr.io/immich-app/base-server-prod:20240604@sha256:481ea3ee56fb0e130804fec25c124d28477f10f8a01f7d06fb2e3f85c181bbb9
|
||||
FROM ghcr.io/immich-app/base-server-prod:20240611@sha256:efd32a2af6e7ace8bcea1e94115fe95a971fe1d1fef7e667ff6e77364ce51c46
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^10.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -254,7 +254,7 @@ export class StorageCore {
|
||||
this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`);
|
||||
return false;
|
||||
}
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: true });
|
||||
if (assetInfo && config.storageTemplate.hashVerificationEnabled) {
|
||||
const { checksum } = assetInfo;
|
||||
const newChecksum = await this.cryptoRepository.hashFile(newPath);
|
||||
|
||||
@@ -42,8 +42,8 @@ export class SystemConfigCore {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
async getConfig(force = false): Promise<SystemConfig> {
|
||||
if (force || !this.config) {
|
||||
async getConfig({ withCache }: { withCache: boolean }): Promise<SystemConfig> {
|
||||
if (!withCache || !this.config) {
|
||||
const lastUpdated = this.lastUpdated;
|
||||
await this.asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => {
|
||||
if (lastUpdated === this.lastUpdated) {
|
||||
@@ -74,13 +74,13 @@ export class SystemConfigCore {
|
||||
|
||||
await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
|
||||
|
||||
const config = await this.getConfig(true);
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
this.config$.next(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
async refreshConfig() {
|
||||
const newConfig = await this.getConfig(true);
|
||||
const newConfig = await this.getConfig({ withCache: false });
|
||||
this.config$.next(newConfig);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@ import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
export class SanitizedAssetResponseDto {
|
||||
id!: string;
|
||||
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
||||
type!: AssetType;
|
||||
thumbhash!: string | null;
|
||||
originalMimeType?: string;
|
||||
resized!: boolean;
|
||||
localDateTime!: Date;
|
||||
duration!: string;
|
||||
@@ -87,6 +89,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
|
||||
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
thumbhash: entity.thumbhash?.toString('base64') ?? null,
|
||||
localDateTime: entity.localDateTime,
|
||||
resized: !!entity.previewPath,
|
||||
@@ -107,6 +110,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
|
||||
type: entity.type,
|
||||
originalPath: entity.originalPath,
|
||||
originalFileName: entity.originalFileName,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
resized: !!entity.previewPath,
|
||||
thumbhash: entity.thumbhash?.toString('base64') ?? null,
|
||||
fileCreatedAt: entity.fileCreatedAt,
|
||||
|
||||
@@ -21,7 +21,7 @@ export class DuplicateDetectionConfig extends TaskConfig {
|
||||
@Min(0.001)
|
||||
@Max(0.1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'float' })
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
maxDistance!: number;
|
||||
}
|
||||
|
||||
@@ -30,14 +30,14 @@ export class FacialRecognitionConfig extends ModelConfig {
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'float' })
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
minScore!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'float' })
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
maxDistance!: number;
|
||||
|
||||
@IsNumber()
|
||||
|
||||
@@ -21,7 +21,7 @@ export class ServerStorageResponseDto {
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
diskAvailableRaw!: number;
|
||||
|
||||
@ApiProperty({ type: 'number', format: 'float' })
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
diskUsagePercentage!: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export interface IAssetRepository {
|
||||
getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
|
||||
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
|
||||
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
|
||||
getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<AssetEntity[]>;
|
||||
getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<string[]>;
|
||||
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||
getById(
|
||||
id: string,
|
||||
|
||||
@@ -155,8 +155,8 @@ export class AssetRepository implements IAssetRepository {
|
||||
});
|
||||
}
|
||||
|
||||
getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<AssetEntity[]> {
|
||||
return this.repository.find({
|
||||
async getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<string[]> {
|
||||
const assets = await this.repository.find({
|
||||
select: { deviceAssetId: true },
|
||||
where: {
|
||||
deviceAssetId: In(deviceAssetIds),
|
||||
@@ -165,6 +165,8 @@ export class AssetRepository implements IAssetRepository {
|
||||
},
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
return assets.map((asset) => asset.deviceAssetId);
|
||||
}
|
||||
|
||||
getByUserId(
|
||||
|
||||
@@ -45,7 +45,7 @@ export class MediaRepository implements IMediaRepository {
|
||||
}
|
||||
|
||||
async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void> {
|
||||
const pipeline = sharp(input, { failOn: 'none' })
|
||||
const pipeline = sharp(input, { failOn: 'none', limitInputPixels: false })
|
||||
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
|
||||
.rotate();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DefaultExiftoolArgs, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
||||
import { DefaultReadTaskOptions, Tags, exiftool } from 'exiftool-vendored';
|
||||
import geotz from 'geo-tz';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
@@ -21,23 +21,18 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
) {
|
||||
this.logger.setContext(MetadataRepository.name);
|
||||
}
|
||||
private exiftool: ExifTool = this.initExiftool();
|
||||
|
||||
async teardown() {
|
||||
await this.exiftool.end();
|
||||
}
|
||||
|
||||
private initExiftool() {
|
||||
// Enable exiftool LFS to parse metadata for files larger than 2GB.
|
||||
const exiftoolArgs = ['-api', 'largefilesupport=1', ...DefaultExiftoolArgs];
|
||||
return new ExifTool({ exiftoolArgs });
|
||||
await exiftool.end();
|
||||
}
|
||||
|
||||
readTags(path: string): Promise<ImmichTags | null> {
|
||||
return this.exiftool
|
||||
return exiftool
|
||||
.read(path, undefined, {
|
||||
...DefaultReadTaskOptions,
|
||||
|
||||
// Enable exiftool LFS to parse metadata for files larger than 2GB.
|
||||
optionalArgs: ['-api', 'largefilesupport=1'],
|
||||
defaultVideosToUTC: true,
|
||||
backfillTimezones: true,
|
||||
inferTimezoneFromDatestamps: true,
|
||||
@@ -53,12 +48,12 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
}
|
||||
|
||||
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
|
||||
return this.exiftool.extractBinaryTagToBuffer(tagName, path);
|
||||
return exiftool.extractBinaryTagToBuffer(tagName, path);
|
||||
}
|
||||
|
||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||
try {
|
||||
await this.exiftool.write(path, tags, ['-overwrite_original']);
|
||||
await exiftool.write(path, tags, ['-overwrite_original']);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||
}
|
||||
|
||||
@@ -277,14 +277,12 @@ export class AssetMediaService {
|
||||
auth: AuthDto,
|
||||
checkExistingAssetsDto: CheckExistingAssetsDto,
|
||||
): Promise<CheckExistingAssetsResponseDto> {
|
||||
const assets = await this.assetRepository.getByDeviceIds(
|
||||
const existingIds = await this.assetRepository.getByDeviceIds(
|
||||
auth.user.id,
|
||||
checkExistingAssetsDto.deviceId,
|
||||
checkExistingAssetsDto.deviceAssetIds,
|
||||
);
|
||||
return {
|
||||
existingIds: assets.map((asset) => asset.id),
|
||||
};
|
||||
return { existingIds };
|
||||
}
|
||||
|
||||
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
|
||||
|
||||
@@ -245,7 +245,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
async handleAssetDeletionCheck(): Promise<JobStatus> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
const trashedDays = config.trash.enabled ? config.trash.days : 0;
|
||||
const trashedBefore = DateTime.now()
|
||||
.minus(Duration.fromObject({ days: trashedDays }))
|
||||
|
||||
@@ -77,7 +77,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async login(dto: LoginCredentialDto, details: LoginDetails) {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
if (!config.passwordLogin.enabled) {
|
||||
throw new UnauthorizedException('Password login has been disabled');
|
||||
}
|
||||
@@ -174,7 +174,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async authorize(dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
if (!config.oauth.enabled) {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
@@ -190,7 +190,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
const profile = await this.getOAuthProfile(config, dto.url);
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
let user = await this.userRepository.getByOAuthId(profile.sub);
|
||||
@@ -242,7 +242,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
|
||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||
if (duplicate && duplicate.id !== auth.user.id) {
|
||||
@@ -264,7 +264,7 @@ export class AuthService {
|
||||
return LOGIN_URL;
|
||||
}
|
||||
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
if (!config.oauth.enabled) {
|
||||
return LOGIN_URL;
|
||||
}
|
||||
|
||||
@@ -42,25 +42,25 @@ export class CliService {
|
||||
}
|
||||
|
||||
async disablePasswordLogin(): Promise<void> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
config.passwordLogin.enabled = false;
|
||||
await this.configCore.updateConfig(config);
|
||||
}
|
||||
|
||||
async enablePasswordLogin(): Promise<void> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
config.passwordLogin.enabled = true;
|
||||
await this.configCore.updateConfig(config);
|
||||
}
|
||||
|
||||
async disableOAuthLogin(): Promise<void> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
config.oauth.enabled = false;
|
||||
await this.configCore.updateConfig(config);
|
||||
}
|
||||
|
||||
async enableOAuthLogin(): Promise<void> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
config.oauth.enabled = true;
|
||||
await this.configCore.updateConfig(config);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class DuplicateService {
|
||||
}
|
||||
|
||||
async handleQueueSearchDuplicates({ force }: IBaseJob): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
if (!isDuplicateDetectionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export class DuplicateService {
|
||||
}
|
||||
|
||||
async handleSearchDuplicates({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!isDuplicateDetectionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export class JobService {
|
||||
}
|
||||
|
||||
async init(jobHandlers: Record<JobName, JobHandler>) {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
let concurrency = 1;
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export class LibraryService {
|
||||
}
|
||||
|
||||
async init() {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
|
||||
const { watch, scan } = config.library;
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export class MapService {
|
||||
}
|
||||
|
||||
async getMapStyle(theme: 'light' | 'dark') {
|
||||
const { map } = await this.configCore.getConfig();
|
||||
const { map } = await this.configCore.getConfig({ withCache: false });
|
||||
const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle;
|
||||
|
||||
if (styleUrl) {
|
||||
|
||||
@@ -149,7 +149,7 @@ export class MediaService {
|
||||
}
|
||||
|
||||
async handleAssetMigration({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const { image } = await this.configCore.getConfig();
|
||||
const { image } = await this.configCore.getConfig({ withCache: true });
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
@@ -164,7 +164,7 @@ export class MediaService {
|
||||
|
||||
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig(),
|
||||
this.configCore.getConfig({ withCache: true }),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||
]);
|
||||
if (!asset) {
|
||||
@@ -185,7 +185,7 @@ export class MediaService {
|
||||
}
|
||||
|
||||
private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) {
|
||||
const { image, ffmpeg } = await this.configCore.getConfig();
|
||||
const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true });
|
||||
const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize;
|
||||
const path = StorageCore.getImagePath(asset, type, format);
|
||||
this.storageCore.ensureFolders(path);
|
||||
@@ -237,7 +237,7 @@ export class MediaService {
|
||||
|
||||
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig(),
|
||||
this.configCore.getConfig({ withCache: true }),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||
]);
|
||||
if (!asset) {
|
||||
@@ -318,7 +318,7 @@ export class MediaService {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const { ffmpeg } = await this.configCore.getConfig();
|
||||
const { ffmpeg } = await this.configCore.getConfig({ withCache: true });
|
||||
const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream);
|
||||
if (target === TranscodeTarget.NONE) {
|
||||
if (asset.encodedVideoPath) {
|
||||
|
||||
@@ -137,7 +137,7 @@ export class MetadataService {
|
||||
this.subscription = this.configCore.config$.subscribe(() => handlePromiseError(this.init(), this.logger));
|
||||
}
|
||||
|
||||
const { reverseGeocoding } = await this.configCore.getConfig();
|
||||
const { reverseGeocoding } = await this.configCore.getConfig({ withCache: false });
|
||||
const { enabled } = reverseGeocoding;
|
||||
|
||||
if (!enabled) {
|
||||
@@ -333,7 +333,7 @@ export class MetadataService {
|
||||
|
||||
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
|
||||
const { latitude, longitude } = exifData;
|
||||
const { reverseGeocoding } = await this.configCore.getConfig();
|
||||
const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!reverseGeocoding.enabled || !longitude || !latitude) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export class NotificationService {
|
||||
throw new HttpException('Failed to verify SMTP configuration', HttpStatus.BAD_REQUEST, { cause: error });
|
||||
}
|
||||
|
||||
const { server } = await this.configCore.getConfig();
|
||||
const { server } = await this.configCore.getConfig({ withCache: false });
|
||||
const { html, text } = this.notificationRepository.renderEmail({
|
||||
template: EmailTemplate.TEST_EMAIL,
|
||||
data: {
|
||||
@@ -94,7 +94,7 @@ export class NotificationService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { server } = await this.configCore.getConfig();
|
||||
const { server } = await this.configCore.getConfig({ withCache: true });
|
||||
const { html, text } = this.notificationRepository.renderEmail({
|
||||
template: EmailTemplate.WELCOME,
|
||||
data: {
|
||||
@@ -137,7 +137,7 @@ export class NotificationService {
|
||||
|
||||
const attachment = await this.getAlbumThumbnailAttachment(album);
|
||||
|
||||
const { server } = await this.configCore.getConfig();
|
||||
const { server } = await this.configCore.getConfig({ withCache: false });
|
||||
const { html, text } = this.notificationRepository.renderEmail({
|
||||
template: EmailTemplate.ALBUM_INVITE,
|
||||
data: {
|
||||
@@ -179,10 +179,15 @@ export class NotificationService {
|
||||
const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => user.id !== senderId);
|
||||
const attachment = await this.getAlbumThumbnailAttachment(album);
|
||||
|
||||
const { server } = await this.configCore.getConfig();
|
||||
const { server } = await this.configCore.getConfig({ withCache: false });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const { emailNotifications } = getPreferences(recipient);
|
||||
const user = await this.userRepository.get(recipient.id, { withDeleted: false });
|
||||
if (!user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { emailNotifications } = getPreferences(user);
|
||||
|
||||
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
|
||||
continue;
|
||||
@@ -215,7 +220,7 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
async handleSendEmail(data: IEmailJob): Promise<JobStatus> {
|
||||
const { notifications } = await this.configCore.getConfig();
|
||||
const { notifications } = await this.configCore.getConfig({ withCache: false });
|
||||
if (!notifications.smtp.enabled) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
@@ -949,6 +949,32 @@ describe(PersonService.name, () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should use preview path for videos', async () => {
|
||||
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
|
||||
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||
mediaMock.getImageDimensions.mockResolvedValue({ width: 2560, height: 1440 });
|
||||
|
||||
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
|
||||
|
||||
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
|
||||
assetStub.video.previewPath,
|
||||
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
|
||||
{
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
crop: {
|
||||
left: 1741,
|
||||
top: 851,
|
||||
width: 588,
|
||||
height: 588,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergePerson', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
mapFaces,
|
||||
mapPerson,
|
||||
} from 'src/dtos/person.dto';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { PersonPathType } from 'src/entities/move.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
||||
import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
||||
import { CropOptions, IMediaRepository, ImageDimensions } from 'src/interfaces/media.interface';
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface';
|
||||
@@ -88,7 +89,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
const people = await this.repository.getAllForUser(auth.user.id, {
|
||||
minimumFaceCount: machineLearning.facialRecognition.minFaces,
|
||||
withHidden: dto.withHidden || false,
|
||||
@@ -282,7 +283,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async handleQueueDetectFaces({ force }: IBaseJob): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
@@ -313,7 +314,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async handleDetectFaces({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
@@ -371,7 +372,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async handleQueueRecognizeFaces({ force }: IBaseJob): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
@@ -402,7 +403,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
@@ -486,7 +487,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async handleGeneratePersonThumbnail(data: IEntityJob): Promise<JobStatus> {
|
||||
const { machineLearning, image } = await this.configCore.getConfig();
|
||||
const { machineLearning, image } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
@@ -509,61 +510,30 @@ export class PersonService {
|
||||
boundingBoxX2: x2,
|
||||
boundingBoxY1: y1,
|
||||
boundingBoxY2: y2,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
imageWidth: oldWidth,
|
||||
imageHeight: oldHeight,
|
||||
} = face;
|
||||
|
||||
const asset = await this.assetRepository.getById(assetId, { exifInfo: true });
|
||||
if (!asset?.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) {
|
||||
this.logger.error(`Could not generate person thumbnail: asset ${assetId} dimensions are unknown`);
|
||||
if (!asset) {
|
||||
this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`);
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
this.logger.verbose(`Cropping face for person: ${person.id}`);
|
||||
const { width, height, inputPath } = await this.getInputDimensions(asset);
|
||||
|
||||
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
|
||||
this.storageCore.ensureFolders(thumbnailPath);
|
||||
|
||||
const { width: exifWidth, height: exifHeight } = this.withOrientation(asset.exifInfo.orientation as Orientation, {
|
||||
width: asset.exifInfo.exifImageWidth,
|
||||
height: asset.exifInfo.exifImageHeight,
|
||||
});
|
||||
|
||||
const widthScale = exifWidth / imageWidth;
|
||||
const heightScale = exifHeight / imageHeight;
|
||||
|
||||
const halfWidth = (widthScale * (x2 - x1)) / 2;
|
||||
const halfHeight = (heightScale * (y2 - y1)) / 2;
|
||||
|
||||
const middleX = Math.round(widthScale * x1 + halfWidth);
|
||||
const middleY = Math.round(heightScale * y1 + halfHeight);
|
||||
|
||||
// zoom out 10%
|
||||
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
|
||||
|
||||
// get the longest distance from the center of the image without overflowing
|
||||
const newHalfSize = Math.min(
|
||||
middleX - Math.max(0, middleX - targetHalfSize),
|
||||
middleY - Math.max(0, middleY - targetHalfSize),
|
||||
Math.min(exifWidth - 1, middleX + targetHalfSize) - middleX,
|
||||
Math.min(exifHeight - 1, middleY + targetHalfSize) - middleY,
|
||||
);
|
||||
|
||||
const cropOptions: CropOptions = {
|
||||
left: middleX - newHalfSize,
|
||||
top: middleY - newHalfSize,
|
||||
width: newHalfSize * 2,
|
||||
height: newHalfSize * 2,
|
||||
};
|
||||
|
||||
const thumbnailOptions = {
|
||||
format: ImageFormat.JPEG,
|
||||
size: FACE_THUMBNAIL_SIZE,
|
||||
colorspace: image.colorspace,
|
||||
quality: image.quality,
|
||||
crop: cropOptions,
|
||||
crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }),
|
||||
} as const;
|
||||
|
||||
await this.mediaRepository.generateThumbnail(asset.originalPath, thumbnailPath, thumbnailOptions);
|
||||
await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions);
|
||||
await this.repository.update({ id: person.id, thumbnailPath });
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
@@ -631,6 +601,27 @@ export class PersonService {
|
||||
return person;
|
||||
}
|
||||
|
||||
private async getInputDimensions(asset: AssetEntity): Promise<ImageDimensions & { inputPath: string }> {
|
||||
if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) {
|
||||
throw new Error(`Asset ${asset.id} dimensions are unknown`);
|
||||
}
|
||||
|
||||
if (!asset.previewPath) {
|
||||
throw new Error(`Asset ${asset.id} has no preview path`);
|
||||
}
|
||||
|
||||
if (asset.type === AssetType.IMAGE) {
|
||||
const { width, height } = this.withOrientation(asset.exifInfo.orientation as Orientation, {
|
||||
width: asset.exifInfo.exifImageWidth,
|
||||
height: asset.exifInfo.exifImageHeight,
|
||||
});
|
||||
return { width, height, inputPath: asset.originalPath };
|
||||
}
|
||||
|
||||
const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath);
|
||||
return { width, height, inputPath: asset.previewPath };
|
||||
}
|
||||
|
||||
private withOrientation(orientation: Orientation, { width, height }: ImageDimensions): ImageDimensions {
|
||||
switch (orientation) {
|
||||
case Orientation.MirrorHorizontalRotate270CW:
|
||||
@@ -644,4 +635,33 @@ export class PersonService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {
|
||||
const widthScale = dims.new.width / dims.old.width;
|
||||
const heightScale = dims.new.height / dims.old.height;
|
||||
|
||||
const halfWidth = (widthScale * (x2 - x1)) / 2;
|
||||
const halfHeight = (heightScale * (y2 - y1)) / 2;
|
||||
|
||||
const middleX = Math.round(widthScale * x1 + halfWidth);
|
||||
const middleY = Math.round(heightScale * y1 + halfHeight);
|
||||
|
||||
// zoom out 10%
|
||||
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
|
||||
|
||||
// get the longest distance from the center of the image without overflowing
|
||||
const newHalfSize = Math.min(
|
||||
middleX - Math.max(0, middleX - targetHalfSize),
|
||||
middleY - Math.max(0, middleY - targetHalfSize),
|
||||
Math.min(dims.new.width - 1, middleX + targetHalfSize) - middleX,
|
||||
Math.min(dims.new.height - 1, middleY + targetHalfSize) - middleY,
|
||||
);
|
||||
|
||||
return {
|
||||
left: middleX - newHalfSize,
|
||||
top: middleY - newHalfSize,
|
||||
width: newHalfSize * 2,
|
||||
height: newHalfSize * 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export class SearchService {
|
||||
}
|
||||
|
||||
async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
if (!isSmartSearchEnabled(machineLearning)) {
|
||||
throw new BadRequestException('Smart search is not enabled');
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export class ServerInfoService {
|
||||
|
||||
async getFeatures(): Promise<ServerFeaturesDto> {
|
||||
const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } =
|
||||
await this.configCore.getConfig();
|
||||
await this.configCore.getConfig({ withCache: false });
|
||||
|
||||
return {
|
||||
smartSearch: isSmartSearchEnabled(machineLearning),
|
||||
@@ -85,12 +85,12 @@ export class ServerInfoService {
|
||||
}
|
||||
|
||||
async getTheme() {
|
||||
const { theme } = await this.configCore.getConfig();
|
||||
const { theme } = await this.configCore.getConfig({ withCache: false });
|
||||
return theme;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<ServerConfigDto> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
const isInitialized = await this.userRepository.hasAdmin();
|
||||
const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING);
|
||||
|
||||
|
||||
@@ -164,6 +164,36 @@ describe(SharedLinkService.name, () => {
|
||||
key: Buffer.from('random-bytes', 'utf8'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a shared link with allowDownload set to false when showMetadata is false', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
assetIds: [assetStub.image.id],
|
||||
showMetadata: false,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([assetStub.image.id]),
|
||||
);
|
||||
expect(shareMock.create).toHaveBeenCalledWith({
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
userId: authStub.admin.user.id,
|
||||
albumId: null,
|
||||
allowDownload: false,
|
||||
allowUpload: true,
|
||||
assets: [{ id: assetStub.image.id }],
|
||||
description: null,
|
||||
expiresAt: null,
|
||||
showExif: false,
|
||||
key: Buffer.from('random-bytes', 'utf8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
|
||||
@@ -84,7 +84,7 @@ export class SharedLinkService {
|
||||
password: dto.password,
|
||||
expiresAt: dto.expiresAt || null,
|
||||
allowUpload: dto.allowUpload ?? true,
|
||||
allowDownload: dto.allowDownload ?? true,
|
||||
allowDownload: dto.showMetadata === false ? false : dto.allowDownload ?? true,
|
||||
showExif: dto.showMetadata ?? true,
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export class SmartInfoService {
|
||||
|
||||
await this.jobRepository.waitForQueueCompletion(QueueName.SMART_SEARCH);
|
||||
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
|
||||
await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, () =>
|
||||
this.repository.init(machineLearning.clip.modelName),
|
||||
@@ -50,7 +50,7 @@ export class SmartInfoService {
|
||||
}
|
||||
|
||||
async handleQueueEncodeClip({ force }: IBaseJob): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
if (!isSmartSearchEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export class SmartInfoService {
|
||||
}
|
||||
|
||||
async handleEncodeClip({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!isSmartSearchEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export class StorageTemplateService {
|
||||
}
|
||||
|
||||
async handleMigrationSingle({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: true });
|
||||
const storageTemplateEnabled = config.storageTemplate.enabled;
|
||||
if (!storageTemplateEnabled) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -140,7 +140,7 @@ export class StorageTemplateService {
|
||||
|
||||
async handleMigration(): Promise<JobStatus> {
|
||||
this.logger.log('Starting storage template migration');
|
||||
const { storageTemplate } = await this.configCore.getConfig();
|
||||
const { storageTemplate } = await this.configCore.getConfig({ withCache: true });
|
||||
const { enabled } = storageTemplate;
|
||||
if (!enabled) {
|
||||
this.logger.log('Storage template migration disabled, skipping');
|
||||
|
||||
@@ -42,7 +42,7 @@ export class SystemConfigService {
|
||||
}
|
||||
|
||||
async init() {
|
||||
const config = await this.core.getConfig();
|
||||
const config = await this.core.getConfig({ withCache: false });
|
||||
this.config$.next(config);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class SystemConfigService {
|
||||
}
|
||||
|
||||
async getConfig(): Promise<SystemConfigDto> {
|
||||
const config = await this.core.getConfig();
|
||||
const config = await this.core.getConfig({ withCache: false });
|
||||
return mapConfig(config);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export class SystemConfigService {
|
||||
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
|
||||
}
|
||||
|
||||
const oldConfig = await this.core.getConfig();
|
||||
const oldConfig = await this.core.getConfig({ withCache: false });
|
||||
|
||||
try {
|
||||
await this.eventRepository.serverSendAsync(ServerAsyncEvent.CONFIG_VALIDATE, {
|
||||
@@ -110,7 +110,7 @@ export class SystemConfigService {
|
||||
}
|
||||
|
||||
async getCustomCss(): Promise<string> {
|
||||
const { theme } = await this.core.getConfig();
|
||||
const { theme } = await this.core.getConfig({ withCache: false });
|
||||
return theme.customCss;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export class UserService {
|
||||
|
||||
async handleUserDeleteCheck(): Promise<JobStatus> {
|
||||
const users = await this.userRepository.getDeletedUsers();
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
await this.jobRepository.queueAll(
|
||||
users.flatMap((user) =>
|
||||
this.isReadyForDeletion(user, config.user.deleteDelay)
|
||||
@@ -140,7 +140,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
async handleUserDelete({ id, force }: IEntityJob): Promise<JobStatus> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
const user = await this.userRepository.get(id, { withDeleted: true });
|
||||
if (!user) {
|
||||
return JobStatus.FAILED;
|
||||
|
||||
@@ -56,7 +56,7 @@ export class VersionService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { newVersionCheck } = await this.configCore.getConfig();
|
||||
const { newVersionCheck } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!newVersionCheck.enabled) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ const port = Number(process.env.IMMICH_PORT) || 3001;
|
||||
const controller = new AbortController();
|
||||
|
||||
const main = async () => {
|
||||
if (!process.env.IMMICH_WORKERS_INCLUDE?.includes('api')) {
|
||||
process.exit();
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/api/server-info/ping`, {
|
||||
|
||||
@@ -842,7 +842,7 @@ export class VAAPIConfig extends BaseHWConfig {
|
||||
}
|
||||
|
||||
getSupportedCodecs() {
|
||||
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9];
|
||||
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9, VideoCodec.AV1];
|
||||
}
|
||||
|
||||
useCQP() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
SwaggerDocumentOptions,
|
||||
SwaggerModule,
|
||||
} from '@nestjs/swagger';
|
||||
import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
|
||||
import { ReferenceObject, SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
|
||||
import _ from 'lodash';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -111,6 +111,14 @@ function sortKeys<T>(target: T): T {
|
||||
export const routeToErrorMessage = (methodName: string) =>
|
||||
'Failed to ' + methodName.replaceAll(/[A-Z]+/g, (letter) => ` ${letter.toLowerCase()}`);
|
||||
|
||||
const isSchema = (schema: string | ReferenceObject | SchemaObject): schema is SchemaObject => {
|
||||
if (typeof schema === 'string' || '$ref' in schema) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const patchOpenAPI = (document: OpenAPIObject) => {
|
||||
document.paths = sortKeys(document.paths);
|
||||
|
||||
@@ -119,13 +127,23 @@ const patchOpenAPI = (document: OpenAPIObject) => {
|
||||
|
||||
document.components.schemas = sortKeys(schemas);
|
||||
|
||||
for (const schema of Object.values(schemas)) {
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
if (schema.properties) {
|
||||
schema.properties = sortKeys(schema.properties);
|
||||
}
|
||||
|
||||
if (schema.required) {
|
||||
schema.required = schema.required.sort();
|
||||
for (const [key, value] of Object.entries(schema.properties)) {
|
||||
if (typeof value === 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSchema(value) && value.type === 'number' && value.format === 'float') {
|
||||
throw new Error(`Invalid number format: ${schemaName}.${key}=float (use double instead). `);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.required) {
|
||||
schema.required = schema.required.sort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ async function bootstrap() {
|
||||
etag: true,
|
||||
gzip: true,
|
||||
brotli: true,
|
||||
extensions: [],
|
||||
setHeaders: (res, pathname) => {
|
||||
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
|
||||
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
|
||||
|
||||
2
server/test/fixtures/asset.stub.ts
vendored
2
server/test/fixtures/asset.stub.ts
vendored
@@ -436,6 +436,8 @@ export const assetStub = {
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 100_000,
|
||||
exifImageHeight: 2160,
|
||||
exifImageWidth: 3840,
|
||||
} as ExifEntity,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
|
||||
2
server/test/fixtures/shared-link.stub.ts
vendored
2
server/test/fixtures/shared-link.stub.ts
vendored
@@ -52,6 +52,7 @@ const assetResponse: AssetResponseDto = {
|
||||
ownerId: 'user_id_1',
|
||||
deviceId: 'device_id_1',
|
||||
type: AssetType.VIDEO,
|
||||
originalMimeType: 'image/jpeg',
|
||||
originalPath: 'fake_path/jpeg',
|
||||
originalFileName: 'asset_1.jpeg',
|
||||
resized: false,
|
||||
@@ -82,6 +83,7 @@ const assetResponse: AssetResponseDto = {
|
||||
const assetResponseWithoutMetadata = {
|
||||
id: 'id_1',
|
||||
type: AssetType.VIDEO,
|
||||
originalMimeType: 'image/jpeg',
|
||||
resized: false,
|
||||
thumbhash: null,
|
||||
localDateTime: today,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"jsonRecursiveSort": true,
|
||||
"organizeImportsSkipDestructiveCodeActions": true,
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
|
||||
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-svelte", "prettier-plugin-sort-json"],
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-web",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
@@ -68,7 +68,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.106.0",
|
||||
"version": "1.106.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||
|
||||
@@ -76,13 +76,10 @@
|
||||
</div>
|
||||
|
||||
{#if forceDelete}
|
||||
<p class="text-immich-error">
|
||||
WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be
|
||||
recovered.
|
||||
</p>
|
||||
<p class="text-immich-error">{$t('admin.force_delete_user_warning')}</p>
|
||||
|
||||
<p class="immich-form-label text-sm" id="confirm-user-desc">
|
||||
To confirm, type "{user.email}" below
|
||||
{$t('admin.confirm_email_below', { values: { email: user.email } })}
|
||||
</p>
|
||||
|
||||
<input
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<Badge color="primary">
|
||||
<div class="flex flex-row gap-1">
|
||||
<span class="text-sm">
|
||||
{jobCounts.failed.toLocaleString($locale)} failed
|
||||
{$t('admin.jobs_failed', { values: { jobCount: jobCounts.failed.toLocaleString($locale) } })}
|
||||
</span>
|
||||
<CircleIconButton
|
||||
color="primary"
|
||||
@@ -74,7 +74,7 @@
|
||||
{#if jobCounts.delayed > 0}
|
||||
<Badge color="secondary">
|
||||
<span class="text-sm">
|
||||
{jobCounts.delayed.toLocaleString($locale)} delayed
|
||||
{$t('admin.jobs_delayed', { values: { jobCount: jobCounts.delayed.toLocaleString($locale) } })}
|
||||
</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
@@ -119,12 +119,14 @@
|
||||
color="light-gray"
|
||||
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
|
||||
>
|
||||
<Icon path={mdiAlertCircle} size="36" /> DISABLED
|
||||
<Icon path={mdiAlertCircle} size="36" />
|
||||
{$t('disabled').toUpperCase()}
|
||||
</JobTileButton>
|
||||
{:else if !isIdle}
|
||||
{#if waitingCount > 0}
|
||||
<JobTileButton color="gray" on:click={() => dispatch('command', { command: JobCommand.Empty, force: false })}>
|
||||
<Icon path={mdiClose} size="24" /> CLEAR
|
||||
<Icon path={mdiClose} size="24" />
|
||||
{$t('clear').toUpperCase()}
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
{#if queueStatus.isPaused}
|
||||
@@ -134,14 +136,16 @@
|
||||
on:click={() => dispatch('command', { command: JobCommand.Resume, force: false })}
|
||||
>
|
||||
<!-- size property is not reactive, so have to use width and height -->
|
||||
<Icon path={mdiFastForward} {size} /> RESUME
|
||||
<Icon path={mdiFastForward} {size} />
|
||||
{$t('resume').toUpperCase()}
|
||||
</JobTileButton>
|
||||
{:else}
|
||||
<JobTileButton
|
||||
color="light-gray"
|
||||
on:click={() => dispatch('command', { command: JobCommand.Pause, force: false })}
|
||||
>
|
||||
<Icon path={mdiPause} size="24" /> PAUSE
|
||||
<Icon path={mdiPause} size="24" />
|
||||
{$t('pause').toUpperCase()}
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
{:else if allowForceCommand}
|
||||
@@ -161,7 +165,8 @@
|
||||
color="light-gray"
|
||||
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
|
||||
>
|
||||
<Icon path={mdiPlay} size="48" /> START
|
||||
<Icon path={mdiPlay} size="48" />
|
||||
{$t('start').toUpperCase()}
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
if (dto.force) {
|
||||
const isConfirmed = await dialogController.show({
|
||||
id: 'confirm-reprocess-all-faces',
|
||||
prompt: 'Are you sure you want to reprocess all faces? This will also clear named people.',
|
||||
prompt: $t('admin.confirm_reprocess_all_faces'),
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
@@ -60,23 +60,23 @@
|
||||
$: jobDetails = <Partial<Record<JobName, JobDetails>>>{
|
||||
[JobName.ThumbnailGeneration]: {
|
||||
icon: mdiFileJpgBox,
|
||||
title: getJobName(JobName.ThumbnailGeneration),
|
||||
title: $getJobName(JobName.ThumbnailGeneration),
|
||||
subtitle: $t('admin.thumbnail_generation_job_description'),
|
||||
},
|
||||
[JobName.MetadataExtraction]: {
|
||||
icon: mdiTable,
|
||||
title: getJobName(JobName.MetadataExtraction),
|
||||
title: $getJobName(JobName.MetadataExtraction),
|
||||
subtitle: $t('admin.metadata_extraction_job_description'),
|
||||
},
|
||||
[JobName.Library]: {
|
||||
icon: mdiLibraryShelves,
|
||||
title: getJobName(JobName.Library),
|
||||
title: $getJobName(JobName.Library),
|
||||
subtitle: $t('admin.library_tasks_description'),
|
||||
allText: $t('all').toUpperCase(),
|
||||
missingText: $t('refresh').toUpperCase(),
|
||||
},
|
||||
[JobName.Sidecar]: {
|
||||
title: getJobName(JobName.Sidecar),
|
||||
title: $getJobName(JobName.Sidecar),
|
||||
icon: mdiFileXmlBox,
|
||||
subtitle: $t('admin.sidecar_job_description'),
|
||||
allText: $t('sync').toUpperCase(),
|
||||
@@ -85,46 +85,44 @@
|
||||
},
|
||||
[JobName.SmartSearch]: {
|
||||
icon: mdiImageSearch,
|
||||
title: getJobName(JobName.SmartSearch),
|
||||
title: $getJobName(JobName.SmartSearch),
|
||||
subtitle: $t('admin.smart_search_job_description'),
|
||||
disabled: !$featureFlags.smartSearch,
|
||||
},
|
||||
[JobName.DuplicateDetection]: {
|
||||
icon: mdiContentDuplicate,
|
||||
title: getJobName(JobName.DuplicateDetection),
|
||||
title: $getJobName(JobName.DuplicateDetection),
|
||||
subtitle: $t('admin.duplicate_detection_job_description'),
|
||||
disabled: !$featureFlags.duplicateDetection,
|
||||
},
|
||||
[JobName.FaceDetection]: {
|
||||
icon: mdiFaceRecognition,
|
||||
title: getJobName(JobName.FaceDetection),
|
||||
subtitle:
|
||||
'Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. "All" (re-)processes all assets. "Missing" queues assets that haven\'t been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.',
|
||||
title: $getJobName(JobName.FaceDetection),
|
||||
subtitle: $t('admin.face_detection_description'),
|
||||
handleCommand: handleConfirmCommand,
|
||||
disabled: !$featureFlags.facialRecognition,
|
||||
},
|
||||
[JobName.FacialRecognition]: {
|
||||
icon: mdiTagFaces,
|
||||
title: getJobName(JobName.FacialRecognition),
|
||||
subtitle:
|
||||
'Group detected faces into people. This step runs after Face Detection is complete. "All" (re-)clusters all faces. "Missing" queues faces that don\'t have a person assigned.',
|
||||
title: $getJobName(JobName.FacialRecognition),
|
||||
subtitle: $t('admin.facial_recognition_job_description'),
|
||||
handleCommand: handleConfirmCommand,
|
||||
disabled: !$featureFlags.facialRecognition,
|
||||
},
|
||||
[JobName.VideoConversion]: {
|
||||
icon: mdiVideo,
|
||||
title: getJobName(JobName.VideoConversion),
|
||||
title: $getJobName(JobName.VideoConversion),
|
||||
subtitle: $t('admin.video_conversion_job_description'),
|
||||
},
|
||||
[JobName.StorageTemplateMigration]: {
|
||||
icon: mdiFolderMove,
|
||||
title: getJobName(JobName.StorageTemplateMigration),
|
||||
title: $getJobName(JobName.StorageTemplateMigration),
|
||||
allowForceCommand: false,
|
||||
description: StorageMigrationDescription,
|
||||
},
|
||||
[JobName.Migration]: {
|
||||
icon: mdiFolderMove,
|
||||
title: getJobName(JobName.Migration),
|
||||
title: $getJobName(JobName.Migration),
|
||||
subtitle: $t('admin.migration_job_description'),
|
||||
allowForceCommand: false,
|
||||
},
|
||||
@@ -140,14 +138,14 @@
|
||||
switch (jobCommand.command) {
|
||||
case JobCommand.Empty: {
|
||||
notificationController.show({
|
||||
message: `Cleared jobs for: ${title}`,
|
||||
message: $t('admin.cleared_jobs', { values: { job: title } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, `Command '${jobCommand.command}' failed for job: ${title}`);
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: jobCommand.command, job: title } }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
{#if user.quotaSizeInBytes}
|
||||
({((user.usage / user.quotaSizeInBytes) * 100).toFixed(0)}%)
|
||||
{:else}
|
||||
(Unlimited)
|
||||
({$t('unlimited')})
|
||||
{/if}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
message: 'Reset settings to the recent saved settings',
|
||||
message: $t('admin.reset_settings_to_recent_saved'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
};
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
message: $t('reset_settings_to_default'),
|
||||
message: $t('admin.reset_settings_to_default'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
},
|
||||
{
|
||||
value: TranscodeHWAccel.Disabled,
|
||||
text: $t('admin.disabled'),
|
||||
text: $t('disabled'),
|
||||
},
|
||||
]}
|
||||
isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import SettingInputField, {
|
||||
SettingInputFieldType,
|
||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let savedConfig: SystemConfigDto;
|
||||
export let defaultConfig: SystemConfigDto;
|
||||
@@ -45,7 +46,7 @@
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
{disabled}
|
||||
label="{getJobName(jobName)} Concurrency"
|
||||
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
||||
desc=""
|
||||
bind:value={config.job[jobName].concurrency}
|
||||
required={true}
|
||||
@@ -54,11 +55,11 @@
|
||||
{:else}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label="{getJobName(jobName)} Concurrency"
|
||||
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
||||
desc=""
|
||||
value="1"
|
||||
disabled={true}
|
||||
title="This job is not concurrency-safe."
|
||||
title={$t('admin.job_not_concurrency_safe')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
const substitutions: Record<string, string> = {
|
||||
filename: 'IMAGE_56437',
|
||||
ext: 'jpg',
|
||||
filetype: $t('img').toUpperCase(),
|
||||
filetype: 'IMG',
|
||||
filetypefull: 'IMAGE',
|
||||
assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
|
||||
album: $t('album_name'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user