1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 22:51:59 +00:00

Merge remote-tracking branch 'origin' into feat/inline-offline-check

This commit is contained in:
Jonathan Jogenfors 2024-12-13 01:48:36 +01:00
commit 3deeaad877
88 changed files with 1058 additions and 769 deletions

View file

@ -88,7 +88,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@v6.9.0
uses: docker/build-push-action@v6.10.0
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64

View file

@ -174,7 +174,7 @@ jobs:
fi
- name: Build and push image
uses: docker/build-push-action@v6.9.0
uses: docker/build-push-action@v6.10.0
with:
context: ${{ env.context }}
file: ${{ env.file }}
@ -265,7 +265,7 @@ jobs:
fi
- name: Build and push image
uses: docker/build-push-action@v6.9.0
uses: docker/build-push-action@v6.10.0
with:
context: ${{ env.context }}
file: ${{ env.file }}

View file

@ -1 +1 @@
22.11.0
22.12.0

6
cli/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.34",
"version": "2.2.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.34",
"version": "2.2.36",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.122.1",
"version": "1.122.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.34",
"version": "2.2.36",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@ -67,6 +67,6 @@
"lodash-es": "^4.17.21"
},
"volta": {
"node": "22.11.0"
"node": "22.12.0"
}
}

View file

@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.46.0"
constraints = "4.46.0"
version = "4.48.0"
constraints = "4.48.0"
hashes = [
"h1:3U4N3bbMacXTAdyaEwT305kETMETh1jZmGApmN6gdyE=",
"h1:3fhZhGNgtS9ugcZ2CIH6kk8LzN6yPxqOdkDUZqkP3+w=",
"h1:JWluJxBRSr8GVUhWVv83xse9SmbpwCLctCDddMXUnVk=",
"h1:KDHwakGt+3iBKXaoALCCAolPaJgpEHbkh3BfjnpuqoM=",
"h1:QFFZshAvwr9L5TQmsNQC6/sDqokk5pjbP8Ae4BQqMLQ=",
"h1:Qdi+vXwzDNii7ytSaOQtnlqhjZ3ZlRoUkFoi6CD2COI=",
"h1:TPcJXcVb/+C91hUuu8CEn98QUoNgLtnHfd4sgAOV+5k=",
"h1:WDy5wiNroXaCnw+r8rJnCP+J1RVsm2Qu3AOZ/iV4lLo=",
"h1:hMuL+dwHj3JbePqYcDrn/ZQN9R0WzeJX0AIDJ02Iteo=",
"h1:hQKCaUEARzJKbFt1CePP06E/+CiHWe/H6lc1AwK7y6w=",
"h1:l4DQ3WXmSzR/GBel3m2CRKWtaziVjBoxvUgL63t1GK0=",
"h1:nN9uVSLyrb/DjfZl6rPtCq5j0TX+6WypzNDexdzCQ08=",
"h1:rAX7njl6lKT9XIKMk6pLjVi7u/42wafRolWWgMHMkI0=",
"h1:t2IQYNu8YNykqYlEB+TTX+XpUd5z2flwGw8km9UgbnQ=",
"zh:2ee426ef3389022db0026792fdc4f2980dcf2600e31adf5a31b4bddfa8d68343",
"zh:2f993edb23df55dc1c18150fa187d80aa7d87e6439698ee34b6a6aad23ac2dd7",
"zh:3d6601333975e55979b1b454e50ff9a482ce4e0269dd6c72a50202163a8f4463",
"zh:4e5f48dce22f7a6d618018d65d1d443bb718defa23f514d5c6385860541fbe79",
"zh:5ebf5aea960fc30de381ffd6db20876d249673cf938fe67f1dfb6b9caa1db418",
"zh:80ed3fb901141f53b4b56ddb7eea5f2e0c0830d501387539d2c2b8e0cc7e587a",
"h1:0IKUOR32xEI1suS5QCOjfxjQ2mRd058btXk8hVnaOJ4=",
"h1:3YG6vu/bFPcYOeLdSUZhiAWiWKaFlOAR34z2o8cbE9k=",
"h1:FvGy06/i9AMtVkSIUnCrXNv5xF6jqBqMH8oPVLyeeAg=",
"h1:GXH7nIF0ocMqebbA41+fSGIYfM+VAM/PvTe7fJr8UrQ=",
"h1:H0ll0ph4404vFE868W3qJ3zhOyy4jbXrOMtdkViEZsU=",
"h1:SX42e3k73IcFcrQlZ2e/Veqt2tvCMy6fwlo5yNUktCE=",
"h1:Uu/gjBc99GefdPdSrlBwU75DWU0ZcwGcrd3ZFyTeL0s=",
"h1:VZw0uN41PWRmNlhg7Ze0Eh7cdoklX1oZbfNAXNYnU1I=",
"h1:cMdV7ql6PsFa4qtb0EoZSctvTaTqV7yplBSDwcLRCLc=",
"h1:ePGvSurmlqOCkD761vkhRmz7bsK36/EnIvx2Xy8TdXo=",
"h1:fOYufF+1bzw2N3aHLpkLB6E8VbZ4ysXDODYQOlwhwd4=",
"h1:qe8RbnWq0T4xhqjn9QcbO6YW5YDx47P+eJ0NUMIfwCc=",
"h1:tRD2av6PafHDP/b9jDQsG5/aX+lHeKxpbIEHYYLBVUc=",
"h1:zyl6Gvx/CFpwYW8pFFDesfO8Lxv+a6CopyAsIMhp54s=",
"zh:04c0a49c2b23140b2f21cfd0d52f9798d70d3bdae3831613e156aabe519bbc6c",
"zh:185f21b4834ba63e8df1f84aa34639d8a7e126429a4007bb5f9ad82f2602a997",
"zh:234724f52cb4c0c3f7313d3b2697caef26d921d134f26ae14801e7afac522f7b",
"zh:38a56fcd1b3e40706af995611c977816543b53f1e55fe2720944aae2b6828fcb",
"zh:419938f5430fc78eff933470aefbf94a460a478f867cf7761a3dea177b4eb153",
"zh:4b46d92bfde1deab7de7ba1a6bbf4ba7c711e4fd925341ddf09d4cc28dae03d8",
"zh:537acd4a31c752f1bae305ba7190f60b71ad1a459f22d464f3f914336c9e919f",
"zh:5ff36b005aad07697dd0b30d4f0c35dbcdc30dc52b41722552060792fa87ce04",
"zh:635c5ee419daea098060f794d9d7d999275301181e49562c4e4c08f043076937",
"zh:859277c330d61f91abe9e799389467ca11b77131bf34bedbef52f8da68b2bb49",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:9aeae8b3be4a577ced46987fd9159262c5b4c54a510f66592fbcdb40fef55b10",
"zh:a0479ef2d308c4a7894f1fe77467cd07e04c7b40d281088f4f204af1bdf94ac6",
"zh:a2bdc0c25130665af0b9559942b9813a1ba4889513e7185d4abc9c02e9bb99bd",
"zh:b10be9755fe80395ced6f0bbda38b8c8681714cf1eca1d895be239c75c2ffc2a",
"zh:ba3d55e722d9f48646574ce7c448f0084fe21fa884b5f8b6d6146a82a99c4baa",
"zh:ec1fd0ecaedc787a77d5342b51ae8dea8362a67f1e19123f6521a0e8e012d9e8",
"zh:ed49590e69faef14550179f965b4451b31415b8f6be6d33427ad48f65c76b6cf",
"zh:f4baa3a2dac719ad20dcfa525bc3f737ad95650b8d0de0c648dc9a87f993b2c3",
"zh:927dfdb8d9aef37ead03fceaa29e87ba076a3dd24e19b6cefdbb0efe9987ff8c",
"zh:bbf2226f07f6b1e721877328e69ded4b64f9c196634d2e2429e3cfabbe41e532",
"zh:daeed873d6f38604232b46ee4a5830c85d195b967f8dbcafe2fcffa98daf9c5f",
"zh:f8f2fc4646c1ba44085612fa7f4dbb7cbcead43b4e661f2b98ddfb4f68afc758",
]
}

View file

@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.46.0"
version = "4.48.0"
}
}
}

View file

@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.46.0"
constraints = "4.46.0"
version = "4.48.0"
constraints = "4.48.0"
hashes = [
"h1:3U4N3bbMacXTAdyaEwT305kETMETh1jZmGApmN6gdyE=",
"h1:3fhZhGNgtS9ugcZ2CIH6kk8LzN6yPxqOdkDUZqkP3+w=",
"h1:JWluJxBRSr8GVUhWVv83xse9SmbpwCLctCDddMXUnVk=",
"h1:KDHwakGt+3iBKXaoALCCAolPaJgpEHbkh3BfjnpuqoM=",
"h1:QFFZshAvwr9L5TQmsNQC6/sDqokk5pjbP8Ae4BQqMLQ=",
"h1:Qdi+vXwzDNii7ytSaOQtnlqhjZ3ZlRoUkFoi6CD2COI=",
"h1:TPcJXcVb/+C91hUuu8CEn98QUoNgLtnHfd4sgAOV+5k=",
"h1:WDy5wiNroXaCnw+r8rJnCP+J1RVsm2Qu3AOZ/iV4lLo=",
"h1:hMuL+dwHj3JbePqYcDrn/ZQN9R0WzeJX0AIDJ02Iteo=",
"h1:hQKCaUEARzJKbFt1CePP06E/+CiHWe/H6lc1AwK7y6w=",
"h1:l4DQ3WXmSzR/GBel3m2CRKWtaziVjBoxvUgL63t1GK0=",
"h1:nN9uVSLyrb/DjfZl6rPtCq5j0TX+6WypzNDexdzCQ08=",
"h1:rAX7njl6lKT9XIKMk6pLjVi7u/42wafRolWWgMHMkI0=",
"h1:t2IQYNu8YNykqYlEB+TTX+XpUd5z2flwGw8km9UgbnQ=",
"zh:2ee426ef3389022db0026792fdc4f2980dcf2600e31adf5a31b4bddfa8d68343",
"zh:2f993edb23df55dc1c18150fa187d80aa7d87e6439698ee34b6a6aad23ac2dd7",
"zh:3d6601333975e55979b1b454e50ff9a482ce4e0269dd6c72a50202163a8f4463",
"zh:4e5f48dce22f7a6d618018d65d1d443bb718defa23f514d5c6385860541fbe79",
"zh:5ebf5aea960fc30de381ffd6db20876d249673cf938fe67f1dfb6b9caa1db418",
"zh:80ed3fb901141f53b4b56ddb7eea5f2e0c0830d501387539d2c2b8e0cc7e587a",
"h1:0IKUOR32xEI1suS5QCOjfxjQ2mRd058btXk8hVnaOJ4=",
"h1:3YG6vu/bFPcYOeLdSUZhiAWiWKaFlOAR34z2o8cbE9k=",
"h1:FvGy06/i9AMtVkSIUnCrXNv5xF6jqBqMH8oPVLyeeAg=",
"h1:GXH7nIF0ocMqebbA41+fSGIYfM+VAM/PvTe7fJr8UrQ=",
"h1:H0ll0ph4404vFE868W3qJ3zhOyy4jbXrOMtdkViEZsU=",
"h1:SX42e3k73IcFcrQlZ2e/Veqt2tvCMy6fwlo5yNUktCE=",
"h1:Uu/gjBc99GefdPdSrlBwU75DWU0ZcwGcrd3ZFyTeL0s=",
"h1:VZw0uN41PWRmNlhg7Ze0Eh7cdoklX1oZbfNAXNYnU1I=",
"h1:cMdV7ql6PsFa4qtb0EoZSctvTaTqV7yplBSDwcLRCLc=",
"h1:ePGvSurmlqOCkD761vkhRmz7bsK36/EnIvx2Xy8TdXo=",
"h1:fOYufF+1bzw2N3aHLpkLB6E8VbZ4ysXDODYQOlwhwd4=",
"h1:qe8RbnWq0T4xhqjn9QcbO6YW5YDx47P+eJ0NUMIfwCc=",
"h1:tRD2av6PafHDP/b9jDQsG5/aX+lHeKxpbIEHYYLBVUc=",
"h1:zyl6Gvx/CFpwYW8pFFDesfO8Lxv+a6CopyAsIMhp54s=",
"zh:04c0a49c2b23140b2f21cfd0d52f9798d70d3bdae3831613e156aabe519bbc6c",
"zh:185f21b4834ba63e8df1f84aa34639d8a7e126429a4007bb5f9ad82f2602a997",
"zh:234724f52cb4c0c3f7313d3b2697caef26d921d134f26ae14801e7afac522f7b",
"zh:38a56fcd1b3e40706af995611c977816543b53f1e55fe2720944aae2b6828fcb",
"zh:419938f5430fc78eff933470aefbf94a460a478f867cf7761a3dea177b4eb153",
"zh:4b46d92bfde1deab7de7ba1a6bbf4ba7c711e4fd925341ddf09d4cc28dae03d8",
"zh:537acd4a31c752f1bae305ba7190f60b71ad1a459f22d464f3f914336c9e919f",
"zh:5ff36b005aad07697dd0b30d4f0c35dbcdc30dc52b41722552060792fa87ce04",
"zh:635c5ee419daea098060f794d9d7d999275301181e49562c4e4c08f043076937",
"zh:859277c330d61f91abe9e799389467ca11b77131bf34bedbef52f8da68b2bb49",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:9aeae8b3be4a577ced46987fd9159262c5b4c54a510f66592fbcdb40fef55b10",
"zh:a0479ef2d308c4a7894f1fe77467cd07e04c7b40d281088f4f204af1bdf94ac6",
"zh:a2bdc0c25130665af0b9559942b9813a1ba4889513e7185d4abc9c02e9bb99bd",
"zh:b10be9755fe80395ced6f0bbda38b8c8681714cf1eca1d895be239c75c2ffc2a",
"zh:ba3d55e722d9f48646574ce7c448f0084fe21fa884b5f8b6d6146a82a99c4baa",
"zh:ec1fd0ecaedc787a77d5342b51ae8dea8362a67f1e19123f6521a0e8e012d9e8",
"zh:ed49590e69faef14550179f965b4451b31415b8f6be6d33427ad48f65c76b6cf",
"zh:f4baa3a2dac719ad20dcfa525bc3f737ad95650b8d0de0c648dc9a87f993b2c3",
"zh:927dfdb8d9aef37ead03fceaa29e87ba076a3dd24e19b6cefdbb0efe9987ff8c",
"zh:bbf2226f07f6b1e721877328e69ded4b64f9c196634d2e2429e3cfabbe41e532",
"zh:daeed873d6f38604232b46ee4a5830c85d195b967f8dbcafe2fcffa98daf9c5f",
"zh:f8f2fc4646c1ba44085612fa7f4dbb7cbcead43b4e661f2b98ddfb4f68afc758",
]
}

View file

@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.46.0"
version = "4.48.0"
}
}
}

View file

@ -91,7 +91,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f
image: prom/prometheus@sha256:565ee86501224ebbb98fc10b332fa54440b100469924003359edf49cbce374bd
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@ -103,7 +103,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:11.3.1-ubuntu@sha256:7ca40d20250157abd70a907a93617a70c9b0ad9d7e59e8e6b5c8140781350d6a
image: grafana/grafana:11.4.0-ubuntu@sha256:afccec22ba0e4815cca1d2bf3836e414322390dc78d77f1851976ffa8d61051c
volumes:
- grafana-data:/var/lib/grafana

View file

@ -1 +1 @@
22.11.0
22.12.0

View file

@ -65,12 +65,17 @@ docker compose up -d # Start remainder of Immich apps
docker compose down -v # CAUTION! Deletes all Immich data to start from scratch
## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database
# Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch
## You should mount the backup (as a volume, example: - 'C:\path\to\backup\dump.sql':/dump.sql) into the immich_postgres container using the docker-compose.yml
docker compose pull # Update to latest version of Immich (if desired)
docker compose create # Create Docker containers for Immich apps without running them
docker start immich_postgres # Start Postgres server
sleep 10 # Wait for Postgres server to start up
docker exec -it immich_postgres bash # Enter the Docker shell and run the following command
# Check the database user if you deviated from the default
gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres # Restore Backup
cat "/dump.sql" \
| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \
| psql --username=postgres # Restore Backup
exit # Exit the Docker shell
docker compose up -d # Start remainder of Immich apps
```

View file

@ -18,8 +18,11 @@ Immich requires the command `docker compose` - the similarly named `docker-compo
## Hardware
- **OS**: Recommended Linux operating system (Ubuntu, Debian, etc).
- Windows is supported with [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/) or [WSL 2](https://docs.docker.com/desktop/wsl/).
- macOS is supported with [Docker Desktop on Mac](https://docs.docker.com/desktop/install/mac-install/).
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
If you still want to try to use a non-Linux OS, you can set it up as follows:
- Windows: [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/) or [WSL 2](https://docs.docker.com/desktop/wsl/).
- macOS: [Docker Desktop on Mac](https://docs.docker.com/desktop/install/mac-install/).
- **RAM**: Minimum 4GB, recommended 6GB.
- **CPU**: Minimum 2 cores, recommended 4 cores.
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.

View file

@ -56,6 +56,6 @@
"node": ">=20"
},
"volta": {
"node": "22.11.0"
"node": "22.12.0"
}
}

View file

@ -89,6 +89,11 @@ const projects: CommunityProjectProps[] = [
'Share your Immich photos and albums in a safe way without exposing your Immich instance to the public.',
url: 'https://github.com/alangrainger/immich-public-proxy',
},
{
title: 'Immich Kodi',
description: 'Unofficial Kodi plugin for Immich.',
url: 'https://github.com/vladd11/immich-kodi',
},
];
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {

View file

@ -1,4 +1,12 @@
[
{
"label": "v1.122.3",
"url": "https://v1.122.3.archive.immich.app"
},
{
"label": "v1.122.2",
"url": "https://v1.122.2.archive.immich.app"
},
{
"label": "v1.122.1",
"url": "https://v1.122.1.archive.immich.app"

View file

@ -1 +1 @@
22.11.0
22.12.0

8
e2e/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.122.1",
"version": "1.122.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.122.1",
"version": "1.122.3",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.34",
"version": "2.2.36",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.122.1",
"version": "1.122.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.122.1",
"version": "1.122.3",
"description": "",
"main": "index.js",
"type": "module",
@ -53,6 +53,6 @@
"vitest": "^2.0.5"
},
"volta": {
"node": "22.11.0"
"node": "22.12.0"
}
}

View file

@ -98,6 +98,7 @@ describe('/search', () => {
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
{ latitude: 0, longitude: 0 }, // null island
];
const updates = coordinates.map((dto, i) =>
@ -532,7 +533,7 @@ describe('/search', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it('should get suggestions for country', async () => {
it('should get suggestions for country (including null)', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=country&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -555,7 +556,29 @@ describe('/search', () => {
expect(status).toBe(200);
});
it('should get suggestions for state', async () => {
it('should get suggestions for country', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=country')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Cuba',
'France',
'Georgia',
'Germany',
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
]);
expect(status).toBe(200);
});
it('should get suggestions for state (including null)', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=state&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -579,7 +602,30 @@ describe('/search', () => {
expect(status).toBe(200);
});
it('should get suggestions for city', async () => {
it('should get suggestions for state', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=state')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Andalusia',
'Berlin',
'Glarus',
'Greater Accra',
'Havana',
'Île-de-France',
'Marrakesh-Safi',
'Mississippi',
'New York',
'Shanghai',
'St.-Petersburg',
'Tbilisi',
'Tokyo',
'Virginia',
]);
expect(status).toBe(200);
});
it('should get suggestions for city (including null)', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=city&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -604,7 +650,31 @@ describe('/search', () => {
expect(status).toBe(200);
});
it('should get suggestions for camera make', async () => {
it('should get suggestions for city', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=city')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Accra',
'Berlin',
'Glarus',
'Havana',
'Marrakesh',
'Montalbán de Córdoba',
'New York City',
'Novena',
'Paris',
'Philadelphia',
'Saint Petersburg',
'Shanghai',
'Stanley',
'Tbilisi',
'Tokyo',
]);
expect(status).toBe(200);
});
it('should get suggestions for camera make (including null)', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=camera-make&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -621,7 +691,23 @@ describe('/search', () => {
expect(status).toBe(200);
});
it('should get suggestions for camera model', async () => {
it('should get suggestions for camera make', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=camera-make')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Apple',
'Canon',
'FUJIFILM',
'NIKON CORPORATION',
'PENTAX Corporation',
'samsung',
'SONY',
]);
expect(status).toBe(200);
});
it('should get suggestions for camera model (including null)', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=camera-model&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -642,5 +728,26 @@ describe('/search', () => {
]);
expect(status).toBe(200);
});
it('should get suggestions for camera model', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=camera-model')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Canon EOS 7D',
'Canon EOS R5',
'DSLR-A550',
'FinePix S3Pro',
'iPhone 7',
'NIKON D700',
'NIKON D750',
'NIKON D80',
'PENTAX K10D',
'SM-F711N',
'SM-S906U',
'SM-T970',
]);
expect(status).toBe(200);
});
});
});

1
i18n/bn.json Normal file
View file

@ -0,0 +1 @@
{}

1
i18n/ur.json Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -575,63 +575,73 @@ test-no-images = ["pytest", "pytest-cov", "pytest-xdist", "wurlitzer"]
[[package]]
name = "coverage"
version = "7.4.0"
version = "7.6.4"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"},
{file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"},
{file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"},
{file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"},
{file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"},
{file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"},
{file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"},
{file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"},
{file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"},
{file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"},
{file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"},
{file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"},
{file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"},
{file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"},
{file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"},
{file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"},
{file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"},
{file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"},
{file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"},
{file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"},
{file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"},
{file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"},
{file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"},
{file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"},
{file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"},
{file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"},
{file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"},
{file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"},
{file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"},
{file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"},
{file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"},
{file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"},
{file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"},
{file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"},
{file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"},
{file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"},
{file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"},
{file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"},
{file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"},
{file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"},
{file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"},
{file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"},
{file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"},
{file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"},
{file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"},
{file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"},
{file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"},
{file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"},
{file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"},
{file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"},
{file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"},
{file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"},
{file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"},
{file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"},
{file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"},
{file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"},
{file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"},
{file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"},
{file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"},
{file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"},
{file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"},
{file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"},
{file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"},
{file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"},
{file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"},
{file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"},
{file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"},
{file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"},
{file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"},
{file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"},
{file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"},
{file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"},
{file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"},
{file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"},
{file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"},
{file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"},
{file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"},
{file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"},
]
[package.dependencies]
@ -2683,17 +2693,17 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "5.0.0"
version = "6.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
{file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
{file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"},
{file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"},
]
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
coverage = {version = ">=7.5", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
@ -2746,13 +2756,13 @@ cli = ["click (>=5.0)"]
[[package]]
name = "python-multipart"
version = "0.0.17"
version = "0.0.19"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"},
{file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"},
{file = "python_multipart-0.0.19-py3-none-any.whl", hash = "sha256:f8d5b0b9c618575bf9df01c684ded1d94a338839bdd8223838afacfb4bb2082d"},
{file = "python_multipart-0.0.19.tar.gz", hash = "sha256:905502ef39050557b7a6af411f454bc19526529ca46ae6831508438890ce12cc"},
]
[[package]]

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.122.1"
version = "1.122.3"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View file

@ -37,7 +37,7 @@
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" />
android:value="false" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 169,
"android.injected.version.name" => "1.122.1",
"android.injected.version.code" => 171,
"android.injected.version.name" => "1.122.3",
}
)
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')

View file

@ -58,7 +58,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.122.0</string>
<string>1.122.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.122.1"
version_number: "1.122.3"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View file

@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
enum ImmichColorPreset {
indigo,
deepPurple,
pink,
red,
orange,
yellow,
lime,
green,
cyan,
slateGray
}
const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo;
const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color red400 = Color(0xFFEF5350);
const Color grey200 = Color(0xFFEEEEEE);

View file

@ -4,23 +4,26 @@ import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:timezone/data/latest.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@ -30,16 +33,15 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart';
void main() async {
ImmichWidgetsBinding();
@ -69,12 +71,12 @@ Future<void> initApp() async {
}
}
await fetchSystemPalette();
await DynamicTheme.fetchSystemPalette();
// Initialize Immich Logger Service
ImmichLogger();
var log = Logger("ImmichErrorLogger");
final log = Logger("ImmichErrorLogger");
FlutterError.onError = (details) {
FlutterError.presentError(details);

View file

@ -61,6 +61,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
final loadAsset = renderList.loadAsset;
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
Future<void> precacheNextImage(int index) async {
if (!context.mounted) {
@ -249,7 +250,6 @@ class GalleryViewerPage extends HookConsumerWidget {
}
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
var newAsset = loadAsset(index);
final stackId = newAsset.stackId;
if (stackId != null && currentIndex.value == index) {
@ -260,7 +260,7 @@ class GalleryViewerPage extends HookConsumerWidget {
}
}
if (newAsset.isImage && !newAsset.isMotionPhoto) {
if (newAsset.isImage && !isPlayingMotionVideo) {
return buildImage(context, newAsset);
}
return buildVideo(context, newAsset);
@ -275,7 +275,7 @@ class GalleryViewerPage extends HookConsumerWidget {
body: Stack(
children: [
PhotoViewGallery.builder(
key: const ValueKey('gallery'),
key: ValueKey(isPlayingMotionVideo),
scaleStateChangedCallback: (state) {
final asset = ref.read(currentAssetProvider);
if (asset == null) {

View file

@ -40,7 +40,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
final showMotionVideo = useState(false);
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
@ -50,30 +49,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
final isCurrent = currentAsset.value == asset;
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible =
useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto);
final isVisible = useState(Platform.isIOS && asset.isLocal);
final log = Logger('NativeVideoViewerPage');
ref.listen(isPlayingMotionVideoProvider, (_, value) async {
final videoController = controller.value;
if (!asset.isMotionPhoto || videoController == null || !context.mounted) {
return;
}
showMotionVideo.value = value;
try {
if (value) {
await videoController.seekTo(0);
await videoController.play();
} else {
await videoController.pause();
}
} catch (error) {
log.severe('Error toggling motion video: $error');
}
});
Future<VideoSource?> createSource() async {
if (!context.mounted) {
return null;
@ -81,7 +60,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
try {
final local = asset.local;
if (local != null && !asset.isMotionPhoto) {
if (local != null) {
final file = await local.file;
if (file == null) {
throw Exception('No file found for the video');
@ -204,9 +183,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
try {
if (asset.isVideo || showMotionVideo.value) {
await videoController.play();
}
await videoController.play();
await videoController.setVolume(0.9);
} catch (error) {
log.severe('Error playing video: $error');
@ -268,8 +245,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return;
}
if (showMotionVideo.value &&
videoController.playbackInfo?.status == PlaybackStatus.stopped &&
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
!ref
.read(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.loopVideo)) {
@ -388,8 +364,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
if (aspectRatio.value != null)
Visibility.maintain(
key: ValueKey(asset),
visible:
(asset.isVideo || showMotionVideo.value) && isVisible.value,
visible: isVisible.value,
child: Center(
key: ValueKey(asset),
child: AspectRatio(

View file

@ -133,6 +133,7 @@ class _MobileLayout extends StatelessWidget {
).tr(),
subtitle: Text(
setting.subtitle,
style: context.textTheme.labelLarge,
).tr(),
onTap: () =>
context.pushRoute(SettingsSubRoute(section: setting)),

View file

@ -264,7 +264,7 @@ class MapPage extends HookConsumerWidget {
selectedAssets.value = selected ? selection : {};
}
return MapThemeOveride(
return MapThemeOverride(
mapBuilder: (style) => context.isMobile
// Single-column
? Scaffold(

View file

@ -58,7 +58,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
}
return MapThemeOveride(
return MapThemeOverride(
mapBuilder: (style) => Builder(
builder: (ctx) => Scaffold(
backgroundColor: ctx.themeData.cardColor,

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
final themeMode = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.themeMode);
debugPrint("Current themeMode $themeMode");
if (themeMode == ThemeMode.light.name) {
return ThemeMode.light;
} else if (themeMode == ThemeMode.dark.name) {
return ThemeMode.dark;
} else {
return ThemeMode.system;
}
});
final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
final appSettingsProvider = ref.watch(appSettingsServiceProvider);
final primaryColorPreset =
appSettingsProvider.getSetting(AppSettingsEnum.primaryColor);
debugPrint("Current theme preset $primaryColorPreset");
try {
return ImmichColorPreset.values
.firstWhere((e) => e.name == primaryColorPreset);
} catch (e) {
debugPrint(
"Theme preset $primaryColorPreset not found. Applying default preset.",
);
appSettingsProvider.setSetting(
AppSettingsEnum.primaryColor,
defaultColorPresetName,
);
return defaultColorPreset;
}
});
final dynamicThemeSettingProvider = StateProvider<bool>((ref) {
return ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.dynamicTheme);
});
final colorfulInterfaceSettingProvider = StateProvider<bool>((ref) {
return ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.colorfulInterface);
});
// Provider for current selected theme
final immichThemeProvider = StateProvider<ImmichTheme>((ref) {
final primaryColorPreset = ref.read(immichThemePresetProvider);
final useSystemColor = ref.watch(dynamicThemeSettingProvider);
final useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider);
final ImmichTheme? dynamicTheme = DynamicTheme.theme;
final currentTheme = (useSystemColor && dynamicTheme != null)
? dynamicTheme
: primaryColorPreset.themeOfPreset;
return useColorfulInterface
? currentTheme
: decolorizeSurfaces(theme: currentTheme);
});

View file

@ -1,4 +1,4 @@
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {

View file

@ -1,29 +1,8 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/theme/theme_data.dart';
enum ImmichColorPreset {
indigo,
deepPurple,
pink,
red,
orange,
yellow,
lime,
green,
cyan,
slateGray
}
const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo;
const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color red400 = Color(0xFFEF5350);
const Color grey200 = Color(0xFFEEEEEE);
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
final Map<ImmichColorPreset, ImmichTheme> _themePresets = {
ImmichColorPreset.indigo: ImmichTheme(
light: ColorScheme.fromSeed(
seedColor: immichBrandColorLight,
@ -110,5 +89,5 @@ final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
};
extension ImmichColorModeExtension on ImmichColorPreset {
ImmichTheme getTheme() => _themePresetsMap[this]!;
ImmichTheme get themeOfPreset => _themePresets[this]!;
}

View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:immich_mobile/theme/theme_data.dart';
abstract final class DynamicTheme {
DynamicTheme._();
static ImmichTheme? _theme;
// Method to fetch dynamic system colors
static Future<void> fetchSystemPalette() async {
try {
final corePalette = await DynamicColorPlugin.getCorePalette();
if (corePalette != null) {
final primaryColor = corePalette.toColorScheme().primary;
debugPrint('dynamic_color: Core palette detected.');
// Some palettes do not generate surface container colors accurately,
// so we regenerate all colors using the primary color
_theme = ImmichTheme(
light: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.light,
),
dark: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.dark,
),
);
}
} catch (error) {
debugPrint('dynamic_color: Failed to obtain core palette: $error');
}
}
static ImmichTheme? get theme => _theme;
static bool get isAvailable => _theme != null;
}

View file

@ -1,11 +1,7 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
class ImmichTheme {
final ColorScheme light;
@ -14,104 +10,166 @@ class ImmichTheme {
const ImmichTheme({required this.light, required this.dark});
}
ImmichTheme? _immichDynamicTheme;
bool get isDynamicThemeAvailable => _immichDynamicTheme != null;
ThemeData getThemeData({
required ColorScheme colorScheme,
required Locale locale,
}) {
final isDark = colorScheme.brightness == Brightness.dark;
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
var themeMode = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.themeMode);
debugPrint("Current themeMode $themeMode");
if (themeMode == "light") {
return ThemeMode.light;
} else if (themeMode == "dark") {
return ThemeMode.dark;
} else {
return ThemeMode.system;
}
});
final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
var appSettingsProvider = ref.watch(appSettingsServiceProvider);
var primaryColorName =
appSettingsProvider.getSetting(AppSettingsEnum.primaryColor);
debugPrint("Current theme preset $primaryColorName");
try {
return ImmichColorPreset.values
.firstWhere((e) => e.name == primaryColorName);
} catch (e) {
debugPrint(
"Theme preset $primaryColorName not found. Applying default preset.",
);
appSettingsProvider.setSetting(
AppSettingsEnum.primaryColor,
defaultColorPresetName,
);
return defaultColorPreset;
}
});
final dynamicThemeSettingProvider = StateProvider<bool>((ref) {
return ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.dynamicTheme);
});
final colorfulInterfaceSettingProvider = StateProvider<bool>((ref) {
return ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.colorfulInterface);
});
// Provider for current selected theme
final immichThemeProvider = StateProvider<ImmichTheme>((ref) {
var primaryColor = ref.read(immichThemePresetProvider);
var useSystemColor = ref.watch(dynamicThemeSettingProvider);
var useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider);
var currentTheme = (useSystemColor && _immichDynamicTheme != null)
? _immichDynamicTheme!
: primaryColor.getTheme();
return useColorfulInterface
? currentTheme
: _decolorizeSurfaces(theme: currentTheme);
});
// Method to fetch dynamic system colors
Future<void> fetchSystemPalette() async {
try {
final corePalette = await DynamicColorPlugin.getCorePalette();
if (corePalette != null) {
final primaryColor = corePalette.toColorScheme().primary;
debugPrint('dynamic_color: Core palette detected.');
// Some palettes do not generate surface container colors accurately,
// so we regenerate all colors using the primary color
_immichDynamicTheme = ImmichTheme(
light: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.light,
return ThemeData(
useMaterial3: true,
brightness: colorScheme.brightness,
colorScheme: colorScheme,
primaryColor: colorScheme.primary,
hintColor: colorScheme.onSurfaceSecondary,
focusColor: colorScheme.primary,
scaffoldBackgroundColor: colorScheme.surface,
splashColor: colorScheme.primary.withOpacity(0.1),
highlightColor: colorScheme.primary.withOpacity(0.1),
dialogBackgroundColor: colorScheme.surfaceContainer,
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: colorScheme.surfaceContainer,
),
fontFamily: _getFontFamilyFromLocale(locale),
snackBarTheme: SnackBarThemeData(
contentTextStyle: TextStyle(
fontFamily: _getFontFamilyFromLocale(locale),
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
backgroundColor: colorScheme.surfaceContainerHighest,
),
appBarTheme: AppBarTheme(
titleTextStyle: TextStyle(
color: colorScheme.primary,
fontFamily: _getFontFamilyFromLocale(locale),
fontWeight: FontWeight.bold,
fontSize: 18,
),
backgroundColor:
isDark ? colorScheme.surfaceContainer : colorScheme.surface,
foregroundColor: colorScheme.primary,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
),
textTheme: const TextTheme(
displayLarge: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
),
displayMedium: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
displaySmall: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
titleSmall: TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.bold,
),
titleMedium: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
titleLarge: TextStyle(
fontSize: 26.0,
fontWeight: FontWeight.bold,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: isDark ? Colors.black87 : Colors.white,
),
),
chipTheme: const ChipThemeData(
side: BorderSide.none,
),
sliderTheme: const SliderThemeData(
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
trackHeight: 2.0,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
),
popupMenuTheme: const PopupMenuThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor:
isDark ? colorScheme.surfaceContainer : colorScheme.surface,
labelTextStyle: const WidgetStatePropertyAll(
TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
dark: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.dark,
),
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.primary,
),
);
}
} catch (e) {
debugPrint('dynamic_color: Failed to obtain core palette.');
}
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
labelStyle: TextStyle(
color: colorScheme.primary,
),
hintStyle: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.normal,
),
),
textSelectionTheme: TextSelectionThemeData(
cursorColor: colorScheme.primary,
),
dropdownMenuTheme: DropdownMenuThemeData(
menuStyle: const MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
),
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.primary,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
labelStyle: TextStyle(
color: colorScheme.primary,
),
hintStyle: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.normal,
),
),
),
);
}
// This method replaces all surface shades in ImmichTheme to a static ones
// as we are creating the colorscheme through seedColor the default surfaces are
// tinted with primary color
ImmichTheme _decolorizeSurfaces({
ImmichTheme decolorizeSurfaces({
required ImmichTheme theme,
}) {
return ImmichTheme(
@ -146,167 +204,10 @@ ImmichTheme _decolorizeSurfaces({
);
}
String? getFontFamilyFromLocale(Locale locale) {
String? _getFontFamilyFromLocale(Locale locale) {
if (localesNotSupportedByOverpass.contains(locale)) {
// Let Flutter use the default font
return null;
}
return 'Overpass';
}
ThemeData getThemeData({
required ColorScheme colorScheme,
required Locale locale,
}) {
var isDark = colorScheme.brightness == Brightness.dark;
var primaryColor = colorScheme.primary;
return ThemeData(
useMaterial3: true,
brightness: colorScheme.brightness,
colorScheme: colorScheme,
primaryColor: primaryColor,
hintColor: colorScheme.onSurfaceSecondary,
focusColor: primaryColor,
scaffoldBackgroundColor: colorScheme.surface,
splashColor: primaryColor.withOpacity(0.1),
highlightColor: primaryColor.withOpacity(0.1),
dialogBackgroundColor: colorScheme.surfaceContainer,
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: colorScheme.surfaceContainer,
),
fontFamily: getFontFamilyFromLocale(locale),
snackBarTheme: SnackBarThemeData(
contentTextStyle: TextStyle(
fontFamily: getFontFamilyFromLocale(locale),
color: primaryColor,
fontWeight: FontWeight.bold,
),
backgroundColor: colorScheme.surfaceContainerHighest,
),
appBarTheme: AppBarTheme(
titleTextStyle: TextStyle(
color: primaryColor,
fontFamily: getFontFamilyFromLocale(locale),
fontWeight: FontWeight.bold,
fontSize: 18,
),
backgroundColor:
isDark ? colorScheme.surfaceContainer : colorScheme.surface,
foregroundColor: primaryColor,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
),
textTheme: const TextTheme(
displayLarge: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
),
displayMedium: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
displaySmall: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
titleSmall: TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.bold,
),
titleMedium: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
titleLarge: TextStyle(
fontSize: 26.0,
fontWeight: FontWeight.bold,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: isDark ? Colors.black87 : Colors.white,
),
),
chipTheme: const ChipThemeData(
side: BorderSide.none,
),
sliderTheme: const SliderThemeData(
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
trackHeight: 2.0,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
),
popupMenuTheme: const PopupMenuThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor:
isDark ? colorScheme.surfaceContainer : colorScheme.surface,
labelTextStyle: const WidgetStatePropertyAll(
TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: primaryColor,
),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
labelStyle: TextStyle(
color: primaryColor,
),
hintStyle: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.normal,
),
),
textSelectionTheme: TextSelectionThemeData(
cursorColor: primaryColor,
),
dropdownMenuTheme: DropdownMenuThemeData(
menuStyle: MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: primaryColor,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
labelStyle: TextStyle(
color: primaryColor,
),
hintStyle: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.normal,
),
),
),
);
}

View file

@ -13,6 +13,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
@ -206,6 +207,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
heroOffset: widget.heroOffset,
onAssetTap: (asset) {
ref.read(currentAssetProvider.notifier).set(asset);
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
if (asset.isVideo) {
ref.read(showControlsProvider.notifier).show = false;
}

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoButton extends ConsumerWidget {

View file

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';

View file

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/error_chip_text.dart';

View file

@ -1,7 +1,7 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
class BackupErrorChipText extends ConsumerWidget {

View file

@ -3,21 +3,22 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/theme/theme_data.dart';
/// Overrides the theme below the widget tree to use the theme data based on the
/// map settings instead of the one from the app settings
class MapThemeOveride extends StatefulHookConsumerWidget {
class MapThemeOverride extends StatefulHookConsumerWidget {
final ThemeMode? themeMode;
final Widget Function(AsyncValue<String> style) mapBuilder;
const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key});
const MapThemeOverride({required this.mapBuilder, this.themeMode, super.key});
@override
ConsumerState createState() => _MapThemeOverideState();
ConsumerState createState() => _MapThemeOverrideState();
}
class _MapThemeOverideState extends ConsumerState<MapThemeOveride>
class _MapThemeOverrideState extends ConsumerState<MapThemeOverride>
with WidgetsBindingObserver {
late ThemeMode _theme;
bool _isDarkTheme = false;

View file

@ -62,7 +62,7 @@ class MapThumbnail extends HookConsumerWidget {
}
}
return MapThemeOveride(
return MapThemeOverride(
themeMode: themeMode,
mapBuilder: (style) => SizedBox(
height: height,

View file

@ -2,12 +2,14 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart';
class PrimaryColorSetting extends HookConsumerWidget {
const PrimaryColorSetting({
@ -124,7 +126,7 @@ class PrimaryColorSetting extends HookConsumerWidget {
style: context.textTheme.titleLarge,
),
),
if (isDynamicThemeAvailable)
if (DynamicTheme.isAvailable)
Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
margin: const EdgeInsets.only(top: 10),
@ -153,16 +155,16 @@ class PrimaryColorSetting extends HookConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: ImmichColorPreset.values.map((themePreset) {
var theme = themePreset.getTheme();
children: ImmichColorPreset.values.map((preset) {
final theme = preset.themeOfPreset;
return GestureDetector(
onTap: () => onPrimaryColorChange(themePreset),
onTap: () => onPrimaryColorChange(preset),
child: buildPrimaryColorTile(
topColor: theme.light.primary,
bottomColor: theme.dark.primary,
tileSize: tileSize,
showSelector: currentPreset.value == themePreset &&
showSelector: currentPreset.value == preset &&
!systemPrimaryColorSetting.value,
),
);

View file

@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
class ThemeSetting extends HookConsumerWidget {
const ThemeSetting({

BIN
mobile/openapi/README.md generated

Binary file not shown.

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.122.1+169
version: 1.122.3+171
environment:
sdk: '>=3.3.0 <4.0.0'

View file

@ -35,7 +35,7 @@ void main() {
(tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
MapThemeOverride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
@ -53,7 +53,7 @@ void main() {
testWidgets("Return error when style is not fetched", (tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
MapThemeOverride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
@ -73,7 +73,7 @@ void main() {
(tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
MapThemeOverride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
@ -94,7 +94,7 @@ void main() {
testWidgets("Return dark theme style when system is dark", (tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
MapThemeOverride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
@ -118,7 +118,7 @@ void main() {
(tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
MapThemeOverride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
@ -142,7 +142,7 @@ void main() {
(tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
MapThemeOverride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");

View file

@ -7436,7 +7436,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.122.1",
"version": "1.122.3",
"contact": {}
},
"tags": [],

View file

@ -1 +1 @@
22.11.0
22.12.0

View file

@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.122.1",
"version": "1.122.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.122.1",
"version": "1.122.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View file

@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.122.1",
"version": "1.122.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "22.11.0"
"node": "22.12.0"
}
}

View file

@ -1,6 +1,6 @@
/**
* Immich
* 1.122.1
* 1.122.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View file

@ -1 +1 @@
22.11.0
22.12.0

View file

@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20241119@sha256:fef1bead6a594ebd6fa54712c3dc4db050173657738db0c21bb91b00f8b56320 AS dev
FROM ghcr.io/immich-app/base-server-dev:20241210@sha256:35c28404b508fc0741fffb39a9de17e3a6acdff0b623bb7b6cdf4a427462a0e2 AS dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@ -42,7 +42,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20241119@sha256:0ab6c3d0d41924fba45f92c383bcf405abda338602d1140d151963bbbb088759
FROM ghcr.io/immich-app/base-server-prod:20241210@sha256:076d002070385bc6dc7454ef9419f44341c074bcfc49be5deddbdb4108ae0060
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View file

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.122.1",
"version": "1.122.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.122.1",
"version": "1.122.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^10.0.1",

View file

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.122.1",
"version": "1.122.3",
"description": "",
"author": "",
"private": true,
@ -139,6 +139,6 @@
"vitest": "^2.0.5"
},
"volta": {
"node": "22.11.0"
"node": "22.12.0"
}
}

View file

@ -147,6 +147,11 @@ export interface UpsertFileOptions {
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
export interface DayOfYearAssets {
yearsAgo: number;
assets: AssetEntity[];
}
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
@ -158,7 +163,7 @@ export interface IAssetRepository {
select?: FindOptionsSelect<AssetEntity>,
): Promise<AssetEntity[]>;
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<DayOfYearAssets[]>;
getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise<AssetEntity | null>;
getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;

View file

@ -130,6 +130,11 @@ export interface ProbeOptions {
countFrames: boolean;
}
export interface VideoInterfaces {
dri: string[];
mali: boolean;
}
export interface IMediaRepository {
// image
extract(input: string, output: string): Promise<boolean>;

View file

@ -170,6 +170,22 @@ export interface AssetDuplicateResult {
distance: number;
}
export interface GetStatesOptions {
country?: string;
}
export interface GetCitiesOptions extends GetStatesOptions {
state?: string;
}
export interface GetCameraModelsOptions {
make?: string;
}
export interface GetCameraMakesOptions {
model?: string;
}
export interface ISearchRepository {
searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity>;
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
@ -183,8 +199,8 @@ export interface ISearchRepository {
getDimensionSize(): Promise<number>;
setDimensionSize(dimSize: number): Promise<void>;
getCountries(userIds: string[]): Promise<Array<string | null>>;
getStates(userIds: string[], country?: string): Promise<Array<string | null>>;
getCities(userIds: string[], country?: string, state?: string): Promise<Array<string | null>>;
getCameraMakes(userIds: string[], model?: string): Promise<Array<string | null>>;
getCameraModels(userIds: string[], make?: string): Promise<Array<string | null>>;
getStates(userIds: string[], options: GetStatesOptions): Promise<Array<string | null>>;
getCities(userIds: string[], options: GetCitiesOptions): Promise<Array<string | null>>;
getCameraMakes(userIds: string[], options: GetCameraMakesOptions): Promise<Array<string | null>>;
getCameraModels(userIds: string[], options: GetCameraModelsOptions): Promise<Array<string | null>>;
}

View file

@ -68,7 +68,7 @@ SELECT
FROM
"assets" "entity"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id"
LEFT JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id"
INNER JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id"
WHERE
(
"entity"."ownerId" IN ($1)
@ -84,6 +84,16 @@ WHERE
FROM
"entity"."localDateTime" AT TIME ZONE 'UTC'
) = $3
AND "files"."type" = $4
AND EXTRACT(
YEAR
FROM
CURRENT_DATE AT TIME ZONE 'UTC'
) - EXTRACT(
YEAR
FROM
"entity"."localDateTime" AT TIME ZONE 'UTC'
) > 0
)
AND ("entity"."deletedAt" IS NULL)
ORDER BY

View file

@ -585,52 +585,57 @@ SELECT DISTINCT
ON ("exif"."country") "exif"."country" AS "country"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" != ''
AND "exif"."country" IS NOT NULL
-- SearchRepository.getStates
SELECT DISTINCT
ON ("exif"."state") "exif"."state" AS "state"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
AND "exif"."state" != ''
AND "exif"."state" IS NOT NULL
-- SearchRepository.getCities
SELECT DISTINCT
ON ("exif"."city") "exif"."city" AS "city"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
AND "exif"."state" = $3
AND "exif"."city" != ''
AND "exif"."city" IS NOT NULL
-- SearchRepository.getCameraMakes
SELECT DISTINCT
ON ("exif"."make") "exif"."make" AS "make"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."model" = $2
AND "exif"."make" != ''
AND "exif"."make" IS NOT NULL
-- SearchRepository.getCameraModels
SELECT DISTINCT
ON ("exif"."model") "exif"."model" AS "model"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."make" = $2
AND "exif"."model" != ''
AND "exif"."model" IS NOT NULL

View file

@ -18,6 +18,7 @@ import {
AssetUpdateAllOptions,
AssetUpdateDuplicateOptions,
AssetUpdateOptions,
DayOfYearAssets,
IAssetRepository,
LivePhotoSearchOptions,
MonthDay,
@ -83,8 +84,8 @@ export class AssetRepository implements IAssetRepository {
}
@GenerateSql({ params: [[DummyValue.UUID], { day: 1, month: 1 }] })
getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<AssetEntity[]> {
return this.repository
async getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<DayOfYearAssets[]> {
const assets = await this.repository
.createQueryBuilder('entity')
.where(
`entity.ownerId IN (:...ownerIds)
@ -99,9 +100,25 @@ export class AssetRepository implements IAssetRepository {
},
)
.leftJoinAndSelect('entity.exifInfo', 'exifInfo')
.leftJoinAndSelect('entity.files', 'files')
.innerJoinAndSelect('entity.files', 'files')
.andWhere('files.type = :type', { type: AssetFileType.THUMBNAIL })
.andWhere(
`EXTRACT(YEAR FROM CURRENT_DATE AT TIME ZONE 'UTC') - EXTRACT(YEAR FROM entity.localDateTime AT TIME ZONE 'UTC') > 0`,
)
.orderBy('entity.fileCreatedAt', 'ASC')
.getMany();
const groups: Record<number, DayOfYearAssets> = {};
const currentYear = new Date().getFullYear();
for (const asset of assets) {
const yearsAgo = currentYear - asset.localDateTime.getFullYear();
if (!groups[yearsAgo]) {
groups[yearsAgo] = { yearsAgo, assets: [] };
}
groups[yearsAgo].assets.push(asset);
}
return Object.values(groups);
}
@GenerateSql({ params: [[DummyValue.UUID]] })

View file

@ -17,6 +17,10 @@ import {
AssetSearchOptions,
FaceEmbeddingSearch,
FaceSearchResult,
GetCameraMakesOptions,
GetCameraModelsOptions,
GetCitiesOptions,
GetStatesOptions,
ISearchRepository,
SearchPaginationOptions,
SmartSearchOptions,
@ -342,23 +346,27 @@ export class SearchRepository implements ISearchRepository {
@GenerateSql({ params: [[DummyValue.UUID]] })
async getCountries(userIds: string[]): Promise<string[]> {
const results = await this.exifRepository
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.innerJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.andWhere(`exif.country != ''`)
.andWhere('exif.country IS NOT NULL')
.select('exif.country', 'country')
.distinctOn(['exif.country'])
.getRawMany<{ country: string }>();
.distinctOn(['exif.country']);
return results.map(({ country }) => country).filter((item) => item !== '');
const results = await query.getRawMany<{ country: string }>();
return results.map(({ country }) => country);
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
async getStates(userIds: string[], { country }: GetStatesOptions): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.innerJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.andWhere(`exif.state != ''`)
.andWhere('exif.state IS NOT NULL')
.select('exif.state', 'state')
.distinctOn(['exif.state']);
@ -367,16 +375,17 @@ export class SearchRepository implements ISearchRepository {
}
const result = await query.getRawMany<{ state: string }>();
return result.map(({ state }) => state).filter((item) => item !== '');
return result.map(({ state }) => state);
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
async getCities(userIds: string[], { country, state }: GetCitiesOptions): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.innerJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.andWhere(`exif.city != ''`)
.andWhere('exif.city IS NOT NULL')
.select('exif.city', 'city')
.distinctOn(['exif.city']);
@ -389,16 +398,17 @@ export class SearchRepository implements ISearchRepository {
}
const results = await query.getRawMany<{ city: string }>();
return results.map(({ city }) => city).filter((item) => item !== '');
return results.map(({ city }) => city);
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
async getCameraMakes(userIds: string[], { model }: GetCameraMakesOptions): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.innerJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.andWhere(`exif.make != ''`)
.andWhere('exif.make IS NOT NULL')
.select('exif.make', 'make')
.distinctOn(['exif.make']);
@ -407,15 +417,17 @@ export class SearchRepository implements ISearchRepository {
}
const results = await query.getRawMany<{ make: string }>();
return results.map(({ make }) => make).filter((item) => item !== '');
return results.map(({ make }) => make);
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
async getCameraModels(userIds: string[], { make }: GetCameraModelsOptions): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.innerJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.andWhere(`exif.model != ''`)
.andWhere('exif.model IS NOT NULL')
.select('exif.model', 'model')
.distinctOn(['exif.model']);
@ -424,7 +436,7 @@ export class SearchRepository implements ISearchRepository {
}
const results = await query.getRawMany<{ model: string }>();
return results.map(({ model }) => model).filter((item) => item !== '');
return results.map(({ model }) => model);
}
private getRuntimeConfig(numResults?: number): string | undefined {

View file

@ -98,7 +98,7 @@ const validImages = [
'.x3f',
];
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.vob', '.webm', '.wmv'];
const uploadTests = [
{

View file

@ -80,7 +80,20 @@ describe(AssetService.name, () => {
const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) };
partnerMock.getAll.mockResolvedValue([]);
assetMock.getByDayOfYear.mockResolvedValue([image1, image2, image3, image4]);
assetMock.getByDayOfYear.mockResolvedValue([
{
yearsAgo: 1,
assets: [image1, image2],
},
{
yearsAgo: 9,
assets: [image3],
},
{
yearsAgo: 15,
assets: [image4],
},
]);
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
{ yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] },

View file

@ -43,28 +43,13 @@ export class AssetService extends BaseService {
});
const userIds = [auth.user.id, ...partnerIds];
const assets = await this.assetRepository.getByDayOfYear(userIds, dto);
const assetsWithThumbnails = assets.filter(({ files }) => !!getAssetFiles(files).thumbnailFile);
const groups: Record<number, AssetEntity[]> = {};
const currentYear = new Date().getFullYear();
for (const asset of assetsWithThumbnails) {
const yearsAgo = currentYear - asset.localDateTime.getFullYear();
if (!groups[yearsAgo]) {
groups[yearsAgo] = [];
}
groups[yearsAgo].push(asset);
}
return Object.keys(groups)
.map(Number)
.sort((a, b) => a - b)
.filter((yearsAgo) => yearsAgo > 0)
.map((yearsAgo) => ({
yearsAgo,
// TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
assets: groups[yearsAgo].map((asset) => mapAsset(asset, { auth })),
}));
const groups = await this.assetRepository.getByDayOfYear(userIds, dto);
return groups.map(({ yearsAgo, assets }) => ({
yearsAgo,
// TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
assets: assets.map((asset) => mapAsset(asset, { auth })),
}));
}
async getStatistics(auth: AuthDto, dto: AssetStatsDto) {

View file

@ -214,17 +214,26 @@ export class LibraryService extends BaseService {
@OnJob({ name: JobName.LIBRARY_SYNC_FILES, queue: QueueName.LIBRARY })
async handleSyncFiles(job: JobOf<JobName.LIBRARY_SYNC_FILES>): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.libraryId);
if (!library) {
// We need to check if the library still exists as it could have been deleted after the scan was queued
this.logger.debug(`Library ${job.libraryId} not found, skipping file import`);
return JobStatus.FAILED;
}
const assetImports = job.assetPaths.map((assetPath) => this.processEntity(assetPath, job.ownerId, job.libraryId));
const assetIds: string[] = [];
const batchSize = 1000; // Adjust the batch size as needed
// Due to a typeorm limitation we must batch the inserts
const batchSize = 2000;
for (let i = 0; i < assetImports.length; i += batchSize) {
const batch = assetImports.slice(i, i + batchSize);
const batchIds = await this.assetRepository.createAll(batch).then((assets) => assets.map((asset) => asset.id));
assetIds.push(...batchIds);
}
this.logger.log(`Imported ${assetIds.length} asset(s) for library ${job.libraryId}`);
this.logger.log(`Imported ${assetIds.length} file(s) into library ${job.libraryId}`);
await this.queuePostSyncJobs(assetIds);
@ -379,14 +388,15 @@ export class LibraryService extends BaseService {
this.logger.log(`Starting to scan library ${id}`);
await this.jobRepository.queue({
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
data: {
id,
await this.jobRepository.queueAll([
{
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
data: {
id,
},
},
});
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } });
{ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } },
]);
}
@OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, queue: QueueName.LIBRARY })
@ -396,6 +406,7 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} });
const libraries = await this.libraryRepository.getAll(true);
await this.jobRepository.queueAll(
libraries.map((library) => ({
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
@ -412,6 +423,7 @@ export class LibraryService extends BaseService {
},
})),
);
return JobStatus.SUCCESS;
}
@ -531,7 +543,7 @@ export class LibraryService extends BaseService {
return JobStatus.SKIPPED;
}
this.logger.log(`Crawling import paths for library ${library.id}...`);
this.logger.debug(`Validating import paths for library ${library.id}...`);
const validImportPaths: string[] = [];
@ -560,11 +572,11 @@ export class LibraryService extends BaseService {
let importCount = 0;
let crawlCount = 0;
this.logger.log(`Starting crawl of filesystem for ${library.id}...`);
this.logger.log(`Starting crawl of ${validImportPaths.length} path(s) for library ${library.id}...`);
for await (const pathBatch of pathsOnDisk) {
crawlCount += pathBatch.length;
this.logger.log(
this.logger.debug(
`Crawled ${pathBatch.length} file(s) for library ${library.id}, in total ${crawlCount} file(s) crawled so far`,
);
const newPaths = await this.assetRepository.getNewPaths(library.id, pathBatch);
@ -575,29 +587,26 @@ export class LibraryService extends BaseService {
name: JobName.LIBRARY_SYNC_FILES,
data: { libraryId: library.id, ownerId: library.ownerId, assetPaths: newPaths },
});
if (newPaths.length < pathBatch.length) {
this.logger.debug(
`Current crawl batch: ${newPaths.length} of ${pathBatch.length} file(s) are new, queued import for library ${library.id}...`,
);
} else {
this.logger.debug(
`Current crawl batch: ${newPaths.length} new file(s), queued import for library ${library.id}...`,
);
}
this.logger.log(
`Crawled ${crawlCount} file(s) so far: ${newPaths.length} of current batch queued for import for ${library.id}...`,
);
} else {
this.logger.debug(`Current crawl batch: ${pathBatch.length} asset(s) already in library ${library.id}`);
this.logger.log(
`Crawled ${crawlCount} file(s) so far: ${pathBatch.length} of current batch already in library ${library.id}...`,
);
}
}
if (importCount > 0 && importCount === crawlCount) {
if (crawlCount === 0) {
this.logger.log(`No files found on disk for library ${library.id}`);
} else if (importCount > 0 && importCount === crawlCount) {
this.logger.log(`Finished crawling and queueing ${crawlCount} file(s) for import for library ${library.id}`);
} else if (importCount > 0) {
this.logger.log(
`Finished crawling ${crawlCount} file(s) of which ${importCount} are queued for import for library ${library.id}`,
`Finished crawling ${crawlCount} file(s) of which ${importCount} file(s) are queued for import for library ${library.id}`,
);
} else {
this.logger.debug(`Finished crawling, no files found for library ${library.id}`);
this.logger.log(`All ${crawlCount} file(s) on disk are already in ${library.id}`);
}
await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() });

View file

@ -1,4 +1,3 @@
import type { Stats } from 'node:fs';
import { SystemConfig } from 'src/config';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
@ -303,7 +302,7 @@ describe(MediaService.name, () => {
it('should skip video thumbnail generation if no video stream', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
assetMock.getById.mockResolvedValue(assetStub.video);
await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toBeInstanceOf(Error);
await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith();
});
@ -770,6 +769,7 @@ describe(MediaService.name, () => {
describe('handleVideoConversion', () => {
beforeEach(() => {
assetMock.getByIds.mockResolvedValue([assetStub.video]);
sut.videoInterfaces = { dri: ['renderD128'], mali: true };
});
it('should skip transcoding if asset not found', async () => {
@ -826,7 +826,7 @@ describe(MediaService.name, () => {
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toBeDefined();
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
@ -1079,7 +1079,7 @@ describe(MediaService.name, () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } });
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrow();
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
@ -1434,7 +1434,7 @@ describe(MediaService.name, () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
@ -1442,7 +1442,7 @@ describe(MediaService.name, () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
@ -1628,7 +1628,6 @@ describe(MediaService.name, () => {
});
it('should set options for qsv', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1664,7 +1663,6 @@ describe(MediaService.name, () => {
});
it('should set options for qsv with custom dri node', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({
ffmpeg: {
@ -1690,7 +1688,6 @@ describe(MediaService.name, () => {
});
it('should omit preset for qsv if invalid', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1710,7 +1707,6 @@ describe(MediaService.name, () => {
});
it('should set low power mode for qsv if target video codec is vp9', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1730,17 +1726,18 @@ describe(MediaService.name, () => {
});
it('should fail for qsv if no hw devices', async () => {
storageMock.readdir.mockRejectedValue(new Error('Could not read directory'));
sut.videoInterfaces = { dri: [], mali: false };
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mediaMock.transcode).not.toHaveBeenCalled();
expect(loggerMock.debug).toHaveBeenCalledWith('No devices found in /dev/dri.');
});
it('should prefer higher index renderD* device for qsv', async () => {
storageMock.readdir.mockResolvedValue(['card1', 'renderD129', 'card0', 'renderD128']);
sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false };
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1760,7 +1757,6 @@ describe(MediaService.name, () => {
});
it('should use hardware decoding for qsv if enabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
@ -1790,7 +1786,6 @@ describe(MediaService.name, () => {
});
it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
@ -1820,7 +1815,7 @@ describe(MediaService.name, () => {
});
it('should use preferred device for qsv when hardware decoding', async () => {
storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']);
sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false };
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' },
@ -1840,7 +1835,6 @@ describe(MediaService.name, () => {
});
it('should set format to nv12 for qsv if input is not yuv420p', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
@ -1866,7 +1860,6 @@ describe(MediaService.name, () => {
});
it('should set options for vaapi', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1898,7 +1891,6 @@ describe(MediaService.name, () => {
});
it('should set vbr options for vaapi when max bitrate is enabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1924,7 +1916,6 @@ describe(MediaService.name, () => {
});
it('should set cq options for vaapi when max bitrate is disabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1950,7 +1941,6 @@ describe(MediaService.name, () => {
});
it('should omit preset for vaapi if invalid', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1970,7 +1960,7 @@ describe(MediaService.name, () => {
});
it('should prefer higher index renderD* device for vaapi', async () => {
storageMock.readdir.mockResolvedValue(['card1', 'renderD129', 'card0', 'renderD128']);
sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false };
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -1990,7 +1980,7 @@ describe(MediaService.name, () => {
});
it('should select specific gpu node if selected', async () => {
storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']);
sut.videoInterfaces = { dri: ['renderD129', 'card1', 'card0', 'renderD128'], mali: false };
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' },
@ -2012,7 +2002,6 @@ describe(MediaService.name, () => {
});
it('should use hardware decoding for vaapi if enabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
@ -2041,7 +2030,6 @@ describe(MediaService.name, () => {
});
it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
@ -2066,7 +2054,6 @@ describe(MediaService.name, () => {
});
it('should set format to nv12 for vaapi if input is not yuv420p', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
@ -2087,7 +2074,7 @@ describe(MediaService.name, () => {
});
it('should use preferred device for vaapi when hardware decoding', async () => {
storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']);
sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false };
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' },
@ -2106,8 +2093,47 @@ describe(MediaService.name, () => {
);
});
it('should fallback to sw transcoding if hw transcoding fails', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.transcode.mockRejectedValueOnce(new Error('error'));
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledTimes(2);
expect(mediaMock.transcode).toHaveBeenLastCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device accel',
]),
outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]),
twoPass: false,
}),
);
});
it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.transcode.mockRejectedValueOnce(new Error('error'));
mediaMock.transcode.mockRejectedValueOnce(new Error('error'));
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledTimes(3);
expect(mediaMock.transcode).toHaveBeenLastCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264']),
twoPass: false,
}),
);
});
it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -2126,17 +2152,15 @@ describe(MediaService.name, () => {
});
it('should fail for vaapi if no hw devices', async () => {
storageMock.readdir.mockResolvedValue([]);
sut.videoInterfaces = { dri: [], mali: true };
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
it('should set options for rkmpp', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@ -2171,8 +2195,6 @@ describe(MediaService.name, () => {
});
it('should set vbr options for rkmpp when max bitrate is enabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
systemMock.get.mockResolvedValue({
ffmpeg: {
@ -2196,8 +2218,6 @@ describe(MediaService.name, () => {
});
it('should set cqp options for rkmpp when max bitrate is disabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
@ -2216,8 +2236,6 @@ describe(MediaService.name, () => {
});
it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
@ -2240,8 +2258,7 @@ describe(MediaService.name, () => {
});
it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
sut.videoInterfaces = { dri: ['renderD128'], mali: false };
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
@ -2262,8 +2279,6 @@ describe(MediaService.name, () => {
});
it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' },
@ -2286,8 +2301,7 @@ describe(MediaService.name, () => {
});
it('should use software tone-mapping if opencl is not available', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
sut.videoInterfaces = { dri: ['renderD128'], mali: false };
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },

View file

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { dirname } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import {
@ -27,7 +27,7 @@ import {
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { AudioStreamInfo, TranscodeCommand, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface';
import { AudioStreamInfo, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/interfaces/media.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
@ -36,8 +36,13 @@ import { usePagination } from 'src/utils/pagination';
@Injectable()
export class MediaService extends BaseService {
private maliOpenCL?: boolean;
private devices?: string[];
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap() {
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
this.videoInterfaces = { dri, mali };
}
@OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> {
@ -300,19 +305,19 @@ export class MediaService extends BaseService {
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs
});
const mainVideoStream = this.getMainStream(videoStreams);
const mainAudioStream = this.getMainStream(audioStreams);
if (!mainVideoStream || !format.formatName) {
const videoStream = this.getMainStream(videoStreams);
const audioStream = this.getMainStream(audioStreams);
if (!videoStream || !format.formatName) {
return JobStatus.FAILED;
}
if (!mainVideoStream.height || !mainVideoStream.width) {
if (!videoStream.height || !videoStream.width) {
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
return JobStatus.FAILED;
}
const { ffmpeg } = await this.getConfig({ withCache: true });
const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream);
let { ffmpeg } = await this.getConfig({ withCache: true });
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) {
if (asset.encodedVideoPath) {
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
@ -325,15 +330,7 @@ export class MediaService extends BaseService {
return JobStatus.SKIPPED;
}
let command: TranscodeCommand;
try {
const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL());
command = config.getCommand(target, mainVideoStream, mainAudioStream);
} catch (error) {
this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
return JobStatus.FAILED;
}
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
} else {
@ -354,8 +351,8 @@ export class MediaService extends BaseService {
if (ffmpeg.accelDecode) {
try {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
const config = BaseConfig.create({ ...ffmpeg, accelDecode: false });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
ffmpeg = { ...ffmpeg, accelDecode: false };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
await this.mediaRepository.transcode(input, output, command);
partialFallbackSuccess = true;
} catch (error: any) {
@ -365,8 +362,8 @@ export class MediaService extends BaseService {
if (!partialFallbackSuccess) {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
ffmpeg = { ...ffmpeg, accel: TranscodeHWAccel.DISABLED };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
await this.mediaRepository.transcode(input, output, command);
}
}
@ -507,30 +504,24 @@ export class MediaService extends BaseService {
}
private async getDevices() {
if (!this.devices) {
try {
this.devices = await this.storageRepository.readdir('/dev/dri');
} catch {
this.logger.debug('No devices found in /dev/dri.');
this.devices = [];
}
try {
return await this.storageRepository.readdir('/dev/dri');
} catch {
this.logger.debug('No devices found in /dev/dri.');
return [];
}
return this.devices;
}
private async hasMaliOpenCL() {
if (this.maliOpenCL === undefined) {
try {
const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd');
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch {
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
this.maliOpenCL = false;
}
try {
const [maliIcdStat, maliDeviceStat] = await Promise.all([
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
this.storageRepository.stat('/dev/mali0'),
]);
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch {
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
return false;
}
return this.maliOpenCL;
}
}

View file

@ -59,20 +59,84 @@ describe(SearchService.name, () => {
});
describe('getSearchSuggestions', () => {
it('should return search suggestions (including null)', async () => {
searchMock.getCountries.mockResolvedValue(['USA', null]);
it('should return search suggestions for country', async () => {
searchMock.getCountries.mockResolvedValue(['USA']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA']);
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
});
it('should return search suggestions for country (including null)', async () => {
searchMock.getCountries.mockResolvedValue(['USA']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA', null]);
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
});
it('should return search suggestions (without null)', async () => {
searchMock.getCountries.mockResolvedValue(['USA', null]);
it('should return search suggestions for state', async () => {
searchMock.getStates.mockResolvedValue(['California']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA']);
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }),
).resolves.toEqual(['California']);
expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for state (including null)', async () => {
searchMock.getStates.mockResolvedValue(['California']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }),
).resolves.toEqual(['California', null]);
expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for city', async () => {
searchMock.getCities.mockResolvedValue(['Denver']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }),
).resolves.toEqual(['Denver']);
expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for city (including null)', async () => {
searchMock.getCities.mockResolvedValue(['Denver']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }),
).resolves.toEqual(['Denver', null]);
expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera make', async () => {
searchMock.getCameraMakes.mockResolvedValue(['Nikon']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }),
).resolves.toEqual(['Nikon']);
expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera make (including null)', async () => {
searchMock.getCameraMakes.mockResolvedValue(['Nikon']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }),
).resolves.toEqual(['Nikon', null]);
expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera model', async () => {
searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }),
).resolves.toEqual(['Fujifilm X100VI']);
expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera model (including null)', async () => {
searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }),
).resolves.toEqual(['Fujifilm X100VI', null]);
expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
});
});

View file

@ -108,8 +108,11 @@ export class SearchService extends BaseService {
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) {
const userIds = await this.getUserIdsToSearch(auth);
const results = await this.getSuggestions(userIds, dto);
return results.filter((result) => (dto.includeNull ? true : result !== null));
const suggestions = await this.getSuggestions(userIds, dto);
if (dto.includeNull) {
suggestions.push(null);
}
return suggestions;
}
private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) {
@ -118,19 +121,19 @@ export class SearchService extends BaseService {
return this.searchRepository.getCountries(userIds);
}
case SearchSuggestionType.STATE: {
return this.searchRepository.getStates(userIds, dto.country);
return this.searchRepository.getStates(userIds, dto);
}
case SearchSuggestionType.CITY: {
return this.searchRepository.getCities(userIds, dto.country, dto.state);
return this.searchRepository.getCities(userIds, dto);
}
case SearchSuggestionType.CAMERA_MAKE: {
return this.searchRepository.getCameraMakes(userIds, dto.model);
return this.searchRepository.getCameraMakes(userIds, dto);
}
case SearchSuggestionType.CAMERA_MODEL: {
return this.searchRepository.getCameraModels(userIds, dto.make);
return this.searchRepository.getCameraModels(userIds, dto);
}
default: {
return [];
return [] as (string | null)[];
}
}
}

View file

@ -7,6 +7,7 @@ import {
VideoCodecHWConfig,
VideoCodecSWConfig,
VideoFormat,
VideoInterfaces,
VideoStreamInfo,
} from 'src/interfaces/media.interface';
@ -14,11 +15,11 @@ export class BaseConfig implements VideoCodecSWConfig {
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
protected constructor(protected config: SystemConfigFFmpegDto) {}
static create(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false): VideoCodecSWConfig {
static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces): VideoCodecSWConfig {
if (config.accel === TranscodeHWAccel.DISABLED) {
return this.getSWCodecConfig(config);
}
return this.getHWCodecConfig(config, devices, hasMaliOpenCL);
return this.getHWCodecConfig(config, interfaces);
}
private static getSWCodecConfig(config: SystemConfigFFmpegDto) {
@ -41,27 +42,31 @@ export class BaseConfig implements VideoCodecSWConfig {
}
}
private static getHWCodecConfig(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false) {
private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces) {
let handler: VideoCodecHWConfig;
switch (config.accel) {
case TranscodeHWAccel.NVENC: {
handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config);
handler = config.accelDecode
? new NvencHwDecodeConfig(config, interfaces)
: new NvencSwDecodeConfig(config, interfaces);
break;
}
case TranscodeHWAccel.QSV: {
handler = config.accelDecode ? new QsvHwDecodeConfig(config, devices) : new QsvSwDecodeConfig(config, devices);
handler = config.accelDecode
? new QsvHwDecodeConfig(config, interfaces)
: new QsvSwDecodeConfig(config, interfaces);
break;
}
case TranscodeHWAccel.VAAPI: {
handler = config.accelDecode
? new VaapiHwDecodeConfig(config, devices)
: new VaapiSwDecodeConfig(config, devices);
? new VaapiHwDecodeConfig(config, interfaces)
: new VaapiSwDecodeConfig(config, interfaces);
break;
}
case TranscodeHWAccel.RKMPP: {
handler = config.accelDecode
? new RkmppHwDecodeConfig(config, devices, hasMaliOpenCL)
: new RkmppSwDecodeConfig(config, devices);
? new RkmppHwDecodeConfig(config, interfaces)
: new RkmppSwDecodeConfig(config, interfaces);
break;
}
default: {
@ -323,13 +328,15 @@ export class BaseConfig implements VideoCodecSWConfig {
export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
protected device: string;
protected interfaces: VideoInterfaces;
constructor(
protected config: SystemConfigFFmpegDto,
devices: string[] = [],
interfaces: VideoInterfaces,
) {
super(config);
this.device = this.getDevice(devices);
this.interfaces = interfaces;
this.device = this.getDevice(interfaces);
}
getSupportedCodecs() {
@ -346,16 +353,16 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
});
}
getDevice(devices: string[]) {
getDevice({ dri }: VideoInterfaces) {
if (this.config.preferredHwDevice === 'auto') {
// eslint-disable-next-line unicorn/no-array-reduce
return `/dev/dri/${this.validateDevices(devices).reduce(function (a, b) {
return `/dev/dri/${this.validateDevices(dri).reduce(function (a, b) {
return a.localeCompare(b) < 0 ? b : a;
})}`;
}
const deviceName = this.config.preferredHwDevice.replace('/dev/dri/', '');
if (!devices.includes(deviceName)) {
if (!dri.includes(deviceName)) {
throw new Error(`Device '${deviceName}' does not exist. If using Docker, make sure this device is mounted`);
}
@ -886,13 +893,6 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
}
export class RkmppSwDecodeConfig extends BaseHWConfig {
constructor(
protected config: SystemConfigFFmpegDto,
devices: string[] = [],
) {
super(config, devices);
}
eligibleForTwoPass(): boolean {
return false;
}
@ -937,16 +937,6 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
}
export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
protected hasMaliOpenCL: boolean;
constructor(
protected config: SystemConfigFFmpegDto,
devices: string[] = [],
hasMaliOpenCL = false,
) {
super(config, devices);
this.hasMaliOpenCL = hasMaliOpenCL;
}
getBaseInputOptions() {
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
}
@ -954,7 +944,7 @@ export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
getFilterOptions(videoStream: VideoStreamInfo) {
if (this.shouldToneMap(videoStream)) {
const { primaries, transfer, matrix } = this.getColors();
if (this.hasMaliOpenCL) {
if (this.interfaces.mali) {
return [
// use RKMPP for scaling, OpenCL for tone mapping
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,

View file

@ -92,6 +92,7 @@ describe('mimeTypes', () => {
{ mimetype: 'video/x-matroska', extension: '.mkv' },
{ mimetype: 'video/x-ms-wmv', extension: '.wmv' },
{ mimetype: 'video/x-msvideo', extension: '.avi' },
{ mimetype: 'video/mpeg', extension: '.vob' },
]) {
it(`should map ${extension} to ${mimetype}`, () => {
expect({ ...mimeTypes.image, ...mimeTypes.video }[extension]).toContain(mimetype);

View file

@ -74,6 +74,7 @@ const video: Record<string, string[]> = {
'.mpeg': ['video/mpeg'],
'.mpg': ['video/mpeg'],
'.mts': ['video/mp2t'],
'.vob': ['video/mpeg'],
'.webm': ['video/webm'],
'.wmv': ['video/x-ms-wmv'],
};

View file

@ -1 +1 @@
22.11.0
22.12.0

6
web/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.122.1",
"version": "1.122.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.122.1",
"version": "1.122.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.7.8",
@ -74,7 +74,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.122.1",
"version": "1.122.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View file

@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.122.1",
"version": "1.122.3",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@ -87,6 +87,6 @@
"thumbhash": "^0.1.1"
},
"volta": {
"node": "22.11.0"
"node": "22.12.0"
}
}

View file

@ -46,6 +46,8 @@
import { tweened } from 'svelte/motion';
import { derived as storeDerived } from 'svelte/store';
import { fade } from 'svelte/transition';
import { preferences, user } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
type MemoryIndex = {
memoryIndex: number;
@ -221,6 +223,7 @@
$effect(() => {
handlePromiseError(handleAction(galleryInView ? 'pause' : 'play'));
});
let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
</script>
<svelte:window
@ -253,6 +256,9 @@
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={handleRemove} />
{#if $preferences.tags.enabled && isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem onAssetDelete={handleRemove} />
</ButtonContextMenu>
</AssetSelectControlBar>

View file

@ -17,19 +17,9 @@
let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore;
const autoHide = () => {
if (!$isUploading && showDetail) {
showDetail = false;
}
if ($isUploading && !showDetail) {
showDetail = true;
}
};
$effect(() => {
if ($isUploading) {
autoHide();
showDetail = true;
}
});
</script>

View file

@ -265,6 +265,7 @@ export const langs = [
{ name: 'Belarusian', code: 'be', loader: () => import('$i18n/be.json') },
{ name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') },
{ name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') },
{ name: 'Bengali', code: 'bn', loader: () => import('$i18n/bn.json') },
{ name: 'Catalan', code: 'ca', loader: () => import('$i18n/ca.json') },
{ name: 'Czech', code: 'cs', loader: () => import('$i18n/cs.json') },
{ name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
@ -319,6 +320,7 @@ export const langs = [
{ name: 'Thai', code: 'th', loader: () => import('$i18n/th.json') },
{ name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') },
{ name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') },
{ name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json') },
{ name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') },
{
name: 'Chinese (Traditional)',

View file

@ -58,6 +58,8 @@
import { listNavigation } from '$lib/actions/list-navigation';
import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { preferences, user } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
interface Props {
data: PageData;
@ -337,6 +339,7 @@
let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived));
let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite));
let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
</script>
{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS}
@ -391,6 +394,9 @@
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
{#if $preferences.tags.enabled && isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
</ButtonContextMenu>
</AssetSelectControlBar>

View file

@ -49,7 +49,7 @@
const isLivePhotoCandidate =
selection.length === 2 &&
selection.some((asset) => asset.type === AssetTypeEnum.Image) &&
selection.some((asset) => asset.type === AssetTypeEnum.Image);
selection.some((asset) => asset.type === AssetTypeEnum.Video);
isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate);
});

View file

@ -44,6 +44,8 @@
import { t } from 'svelte-i18n';
import { onMount, tick } from 'svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import { preferences, user } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
@ -229,6 +231,7 @@
function getObjectKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}
let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} bind:scrollY />
@ -250,6 +253,9 @@
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
{#if $preferences.tags.enabled && isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} />
<hr />
<AssetJobActions />