Compare commits
109 Commits
v1.83.0
...
feat/ml-ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
683bb88f8b | ||
|
|
ae80def7f2 | ||
|
|
069a32dcdb | ||
|
|
388144823a | ||
|
|
c23d84be39 | ||
|
|
66120025b7 | ||
|
|
da33653b0a | ||
|
|
3ea0210c1d | ||
|
|
98f1e85c87 | ||
|
|
d2509c619e | ||
|
|
2bfe5d1573 | ||
|
|
d7d464570f | ||
|
|
2e82476cff | ||
|
|
2f462717aa | ||
|
|
86e04832a1 | ||
|
|
96f1a271ef | ||
|
|
55e3605ca4 | ||
|
|
0bf55d8e32 | ||
|
|
2dcad93d9c | ||
|
|
328a58ac0d | ||
|
|
7fca0d8da5 | ||
|
|
413ab2c538 | ||
|
|
394e0dfe37 | ||
|
|
a9b6acec28 | ||
|
|
ad4cbf20de | ||
|
|
26fd797ac9 | ||
|
|
35767591d2 | ||
|
|
3b11854702 | ||
|
|
895129c997 | ||
|
|
92ec1ce77f | ||
|
|
986bbfa831 | ||
|
|
75c065c83a | ||
|
|
9c0805c37a | ||
|
|
bffc2cdf60 | ||
|
|
a147dee4b6 | ||
|
|
5423f1c25b | ||
|
|
5c602bf4d4 | ||
|
|
5db73c5c5c | ||
|
|
52fe392a9e | ||
|
|
5e1c0fb465 | ||
|
|
37ab37bffc | ||
|
|
664b7106ca | ||
|
|
bb28cae671 | ||
|
|
c2c26c471a | ||
|
|
2dca2850dc | ||
|
|
7fc8f6433b | ||
|
|
f6180fccdc | ||
|
|
9d01885b58 | ||
|
|
ace0a5911c | ||
|
|
21f2d3058a | ||
|
|
26fd9d7e5f | ||
|
|
c74ea7282a | ||
|
|
279481ad54 | ||
|
|
9e7a32804b | ||
|
|
a0743d8b7d | ||
|
|
68000c21a8 | ||
|
|
e671b30aaf | ||
|
|
cf1dfdc776 | ||
|
|
de29480dda | ||
|
|
2e424fe249 | ||
|
|
d4ef6f52bb | ||
|
|
e1e45f3f32 | ||
|
|
330f4cadda | ||
|
|
621eef0edc | ||
|
|
33ce2b7bba | ||
|
|
81792a5342 | ||
|
|
5f43971ccf | ||
|
|
38443a6068 | ||
|
|
92bb42950e | ||
|
|
b58edae134 | ||
|
|
2b9f20a1b5 | ||
|
|
d5f8199655 | ||
|
|
d8903de92e | ||
|
|
1d35965d03 | ||
|
|
309bf1ad22 | ||
|
|
0130591a0f | ||
|
|
cf4ec06750 | ||
|
|
e8712e6694 | ||
|
|
ce5966c23d | ||
|
|
68f6446718 | ||
|
|
197f336b5f | ||
|
|
cd375a976e | ||
|
|
088d5addf2 | ||
|
|
2377df9dae | ||
|
|
ad5ba82f50 | ||
|
|
b6f18cbe81 | ||
|
|
87a0ba3db3 | ||
|
|
3212a47720 | ||
|
|
431536cdbb | ||
|
|
9a60578088 | ||
|
|
8dcd159bd6 | ||
|
|
2f87463170 | ||
|
|
9f56bf0ab9 | ||
|
|
603b056512 | ||
|
|
ce04e9e07a | ||
|
|
c54a188154 | ||
|
|
c77ba46d60 | ||
|
|
cc3149c520 | ||
|
|
512f672e9e | ||
|
|
b117985f66 | ||
|
|
b92a2b2a56 | ||
|
|
a6f39bc74f | ||
|
|
daad02504f | ||
|
|
8a6889529c | ||
|
|
b34cbd881a | ||
|
|
f6eaaab725 | ||
|
|
2a2c74e081 | ||
|
|
c653e0f261 | ||
|
|
f0dd1d715a |
4
.github/workflows/docker-cleanup.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
-
|
||||
name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.3.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.4.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "immich-app"
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
-
|
||||
name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.3.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.4.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "immich-app"
|
||||
|
||||
83
.github/workflows/docker.yml
vendored
@@ -33,91 +33,10 @@ jobs:
|
||||
- context: "nginx"
|
||||
image: "immich-proxy"
|
||||
platforms: "linux/amd64,linux/arm64"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
# Workaround to fix error:
|
||||
# failed to push: failed to copy: io: read/write on closed pipe
|
||||
# See https://github.com/docker/build-push-action/issues/761
|
||||
with:
|
||||
driver-opts: |
|
||||
image=moby/buildkit:v0.10.6
|
||||
|
||||
- name: Login to Docker Hub
|
||||
# Only push to Docker Hub when making a release
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
# Skip when PR from a fork
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
flavor: |
|
||||
# Disable latest tag
|
||||
latest=false
|
||||
images: |
|
||||
name=ghcr.io/${{ github.repository_owner }}/${{matrix.image}}
|
||||
name=altran1502/${{matrix.image}},enable=${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
# Tag with branch name
|
||||
type=ref,event=branch
|
||||
# Tag with pr-number
|
||||
type=ref,event=pr
|
||||
# Tag with git tag on release
|
||||
type=ref,event=tag
|
||||
type=raw,value=release,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Determine build cache output
|
||||
id: cache-target
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
# Essentially just ignore the cache output (PR can't write to registry cache)
|
||||
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
# Skip pushing when PR from a fork
|
||||
push: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
|
||||
cache-to: ${{ steps.cache-target.outputs.cache-to }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
|
||||
build_and_push_server_arm_64:
|
||||
runs-on: self-hosted
|
||||
strategy:
|
||||
# Prevent a failure in one image from stopping the other builds
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- context: "server"
|
||||
image: "immich-server"
|
||||
platforms: "linux/arm64,linux/amd64"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
1
.github/workflows/test.yml
vendored
@@ -166,7 +166,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install --with dev
|
||||
poetry run pip install --no-deps -r requirements.txt
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
poetry run ruff check --format=github app
|
||||
|
||||
18
Makefile
@@ -1,35 +1,29 @@
|
||||
dev:
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
dev-new:
|
||||
docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans || make dev-down
|
||||
|
||||
dev-down:
|
||||
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||
|
||||
dev-new-update:
|
||||
dev-update:
|
||||
docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
dev-update:
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
dev-scale:
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
|
||||
stage:
|
||||
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
|
||||
docker compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
|
||||
|
||||
pull-stage:
|
||||
docker-compose -f ./docker/docker-compose.staging.yml pull
|
||||
docker compose -f ./docker/docker-compose.staging.yml pull
|
||||
|
||||
test-e2e:
|
||||
docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
||||
|
||||
prod:
|
||||
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
|
||||
prod-scale:
|
||||
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||
|
||||
api:
|
||||
cd ./server && npm run api:generate
|
||||
|
||||
13
README.md
@@ -2,7 +2,7 @@
|
||||
<br/>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
|
||||
<a href="https://discord.gg/D8JsnBEuKb">
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
|
||||
</a>
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -66,7 +66,7 @@ password: demo
|
||||
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
```
|
||||
|
||||
# Features
|
||||
## Features
|
||||
|
||||
| Features | Mobile | Web |
|
||||
| -------------------------------------------- | ------ | --- |
|
||||
@@ -96,7 +96,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
| Offline support | Yes | No |
|
||||
| Read-only gallery | Yes | Yes |
|
||||
|
||||
# Support the project
|
||||
## Support the project
|
||||
|
||||
I've committed to this project, and I will not stop. I will keep updating the docs, adding new features, and fixing bugs. But I can't do it alone. So I need your help to give me additional motivation to keep going.
|
||||
|
||||
@@ -104,10 +104,15 @@ As our hosts in the [selfhosted.show - In the episode 'The-organization-must-not
|
||||
|
||||
If you feel like this is the right cause and the app is something you are seeing yourself using for a long time, please consider supporting the project with the option below.
|
||||
|
||||
## Donation
|
||||
### Donation
|
||||
|
||||
- [Monthly donation](https://github.com/sponsors/alextran1502) via GitHub Sponsors
|
||||
- [One-time donation](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
|
||||
- [Librepay](https://liberapay.com/alex.tran1502/)
|
||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
||||
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
|
||||
|
||||
## Contributors
|
||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||
</a>
|
||||
|
||||
130
cli/package-lock.json
generated
@@ -1489,21 +1489,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/byte-size": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/byte-size/-/byte-size-8.1.0.tgz",
|
||||
"integrity": "sha512-LCIlZh8vyx+I2fgRycE1D34c33QDppYY6quBYYoaOpQ1nGhJ/avSP2VlrAefVotjJxgSk6WkKo0rTcCJwGG7vA==",
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/byte-size/-/byte-size-8.1.2.tgz",
|
||||
"integrity": "sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz",
|
||||
"integrity": "sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==",
|
||||
"version": "4.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.10.tgz",
|
||||
"integrity": "sha512-of+ICnbqjmFCiixUnqRulbylyXQrPqIGf/B3Jax1wIF3DvSheysQxAWvqHhZiW3IQrycvokcLcFQlveGp+vyNg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/cli-progress": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.3.tgz",
|
||||
"integrity": "sha512-/+C9xAdVtc+g5yHHkGBThgAA8rYpi5B+2ve3wLtybYj0JHEBs57ivR4x/zGfSsplRnV+psE91Nfin1soNKqz5Q==",
|
||||
"version": "3.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.5.tgz",
|
||||
"integrity": "sha512-D4PbNRbviKyppS5ivBGyFO29POlySLmA2HyUFE4p5QGazAMM3CwkKWcvTl8gvElSuxRh6FPKL8XmidX873ou4g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -1543,9 +1543,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest": {
|
||||
"version": "29.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz",
|
||||
"integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==",
|
||||
"version": "29.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz",
|
||||
"integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"expect": "^29.0.0",
|
||||
@@ -1553,9 +1553,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.6.tgz",
|
||||
"integrity": "sha512-ACTuifTSIIbyksx2HTon3aFtCKWcID7/h3XEmRpDYdMCXxPbl+m9GteOJeaAkiAta/NJaSFuA7ahZ0NkwajDSw==",
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -1565,25 +1565,28 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mime-types": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.2.tgz",
|
||||
"integrity": "sha512-q9QGHMGCiBJCHEvd4ZLdasdqXv570agPsUW0CeIm/B8DzhxsYMerD0l3IlI+EQ1A2RWHY2mmM9x1YIuuWxisCg==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
|
||||
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mock-fs": {
|
||||
"version": "4.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.2.tgz",
|
||||
"integrity": "sha512-mSIMAOjrNTVUFmZgJEigSIm+GlS4hbrk8U5+M8EB45uMrykKdN9TidjjSaOY1yFph2+TD7bsIfB4r+IrMYVyPQ==",
|
||||
"version": "4.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz",
|
||||
"integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.2.tgz",
|
||||
"integrity": "sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==",
|
||||
"dev": true
|
||||
"version": "20.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz",
|
||||
"integrity": "sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/normalize-package-data": {
|
||||
"version": "2.4.2",
|
||||
@@ -3867,9 +3870,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jest-extended": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.1.tgz",
|
||||
"integrity": "sha512-KM6dwuBUAgy6QONuR19CGubZB9Hkjqvl/d5Yc/FXsdB8+gsGxB2VQ+NEdOrr95J4GMPeLnDoPOKyi6+mKCCnZQ==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz",
|
||||
"integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"jest-diff": "^29.0.0",
|
||||
@@ -5862,6 +5865,12 @@
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
||||
@@ -7270,21 +7279,21 @@
|
||||
}
|
||||
},
|
||||
"@types/byte-size": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/byte-size/-/byte-size-8.1.0.tgz",
|
||||
"integrity": "sha512-LCIlZh8vyx+I2fgRycE1D34c33QDppYY6quBYYoaOpQ1nGhJ/avSP2VlrAefVotjJxgSk6WkKo0rTcCJwGG7vA==",
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/byte-size/-/byte-size-8.1.2.tgz",
|
||||
"integrity": "sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/chai": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz",
|
||||
"integrity": "sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==",
|
||||
"version": "4.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.10.tgz",
|
||||
"integrity": "sha512-of+ICnbqjmFCiixUnqRulbylyXQrPqIGf/B3Jax1wIF3DvSheysQxAWvqHhZiW3IQrycvokcLcFQlveGp+vyNg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/cli-progress": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.3.tgz",
|
||||
"integrity": "sha512-/+C9xAdVtc+g5yHHkGBThgAA8rYpi5B+2ve3wLtybYj0JHEBs57ivR4x/zGfSsplRnV+psE91Nfin1soNKqz5Q==",
|
||||
"version": "3.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.5.tgz",
|
||||
"integrity": "sha512-D4PbNRbviKyppS5ivBGyFO29POlySLmA2HyUFE4p5QGazAMM3CwkKWcvTl8gvElSuxRh6FPKL8XmidX873ou4g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
@@ -7324,9 +7333,9 @@
|
||||
}
|
||||
},
|
||||
"@types/jest": {
|
||||
"version": "29.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz",
|
||||
"integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==",
|
||||
"version": "29.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz",
|
||||
"integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"expect": "^29.0.0",
|
||||
@@ -7334,9 +7343,9 @@
|
||||
}
|
||||
},
|
||||
"@types/js-yaml": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.6.tgz",
|
||||
"integrity": "sha512-ACTuifTSIIbyksx2HTon3aFtCKWcID7/h3XEmRpDYdMCXxPbl+m9GteOJeaAkiAta/NJaSFuA7ahZ0NkwajDSw==",
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/json-schema": {
|
||||
@@ -7346,25 +7355,28 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/mime-types": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.2.tgz",
|
||||
"integrity": "sha512-q9QGHMGCiBJCHEvd4ZLdasdqXv570agPsUW0CeIm/B8DzhxsYMerD0l3IlI+EQ1A2RWHY2mmM9x1YIuuWxisCg==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
|
||||
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/mock-fs": {
|
||||
"version": "4.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.2.tgz",
|
||||
"integrity": "sha512-mSIMAOjrNTVUFmZgJEigSIm+GlS4hbrk8U5+M8EB45uMrykKdN9TidjjSaOY1yFph2+TD7bsIfB4r+IrMYVyPQ==",
|
||||
"version": "4.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz",
|
||||
"integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "20.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.2.tgz",
|
||||
"integrity": "sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==",
|
||||
"dev": true
|
||||
"version": "20.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz",
|
||||
"integrity": "sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"@types/normalize-package-data": {
|
||||
"version": "2.4.2",
|
||||
@@ -8969,9 +8981,9 @@
|
||||
}
|
||||
},
|
||||
"jest-extended": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.1.tgz",
|
||||
"integrity": "sha512-KM6dwuBUAgy6QONuR19CGubZB9Hkjqvl/d5Yc/FXsdB8+gsGxB2VQ+NEdOrr95J4GMPeLnDoPOKyi6+mKCCnZQ==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz",
|
||||
"integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"jest-diff": "^29.0.0",
|
||||
@@ -10419,6 +10431,12 @@
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"dev": true
|
||||
},
|
||||
"undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true
|
||||
},
|
||||
"update-browserslist-db": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
||||
|
||||
2522
cli/src/api/open-api/api.ts
generated
2
cli/src/api/open-api/base.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.83.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/common.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.83.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/configuration.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.83.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/index.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.83.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# See:
|
||||
# - https://immich.app/docs/developer/setup
|
||||
# - https://immich.app/docs/developer/troubleshooting
|
||||
|
||||
version: "3.8"
|
||||
|
||||
name: immich-dev
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
@@ -71,10 +77,6 @@ services:
|
||||
command: npm run dev --host
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Rename these values for svelte public interface
|
||||
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
|
||||
- PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL}
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 24678:24678
|
||||
@@ -121,11 +123,11 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
|
||||
image: redis:6.2-alpine@sha256:3995fe6ea6a619313e31046bd3c8643f9e70f8f2b294ff82659d409b47d06abb
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
|
||||
image: postgres:14-alpine@sha256:874f566dd512d79cf74f59754833e869ae76ece96716d153b0fa3e64aec88d92
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
version: "3.8"
|
||||
|
||||
name: immich-prod
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
@@ -7,9 +9,9 @@ services:
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
command: ["./start-server.sh"]
|
||||
command: [ "./start-server.sh" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
@@ -18,19 +20,6 @@ services:
|
||||
- database
|
||||
- typesense
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
image: immich-machine-learning:latest
|
||||
build:
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: immich-microservices:latest
|
||||
@@ -40,9 +29,9 @@ services:
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
command: ["./start-microservices.sh"]
|
||||
command: [ "./start-microservices.sh" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
@@ -64,6 +53,18 @@ services:
|
||||
depends_on:
|
||||
- immich-server
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
image: immich-machine-learning:latest
|
||||
build:
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
typesense:
|
||||
container_name: immich_typesense
|
||||
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
|
||||
@@ -73,17 +74,17 @@ services:
|
||||
# remove this to get debug messages
|
||||
- GLOG_minloglevel=1
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
- ${UPLOAD_LOCATION}/typesense:/data
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
|
||||
image: redis:6.2-alpine@sha256:3995fe6ea6a619313e31046bd3c8643f9e70f8f2b294ff82659d409b47d06abb
|
||||
restart: always
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
|
||||
image: postgres:14-alpine@sha256:874f566dd512d79cf74f59754833e869ae76ece96716d153b0fa3e64aec88d92
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -91,7 +92,7 @@ services:
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
||||
restart: always
|
||||
|
||||
immich-proxy:
|
||||
@@ -113,6 +114,4 @@ services:
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
model-cache:
|
||||
tsdata:
|
||||
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
- database
|
||||
|
||||
database:
|
||||
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
|
||||
image: postgres:14-alpine@sha256:874f566dd512d79cf74f59754833e869ae76ece96716d153b0fa3e64aec88d92
|
||||
command: -c fsync=off
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
version: "3.8"
|
||||
|
||||
name: immich
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
command: ["start.sh", "immich"]
|
||||
command: [ "start.sh", "immich" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
@@ -22,7 +24,7 @@ services:
|
||||
# extends:
|
||||
# file: hwaccel.yml
|
||||
# service: hwaccel
|
||||
command: ["start.sh", "microservices"]
|
||||
command: [ "start.sh", "microservices" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
@@ -64,12 +66,12 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
|
||||
image: redis:6.2-alpine@sha256:3995fe6ea6a619313e31046bd3c8643f9e70f8f2b294ff82659d409b47d06abb
|
||||
restart: always
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
|
||||
image: postgres:14-alpine@sha256:874f566dd512d79cf74f59754833e869ae76ece96716d153b0fa3e64aec88d92
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
24
docker/hwaccel-rkmpp.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
version: "3.8"
|
||||
|
||||
# Hardware acceleration for transcoding using RKMPP for Rockchip SOCs
|
||||
# This is only needed if you want to use hardware acceleration for transcoding.
|
||||
# Supported host OS is Ubuntu Jammy 22.04 with custom ffmpeg from ppa:liujianfeng1994/rockchip-multimedia
|
||||
|
||||
services:
|
||||
hwaccel:
|
||||
security_opt: # enables full access to /sys and /proc, still far better than privileged: true
|
||||
- systempaths=unconfined
|
||||
- apparmor=unconfined
|
||||
group_add:
|
||||
- video
|
||||
devices:
|
||||
- /dev/rga:/dev/rga
|
||||
- /dev/dri:/dev/dri
|
||||
- /dev/dma_heap:/dev/dma_heap
|
||||
- /dev/mpp_service:/dev/mpp_service
|
||||
volumes:
|
||||
- /usr/bin/ffmpeg:/usr/bin/ffmpeg_mpp:ro
|
||||
- /lib/aarch64-linux-gnu:/lib/ffmpeg-mpp:ro
|
||||
- /lib/aarch64-linux-gnu/libblas.so.3:/lib/ffmpeg-mpp/libblas.so.3:ro # symlink is resolved by mounting
|
||||
- /lib/aarch64-linux-gnu/liblapack.so.3:/lib/ffmpeg-mpp/liblapack.so.3:ro # symlink is resolved by mounting
|
||||
- /lib/aarch64-linux-gnu/pulseaudio/libpulsecommon-15.99.so:/lib/ffmpeg-mpp/libpulsecommon-15.99.so:ro
|
||||
@@ -33,8 +33,6 @@ To be concise, Immich can now read in the gallery files, register the path into
|
||||
- Only new files that are added to the gallery will be detected.
|
||||
- Deleted and moved files will not be detected.
|
||||
|
||||
You can find more information on how to use the feature by reading the documentation [here](/docs/features/read-only-gallery).
|
||||
|
||||
## Memory feature
|
||||
|
||||
This is considered a fun feature that the team and I wanted to build for so long, but we had to put it off because of the refactoring of the code base. The code base is now in a good enough form to circle back and add more exciting features.
|
||||
|
||||
@@ -17,13 +17,13 @@ docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/back
|
||||
```
|
||||
|
||||
```bash title='Restore'
|
||||
docker-compose down -v # CAUTION! Deletes all Immich data to start from scratch.
|
||||
docker-compose pull # Update to latest version of Immich (if desired)
|
||||
docker-compose create # Create Docker containers for Immich apps without running them.
|
||||
docker compose down -v # CAUTION! Deletes all Immich data to start from scratch.
|
||||
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
|
||||
gunzip < "/path/to/backup/dump.sql.gz" | docker exec -i immich_postgres psql -U postgres -d immich # Restore Backup
|
||||
docker-compose up -d # Start remainder of Immich apps
|
||||
docker compose up -d # Start remainder of Immich apps
|
||||
```
|
||||
|
||||
Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.).
|
||||
|
||||
@@ -51,8 +51,7 @@ immich-admin list-users
|
||||
{
|
||||
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
|
||||
email: 'immich@example.com.com',
|
||||
firstName: 'Immich',
|
||||
lastName: 'Admin',
|
||||
name: 'Immich Admin',
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
profileImagePath: 'upload/profile/e65e6f88-2a30-4dbe-8dd9-1885f4889b53/e65e6f88-2a30-4dbe-8dd9-1885f4889b53.jpg',
|
||||
|
||||
@@ -9,6 +9,6 @@ npm run typeorm:migrations:generate ./src/infra/<migration-name>
|
||||
```
|
||||
|
||||
2. Check if the migration file makes sense.
|
||||
3. Move the migration file to folder `./src/infra/database/migrations` in your code editor.
|
||||
3. Move the migration file to folder `./server/src/infra/migrations` in your code editor.
|
||||
|
||||
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.
|
||||
|
||||
@@ -12,6 +12,6 @@ The backend has an end-to-end test suite that can be called with `npm run test:e
|
||||
|
||||
Note that there is a bug in nodejs <20.8 that causes segmentation faults when running these tests. If you run into segfaults, ensure you are using at least version 20.8.
|
||||
|
||||
To perform a full e2e test, you need to run e2e tests inside docker. The easiest way to do that is to run `make test-e2e` in the root directory. This will build and start a docker-compose consisting of the server, microservices, and a postgres database. It will then perfom the tests and exit.
|
||||
To perform a full e2e test, you need to run e2e tests inside docker. The easiest way to do that is to run `make test-e2e` in the root directory. This will build and start a docker-compose consisting of the server, microservices, and a postgres database. It will then perform the tests and exit.
|
||||
|
||||
If you manually install the dependencies (see the DOCKERFILE) on your development machine, you can also run the full e2e tests manually by setting the `IMMICH_RUN_ALL_TESTS` environment value to true, i.e. `IMMICH_RUN_ALL_TESTS=true npm run test:e2e`.
|
||||
|
||||
19
docs/docs/developer/troubleshooting.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Troubleshooting
|
||||
|
||||
:::tip
|
||||
A great option to get assistance with troubleshooting is to join our [Discord](https://discord.gg/D8JsnBEuKb) server, where we have a dedicated channel for `#contributing`.
|
||||
:::
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Running on Windows
|
||||
|
||||
Running Immich on Windows can be frustrating and there are lots of ways it can go wrong. Where possible we recommend using Docker on Linux. However, several people have had success running Immich on Windows using Docker via WSL2.
|
||||
|
||||
### NTFS Mounted Volumes
|
||||
|
||||
The docker-compose.dev.yml and docker-compose.prod.yml use volume mounts for the postgres database. On start-up, postgres will try to `chown` the data directory, but fail. See [this post](https://forums.docker.com/t/data-directory-var-lib-postgresql-data-pgdata-has-wrong-ownership/17963/24) for more information about this issue and possible solutions.
|
||||
|
||||
### `Cannot read properties of null (reading 'split')`
|
||||
|
||||
This error occurs when trying to access the app via port `3000` instead of `2283`. During development `immich-proxy` runs on port 2283, while `immich-web` runs on `3000`.
|
||||
@@ -4,6 +4,10 @@ You can use the CLI to upload an existing gallery to the Immich server
|
||||
|
||||
[Immich CLI Repository](https://github.com/immich-app/CLI)
|
||||
|
||||
:::tip Google Photos Takeout
|
||||
If you are looking to import your Google Photos takeout, we recommed this community maintained tool [immich-go](https://github.com/simulot/immich-go)
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 16 or above
|
||||
@@ -32,7 +36,6 @@ immich
|
||||
| --server / -s | Immich's server address |
|
||||
| --threads / -t | Number of threads to use (Default 5) |
|
||||
| --album/ -al | Create albums for assets based on the parent folder or a given name |
|
||||
| --import/ -i | Import gallery (assets are not uploaded) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -108,70 +111,3 @@ npm run build
|
||||
```bash title="Run the command"
|
||||
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Importing existing libraries
|
||||
|
||||
If you do not wish to upload files into the server, existing files can be imported into the immich gallery through the use of the `--import` flag.
|
||||
|
||||
```
|
||||
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/ --import
|
||||
```
|
||||
|
||||
```
|
||||
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg --import
|
||||
```
|
||||
|
||||
The `immich-server` and `immich-microservices` containers must be able to access the files, or directories at the path referenced in the command. The directories referenced must be set under a user's `External Path` setting. More detailed instructions can be found [here](/docs/features/read-only-gallery).
|
||||
|
||||
:::tip Matching volume references
|
||||
The import command is most easily run on the machine running the immich service, as the path to the files on the machine running the command and the server much match identically.
|
||||
|
||||
If you are running immich within docker, the volume pointing to your existing library should be identical with your host machine.
|
||||
|
||||
```diff title="docker-compose.yml"
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
command: [ "start.sh", "immich" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
+ - /path/to/media:/path/to/media
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
- typesense
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
command: [ "start.sh", "microservices" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
+ - /path/to/media:/path/to/media
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
- typesense
|
||||
restart: always
|
||||
```
|
||||
|
||||
The proper command for above would be as shown below. You should have access to `/path/to/media` exactly on the environment the CLI command is being run on
|
||||
|
||||
```
|
||||
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import
|
||||
```
|
||||
|
||||
If you are running the import using the docker command, please note that the volumes should point to the `/path/to/media` exactly on the environment the CLI command is being run on
|
||||
|
||||
```
|
||||
docker run -it --rm -v "/path/to/media:/path/to/media" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Facial Recognition
|
||||
|
||||
## Overview
|
||||
|
||||
Immich recognizes faces in your photos and videos and groups them together. You can then assign names to the faces and search for them.
|
||||
|
||||
The list of people is shown in the Explore page.
|
||||
@@ -13,3 +15,16 @@ Upon clicking on a person, a list of assets that contain their face will be show
|
||||
The asset detail view will also show the faces that are recognized in the asset.
|
||||
|
||||
<img src={require('./img/facial-recognition-3.png').default} title='Facial Recognition 3' />
|
||||
|
||||
## Actions
|
||||
|
||||
Additional actions you can do with a detected person are:
|
||||
|
||||
- Change the feature face photo of the person
|
||||
- Set date of birth
|
||||
- Merge two or more detected faces into one person
|
||||
- Hide face
|
||||
|
||||
It can be found from the app bar when you access the detial view of a person
|
||||
|
||||
<img src={require('./img/facial-recognition-4.png').default} title='Facial Recognition 4' width="70%"/>
|
||||
|
||||
BIN
docs/docs/features/img/facial-recognition-4.png
Normal file
|
After Width: | Height: | Size: 416 KiB |
@@ -75,7 +75,7 @@ Some basic examples:
|
||||
- `*.tif` will exclude all files with the extension `.tif`
|
||||
- `hidden.jpg` will exclude all files named `hidden.jpg`
|
||||
- `**/Raw/**` will exclude all files in any directory named `Raw`
|
||||
- `*.(tif,jpg)` will exclude all files with the extension `.tif` or `.jpg`
|
||||
- `*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg`
|
||||
|
||||
### Nightly job
|
||||
|
||||
@@ -85,7 +85,7 @@ There is an automatic job that's run once a day and refreshes all modified files
|
||||
|
||||
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:
|
||||
|
||||
- `/home/user/old-pics`: a folder contining childhood photos.
|
||||
- `/home/user/old-pics`: a folder containing childhood photos.
|
||||
- `/mnt/nas/christmas-trip`: photos from a christmas trip. The subfolder `/mnt/nas/christmas-trip/Raw` contains the raw files directly from the DSLR. We don't want to import the raw files to Immich
|
||||
- `/mnt/media/videos`: Videos from the same christmas trip.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import MobileAppDownload from '../partials/_mobile-app-download.md';
|
||||
import MobileAppLogin from '../partials/_mobile-app-login.md';
|
||||
import MobileAppBackup from '../partials/_mobile-app-login.md';
|
||||
import MobileAppBackup from '../partials/_mobile-app-backup.md';
|
||||
|
||||
# Mobile App
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# Read-only Gallery [Deprecated]
|
||||
|
||||
:::caution
|
||||
|
||||
This feature is being deprecated in favor of [Libraries](/docs/features/libraries.md).
|
||||
|
||||
:::
|
||||
|
||||
## Overview
|
||||
|
||||
This feature enables users to use an existing gallery without uploading the assets to Immich.
|
||||
|
||||
Upon syncing the file information, it will be read by Immich to generate supported files.
|
||||
|
||||
## Usage
|
||||
|
||||
:::tip Example scenario
|
||||
|
||||
On the VM/system that Immich is running, I have 2 galleries that I want to use with Immich.
|
||||
|
||||
- My gallery is stored at `/mnt/media/precious-memory`
|
||||
- My wife's gallery is stored at `/mnt/media/childhood-memory`
|
||||
|
||||
We will use those values in the steps below.
|
||||
|
||||
:::
|
||||
|
||||
### Mount the gallery to the containers.
|
||||
|
||||
`immich-server` and `immich-microservices` containers will need access to the gallery. Mount the directory path as in the example below
|
||||
|
||||
```diff title="docker-compose.yml"
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
command: [ "start.sh", "immich" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
+ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro
|
||||
+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
- typesense
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
command: [ "start.sh", "microservices" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
+ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro
|
||||
+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
- typesense
|
||||
restart: always
|
||||
```
|
||||
|
||||
:::tip
|
||||
Internal and external path have to be identical.
|
||||
:::
|
||||
|
||||
_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._
|
||||
|
||||
### Register the path for the user.
|
||||
|
||||
This action is done by the admin of the instance.
|
||||
|
||||
- Navigate to `Administration > Users` page on the web.
|
||||
- Click on the user edit button.
|
||||
- Add the gallery path to the `External Path` field for the corresponding user and confirm the changes.
|
||||
|
||||
<img src={require('./img/me.png').default} width='33%' title='My Account Storage Path' />
|
||||
|
||||
<img src={require('./img/my-wife.png').default} width='33%' title='My Wifes Account Storage Path' />
|
||||
|
||||
### Sync with the CLI tool.
|
||||
|
||||
- Install or update the [CLI Tool](/docs/features/bulk-upload.md). The import feature is supported from version `v0.39.0` of the CLI
|
||||
- Run the command below to sync the gallery with Immich.
|
||||
|
||||
```bash title="Import my gallery"
|
||||
immich upload --key <my-api-key> --server http://my-server-ip:2283/api /mnt/media/precious-memory --recursive --import
|
||||
```
|
||||
|
||||
```bash title="Import my wife gallery"
|
||||
immich upload --key <my-wife-api-key> --server http://my-server-ip:2283/api /mnt/media/childhood-memory --recursive --import
|
||||
```
|
||||
|
||||
The `--import` flag will tell Immich to import the files by path instead of uploading them.
|
||||
42
docs/docs/guides/python-file-upload.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Python File Upload
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
API_KEY = 'YOUR_API_KEY' # replace with a valid api key
|
||||
BASE_URL = 'http://127.0.0.1:2283/api' # replace as needed
|
||||
|
||||
|
||||
def upload(file):
|
||||
stats = os.stat(file)
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'x-api-key': API_KEY
|
||||
}
|
||||
|
||||
data = {
|
||||
'deviceAssetId': f'{file}-{stats.st_mtime}',
|
||||
'deviceId': 'python',
|
||||
'fileCreatedAt': datetime.fromtimestamp(stats.st_mtime),
|
||||
'fileModifiedAt': datetime.fromtimestamp(stats.st_mtime),
|
||||
'isFavorite': 'false',
|
||||
}
|
||||
|
||||
files = {
|
||||
'assetData': open(file, 'rb')
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f'{BASE_URL}/asset/upload', headers=headers, data=data, files=files)
|
||||
|
||||
print(response.json())
|
||||
# {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False}
|
||||
|
||||
|
||||
upload('./test.jpg')
|
||||
```
|
||||
@@ -17,6 +17,12 @@ The default configuration looks like this:
|
||||
"targetAudioCodec": "aac",
|
||||
"targetResolution": "720",
|
||||
"maxBitrate": "0",
|
||||
"bframes": -1,
|
||||
"refs": 0,
|
||||
"gopSize": 0,
|
||||
"npl": 0,
|
||||
"temporalAQ": false,
|
||||
"cqMode": "auto",
|
||||
"twoPass": false,
|
||||
"transcode": "required",
|
||||
"tonemap": "hable",
|
||||
@@ -44,9 +50,15 @@ The default configuration looks like this:
|
||||
"sidecar": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"library": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"storageTemplateMigration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"migration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"thumbnailGeneration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
@@ -55,16 +67,16 @@ The default configuration looks like this:
|
||||
}
|
||||
},
|
||||
"machineLearning": {
|
||||
"classification": {
|
||||
"minScore": 0.7,
|
||||
"enabled": true,
|
||||
"modelName": "microsoft/resnet-50"
|
||||
},
|
||||
"enabled": true,
|
||||
"url": "http://immich-machine-learning:3003",
|
||||
"classification": {
|
||||
"enabled": true,
|
||||
"modelName": "microsoft/resnet-50",
|
||||
"minScore": 0.9
|
||||
},
|
||||
"clip": {
|
||||
"enabled": true,
|
||||
"modelName": "ViT-B-32::openai"
|
||||
"modelName": "ViT-B-32__openai"
|
||||
},
|
||||
"facialRecognition": {
|
||||
"enabled": true,
|
||||
@@ -74,6 +86,14 @@ The default configuration looks like this:
|
||||
"minFaces": 1
|
||||
}
|
||||
},
|
||||
"map": {
|
||||
"enabled": true,
|
||||
"tileUrl": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
},
|
||||
"reverseGeocoding": {
|
||||
"enabled": true,
|
||||
"citiesFileOverride": "cities500"
|
||||
},
|
||||
"oauth": {
|
||||
"enabled": false,
|
||||
"issuerUrl": "",
|
||||
@@ -96,8 +116,27 @@ The default configuration looks like this:
|
||||
"thumbnail": {
|
||||
"webpSize": 250,
|
||||
"jpegSize": 1440,
|
||||
"quality": 90,
|
||||
"quality": 80,
|
||||
"colorspace": "p3"
|
||||
},
|
||||
"newVersionCheck": {
|
||||
"enabled": true
|
||||
},
|
||||
"trash": {
|
||||
"enabled": true,
|
||||
"days": 30
|
||||
},
|
||||
"theme": {
|
||||
"customCss": ""
|
||||
},
|
||||
"library": {
|
||||
"scan": {
|
||||
"enabled": true,
|
||||
"cronExpression": "0 0 * * *"
|
||||
}
|
||||
},
|
||||
"stylesheets": {
|
||||
"css": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 334 KiB |
20
docs/package-lock.json
generated
@@ -13232,19 +13232,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz",
|
||||
"integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==",
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz",
|
||||
"integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
"chokidar": "^3.5.3",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"fast-glob": "^3.2.12",
|
||||
"fast-glob": "^3.3.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.18.2",
|
||||
"jiti": "^1.19.1",
|
||||
"lilconfig": "^2.1.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"normalize-path": "^3.0.0",
|
||||
@@ -24517,19 +24517,19 @@
|
||||
}
|
||||
},
|
||||
"tailwindcss": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz",
|
||||
"integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==",
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz",
|
||||
"integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==",
|
||||
"requires": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
"chokidar": "^3.5.3",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"fast-glob": "^3.2.12",
|
||||
"fast-glob": "^3.3.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.18.2",
|
||||
"jiti": "^1.19.1",
|
||||
"lilconfig": "^2.1.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"normalize-path": "^3.0.0",
|
||||
|
||||
@@ -34,7 +34,7 @@ function HomepageHeader() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<img src="/img/immich-screenshots.png" alt="logo" />
|
||||
<img src="/img/immich-screenshots.png" alt="screenshots" width={'85%'} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-4 gap-1">
|
||||
<div className="h-24">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mdiCheckAll,
|
||||
mdiCheckboxMarked,
|
||||
mdiCollage,
|
||||
mdiContentCopy,
|
||||
mdiDevices,
|
||||
mdiFaceMan,
|
||||
mdiFaceManOutline,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
mdiMerge,
|
||||
mdiMonitor,
|
||||
mdiMotionPlayOutline,
|
||||
mdiPalette,
|
||||
mdiPanVertical,
|
||||
mdiPartyPopper,
|
||||
mdiRaw,
|
||||
@@ -47,6 +49,33 @@ import React from 'react';
|
||||
import Timeline, { DateType, Item } from '../components/timeline';
|
||||
|
||||
const items: Item[] = [
|
||||
{
|
||||
icon: mdiStar,
|
||||
description: 'Reach 20K Stars on GitHub!',
|
||||
title: '20,000 Stars',
|
||||
release: 'v1.83.0',
|
||||
tag: 'v1.83.0',
|
||||
date: new Date(2023, 9, 28),
|
||||
dateType: DateType.RELEASE,
|
||||
},
|
||||
{
|
||||
icon: mdiContentCopy,
|
||||
title: 'Stack assets',
|
||||
description: 'Manual asset stacking for grouping and hiding related assets in the main timeline.',
|
||||
release: 'v1.83.0',
|
||||
tag: 'v1.83.0',
|
||||
date: new Date(2023, 9, 28),
|
||||
dateType: DateType.RELEASE,
|
||||
},
|
||||
{
|
||||
icon: mdiPalette,
|
||||
title: 'Custom theme',
|
||||
description: 'Apply your custom CSS for modifying fonts, colors, and styles in the web application.',
|
||||
release: 'v1.83.0',
|
||||
tag: 'v1.83.0',
|
||||
date: new Date(2023, 9, 28),
|
||||
dateType: DateType.RELEASE,
|
||||
},
|
||||
{
|
||||
icon: mdiTrashCanOutline,
|
||||
title: 'Trash Feature',
|
||||
@@ -283,7 +312,7 @@ const items: Item[] = [
|
||||
},
|
||||
{
|
||||
icon: mdiStar,
|
||||
description: 'Reach 10K Starts on GitHub!',
|
||||
description: 'Reach 10K Stars on GitHub!',
|
||||
title: '10,000 Stars',
|
||||
release: 'v1.54.0',
|
||||
tag: 'v1.54.0',
|
||||
|
||||
BIN
docs/static/img/immich-screenshots.png
vendored
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.8 MiB |
@@ -10,9 +10,8 @@ RUN poetry config installer.max-workers 10 && \
|
||||
RUN python -m venv /opt/venv
|
||||
ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
COPY poetry.lock pyproject.toml requirements.txt ./
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
|
||||
RUN pip install --no-deps -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
|
||||
@@ -38,8 +38,16 @@ class LogSettings(BaseSettings):
|
||||
_clean_name = str.maketrans(":\\/", "___", ".")
|
||||
|
||||
|
||||
def clean_name(model_name: str) -> str:
|
||||
return model_name.split("/")[-1].translate(_clean_name)
|
||||
|
||||
|
||||
def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
|
||||
return Path(settings.cache_folder) / model_type.value / model_name.translate(_clean_name)
|
||||
return Path(settings.cache_folder) / model_type.value / clean_name(model_name)
|
||||
|
||||
|
||||
def get_hf_model_name(model_name: str) -> str:
|
||||
return f"immich-app/{clean_name(model_name)}"
|
||||
|
||||
|
||||
LOG_LEVELS: dict[str, int] = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
from typing import Any, Iterator, TypeAlias
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
@@ -8,8 +9,7 @@ from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from .main import app, init_state
|
||||
|
||||
ndarray: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
|
||||
from .schemas import ndarray_f32
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -18,13 +18,13 @@ def pil_image() -> Image.Image:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cv_image(pil_image: Image.Image) -> ndarray:
|
||||
def cv_image(pil_image: Image.Image) -> ndarray_f32:
|
||||
return np.asarray(pil_image)[:, :, ::-1] # PIL uses RGB while cv2 uses BGR
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_model() -> Iterator[mock.Mock]:
|
||||
with mock.patch("app.models.cache.InferenceModel.from_model_type", autospec=True) as mocked:
|
||||
with mock.patch("app.models.cache.from_model_type", autospec=True) as mocked:
|
||||
yield mocked
|
||||
|
||||
|
||||
@@ -37,3 +37,25 @@ def deployed_app() -> TestClient:
|
||||
@pytest.fixture(scope="session")
|
||||
def responses() -> dict[str, Any]:
|
||||
return json.load(open("responses.json", "r"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def clip_model_cfg() -> dict[str, Any]:
|
||||
return {
|
||||
"embed_dim": 512,
|
||||
"vision_cfg": {"image_size": 224, "layers": 12, "width": 768, "patch_size": 32},
|
||||
"text_cfg": {"context_length": 77, "vocab_size": 49408, "width": 512, "heads": 8, "layers": 12},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def clip_preprocess_cfg() -> dict[str, Any]:
|
||||
return {
|
||||
"size": [224, 224],
|
||||
"mode": "RGB",
|
||||
"mean": [0.48145466, 0.4578275, 0.40821073],
|
||||
"std": [0.26862954, 0.26130258, 0.27577711],
|
||||
"interpolation": "bicubic",
|
||||
"resize_mode": "shortest",
|
||||
"fill_color": 0,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
from .clip import CLIPEncoder
|
||||
from typing import Any
|
||||
|
||||
from app.schemas import ModelType
|
||||
|
||||
from .base import InferenceModel
|
||||
from .clip import MCLIPEncoder, OpenCLIPEncoder
|
||||
from .constants import is_insightface, is_mclip, is_openclip
|
||||
from .facial_recognition import FaceRecognizer
|
||||
from .image_classification import ImageClassifier
|
||||
|
||||
|
||||
def from_model_type(model_type: ModelType, model_name: str, **model_kwargs: Any) -> InferenceModel:
|
||||
match model_type:
|
||||
case ModelType.CLIP:
|
||||
if is_openclip(model_name):
|
||||
return OpenCLIPEncoder(model_name, **model_kwargs)
|
||||
elif is_mclip(model_name):
|
||||
return MCLIPEncoder(model_name, **model_kwargs)
|
||||
case ModelType.FACIAL_RECOGNITION:
|
||||
if is_insightface(model_name):
|
||||
return FaceRecognizer(model_name, **model_kwargs)
|
||||
case ModelType.IMAGE_CLASSIFICATION:
|
||||
return ImageClassifier(model_name, **model_kwargs)
|
||||
case _:
|
||||
raise ValueError(f"Unknown model type {model_type}")
|
||||
|
||||
raise ValueError(f"Unknown ${model_type} model {model_name}")
|
||||
|
||||
@@ -7,8 +7,9 @@ from shutil import rmtree
|
||||
from typing import Any
|
||||
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
from ..config import get_cache_dir, log, settings
|
||||
from ..config import get_cache_dir, get_hf_model_name, log, settings
|
||||
from ..schemas import ModelType
|
||||
|
||||
|
||||
@@ -25,7 +26,7 @@ class InferenceModel(ABC):
|
||||
) -> None:
|
||||
self.model_name = model_name
|
||||
self.loaded = False
|
||||
self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type)
|
||||
self._cache_dir = Path(cache_dir) if cache_dir is not None else None
|
||||
self.providers = model_kwargs.pop("providers", ["CPUExecutionProvider"])
|
||||
# don't pre-allocate more memory than needed
|
||||
self.provider_options = model_kwargs.pop(
|
||||
@@ -78,9 +79,13 @@ class InferenceModel(ABC):
|
||||
def configure(self, **model_kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _download(self) -> None:
|
||||
...
|
||||
snapshot_download(
|
||||
get_hf_model_name(self.model_name),
|
||||
cache_dir=self.cache_dir,
|
||||
local_dir=self.cache_dir,
|
||||
local_dir_use_symlinks=False,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _load(self) -> None:
|
||||
@@ -92,7 +97,7 @@ class InferenceModel(ABC):
|
||||
|
||||
@property
|
||||
def cache_dir(self) -> Path:
|
||||
return self._cache_dir
|
||||
return self._cache_dir if self._cache_dir is not None else get_cache_dir(self.model_name, self.model_type)
|
||||
|
||||
@cache_dir.setter
|
||||
def cache_dir(self, cache_dir: Path) -> None:
|
||||
|
||||
@@ -4,6 +4,8 @@ from aiocache.backends.memory import SimpleMemoryCache
|
||||
from aiocache.lock import OptimisticLock
|
||||
from aiocache.plugins import BasePlugin, TimingPlugin
|
||||
|
||||
from app.models import from_model_type
|
||||
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
@@ -50,7 +52,7 @@ class ModelCache:
|
||||
async with OptimisticLock(self.cache, key) as lock:
|
||||
model = await self.cache.get(key)
|
||||
if model is None:
|
||||
model = InferenceModel.from_model_type(model_type, model_name, **model_kwargs)
|
||||
model = from_model_type(model_type, model_name, **model_kwargs)
|
||||
await lock.cas(model, ttl=self.ttl)
|
||||
return model
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import os
|
||||
import zipfile
|
||||
import json
|
||||
from abc import abstractmethod
|
||||
from functools import cached_property
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
import torch
|
||||
from clip_server.model.clip import BICUBIC, _convert_image_to_rgb
|
||||
from clip_server.model.clip_onnx import _MODELS, _S3_BUCKET_V2, CLIPOnnxModel, download_model
|
||||
from clip_server.model.pretrained_models import _VISUAL_MODEL_IMAGE_SIZE
|
||||
from clip_server.model.tokenization import Tokenizer
|
||||
from PIL import Image
|
||||
from torchvision.transforms import CenterCrop, Compose, Normalize, Resize, ToTensor
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from app.config import clean_name, log
|
||||
from app.models.transforms import crop, get_pil_resampling, normalize, resize, to_numpy
|
||||
from app.schemas import ModelType, ndarray_f32, ndarray_i32, ndarray_i64
|
||||
|
||||
from ..config import log
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
|
||||
class CLIPEncoder(InferenceModel):
|
||||
class BaseCLIPEncoder(InferenceModel):
|
||||
_model_type = ModelType.CLIP
|
||||
|
||||
def __init__(
|
||||
@@ -27,48 +27,29 @@ class CLIPEncoder(InferenceModel):
|
||||
mode: Literal["text", "vision"] | None = None,
|
||||
**model_kwargs: Any,
|
||||
) -> None:
|
||||
if mode is not None and mode not in ("text", "vision"):
|
||||
raise ValueError(f"Mode must be 'text', 'vision', or omitted; got '{mode}'")
|
||||
if model_name not in _MODELS:
|
||||
raise ValueError(f"Unknown model name {model_name}.")
|
||||
self.mode = mode
|
||||
super().__init__(model_name, cache_dir, **model_kwargs)
|
||||
|
||||
def _download(self) -> None:
|
||||
models: tuple[tuple[str, str], tuple[str, str]] = _MODELS[self.model_name]
|
||||
text_onnx_path = self.cache_dir / "textual.onnx"
|
||||
vision_onnx_path = self.cache_dir / "visual.onnx"
|
||||
|
||||
if not text_onnx_path.is_file():
|
||||
self._download_model(*models[0])
|
||||
|
||||
if not vision_onnx_path.is_file():
|
||||
self._download_model(*models[1])
|
||||
|
||||
def _load(self) -> None:
|
||||
if self.mode == "text" or self.mode is None:
|
||||
log.debug(f"Loading clip text model '{self.model_name}'")
|
||||
|
||||
self.text_model = ort.InferenceSession(
|
||||
self.cache_dir / "textual.onnx",
|
||||
self.textual_path.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
self.text_outputs = [output.name for output in self.text_model.get_outputs()]
|
||||
self.tokenizer = Tokenizer(self.model_name)
|
||||
|
||||
if self.mode == "vision" or self.mode is None:
|
||||
log.debug(f"Loading clip vision model '{self.model_name}'")
|
||||
|
||||
self.vision_model = ort.InferenceSession(
|
||||
self.cache_dir / "visual.onnx",
|
||||
self.visual_path.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
self.vision_outputs = [output.name for output in self.vision_model.get_outputs()]
|
||||
|
||||
image_size = _VISUAL_MODEL_IMAGE_SIZE[CLIPOnnxModel.get_model_name(self.model_name)]
|
||||
self.transform = _transform_pil_image(image_size)
|
||||
|
||||
def _predict(self, image_or_text: Image.Image | str) -> list[float]:
|
||||
if isinstance(image_or_text, bytes):
|
||||
@@ -78,55 +59,106 @@ class CLIPEncoder(InferenceModel):
|
||||
case Image.Image():
|
||||
if self.mode == "text":
|
||||
raise TypeError("Cannot encode image as text-only model")
|
||||
pixel_values = self.transform(image_or_text)
|
||||
assert isinstance(pixel_values, torch.Tensor)
|
||||
pixel_values = torch.unsqueeze(pixel_values, 0).numpy()
|
||||
outputs = self.vision_model.run(self.vision_outputs, {"pixel_values": pixel_values})
|
||||
|
||||
outputs = self.vision_model.run(None, self.transform(image_or_text))
|
||||
case str():
|
||||
if self.mode == "vision":
|
||||
raise TypeError("Cannot encode text as vision-only model")
|
||||
text_inputs: dict[str, torch.Tensor] = self.tokenizer(image_or_text)
|
||||
inputs = {
|
||||
"input_ids": text_inputs["input_ids"].int().numpy(),
|
||||
"attention_mask": text_inputs["attention_mask"].int().numpy(),
|
||||
}
|
||||
outputs = self.text_model.run(self.text_outputs, inputs)
|
||||
|
||||
outputs = self.text_model.run(None, self.tokenize(image_or_text))
|
||||
case _:
|
||||
raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
|
||||
|
||||
return outputs[0][0].tolist()
|
||||
|
||||
def _download_model(self, model_name: str, model_md5: str) -> bool:
|
||||
# downloading logic is adapted from clip-server's CLIPOnnxModel class
|
||||
download_model(
|
||||
url=_S3_BUCKET_V2 + model_name,
|
||||
target_folder=self.cache_dir.as_posix(),
|
||||
md5sum=model_md5,
|
||||
with_resume=True,
|
||||
)
|
||||
file = self.cache_dir / model_name.split("/")[1]
|
||||
if file.suffix == ".zip":
|
||||
with zipfile.ZipFile(file, "r") as zip_ref:
|
||||
zip_ref.extractall(self.cache_dir)
|
||||
os.remove(file)
|
||||
return True
|
||||
@abstractmethod
|
||||
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def transform(self, image: Image.Image) -> dict[str, ndarray_f32]:
|
||||
pass
|
||||
|
||||
@property
|
||||
def textual_dir(self) -> Path:
|
||||
return self.cache_dir / "textual"
|
||||
|
||||
@property
|
||||
def visual_dir(self) -> Path:
|
||||
return self.cache_dir / "visual"
|
||||
|
||||
@property
|
||||
def model_cfg_path(self) -> Path:
|
||||
return self.cache_dir / "config.json"
|
||||
|
||||
@property
|
||||
def textual_path(self) -> Path:
|
||||
return self.textual_dir / "model.onnx"
|
||||
|
||||
@property
|
||||
def visual_path(self) -> Path:
|
||||
return self.visual_dir / "model.onnx"
|
||||
|
||||
@property
|
||||
def preprocess_cfg_path(self) -> Path:
|
||||
return self.visual_dir / "preprocess_cfg.json"
|
||||
|
||||
@property
|
||||
def cached(self) -> bool:
|
||||
return (self.cache_dir / "textual.onnx").is_file() and (self.cache_dir / "visual.onnx").is_file()
|
||||
return self.textual_path.is_file() and self.visual_path.is_file()
|
||||
|
||||
|
||||
# same as `_transform_blob` without `_blob2image`
|
||||
def _transform_pil_image(n_px: int) -> Compose:
|
||||
return Compose(
|
||||
[
|
||||
Resize(n_px, interpolation=BICUBIC),
|
||||
CenterCrop(n_px),
|
||||
_convert_image_to_rgb,
|
||||
ToTensor(),
|
||||
Normalize(
|
||||
(0.48145466, 0.4578275, 0.40821073),
|
||||
(0.26862954, 0.26130258, 0.27577711),
|
||||
),
|
||||
]
|
||||
)
|
||||
class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
cache_dir: str | None = None,
|
||||
mode: Literal["text", "vision"] | None = None,
|
||||
**model_kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(clean_name(model_name), cache_dir, mode, **model_kwargs)
|
||||
|
||||
def _load(self) -> None:
|
||||
super()._load()
|
||||
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(self.textual_dir)
|
||||
self.sequence_length = self.model_cfg["text_cfg"]["context_length"]
|
||||
|
||||
self.size = (
|
||||
self.preprocess_cfg["size"][0] if type(self.preprocess_cfg["size"]) == list else self.preprocess_cfg["size"]
|
||||
)
|
||||
self.resampling = get_pil_resampling(self.preprocess_cfg["interpolation"])
|
||||
self.mean = np.array(self.preprocess_cfg["mean"], dtype=np.float32)
|
||||
self.std = np.array(self.preprocess_cfg["std"], dtype=np.float32)
|
||||
|
||||
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
|
||||
input_ids: ndarray_i64 = self.tokenizer(
|
||||
text,
|
||||
max_length=self.sequence_length,
|
||||
return_tensors="np",
|
||||
return_attention_mask=False,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
).input_ids
|
||||
return {"text": input_ids.astype(np.int32)}
|
||||
|
||||
def transform(self, image: Image.Image) -> dict[str, ndarray_f32]:
|
||||
image = resize(image, self.size)
|
||||
image = crop(image, self.size)
|
||||
image_np = to_numpy(image)
|
||||
image_np = normalize(image_np, self.mean, self.std)
|
||||
return {"image": np.expand_dims(image_np.transpose(2, 0, 1), 0)}
|
||||
|
||||
@cached_property
|
||||
def model_cfg(self) -> dict[str, Any]:
|
||||
return json.load(self.model_cfg_path.open())
|
||||
|
||||
@cached_property
|
||||
def preprocess_cfg(self) -> dict[str, Any]:
|
||||
return json.load(self.preprocess_cfg_path.open())
|
||||
|
||||
|
||||
class MCLIPEncoder(OpenCLIPEncoder):
|
||||
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
|
||||
tokens: dict[str, ndarray_i64] = self.tokenizer(text, return_tensors="np")
|
||||
return {k: v.astype(np.int32) for k, v in tokens.items()}
|
||||
|
||||
57
machine-learning/app/models/constants.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from app.config import clean_name
|
||||
|
||||
_OPENCLIP_MODELS = {
|
||||
"RN50__openai",
|
||||
"RN50__yfcc15m",
|
||||
"RN50__cc12m",
|
||||
"RN101__openai",
|
||||
"RN101__yfcc15m",
|
||||
"RN50x4__openai",
|
||||
"RN50x16__openai",
|
||||
"RN50x64__openai",
|
||||
"ViT-B-32__openai",
|
||||
"ViT-B-32__laion2b_e16",
|
||||
"ViT-B-32__laion400m_e31",
|
||||
"ViT-B-32__laion400m_e32",
|
||||
"ViT-B-32__laion2b-s34b-b79k",
|
||||
"ViT-B-16__openai",
|
||||
"ViT-B-16__laion400m_e31",
|
||||
"ViT-B-16__laion400m_e32",
|
||||
"ViT-B-16-plus-240__laion400m_e31",
|
||||
"ViT-B-16-plus-240__laion400m_e32",
|
||||
"ViT-L-14__openai",
|
||||
"ViT-L-14__laion400m_e31",
|
||||
"ViT-L-14__laion400m_e32",
|
||||
"ViT-L-14__laion2b-s32b-b82k",
|
||||
"ViT-L-14-336__openai",
|
||||
"ViT-H-14__laion2b-s32b-b79k",
|
||||
"ViT-g-14__laion2b-s12b-b42k",
|
||||
}
|
||||
|
||||
|
||||
_MCLIP_MODELS = {
|
||||
"LABSE-Vit-L-14",
|
||||
"XLM-Roberta-Large-Vit-B-32",
|
||||
"XLM-Roberta-Large-Vit-B-16Plus",
|
||||
"XLM-Roberta-Large-Vit-L-14",
|
||||
}
|
||||
|
||||
|
||||
_INSIGHTFACE_MODELS = {
|
||||
"antelopev2",
|
||||
"buffalo_l",
|
||||
"buffalo_m",
|
||||
"buffalo_s",
|
||||
}
|
||||
|
||||
|
||||
def is_openclip(model_name: str) -> bool:
|
||||
return clean_name(model_name) in _OPENCLIP_MODELS
|
||||
|
||||
|
||||
def is_mclip(model_name: str) -> bool:
|
||||
return clean_name(model_name) in _MCLIP_MODELS
|
||||
|
||||
|
||||
def is_insightface(model_name: str) -> bool:
|
||||
return clean_name(model_name) in _INSIGHTFACE_MODELS
|
||||
@@ -1,4 +1,3 @@
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -7,9 +6,10 @@ import numpy as np
|
||||
import onnxruntime as ort
|
||||
from insightface.model_zoo import ArcFaceONNX, RetinaFace
|
||||
from insightface.utils.face_align import norm_crop
|
||||
from insightface.utils.storage import BASE_REPO_URL, download_file
|
||||
|
||||
from ..schemas import ModelType
|
||||
from app.config import clean_name
|
||||
from app.schemas import ModelType, ndarray_f32
|
||||
|
||||
from .base import InferenceModel
|
||||
|
||||
|
||||
@@ -24,37 +24,21 @@ class FaceRecognizer(InferenceModel):
|
||||
**model_kwargs: Any,
|
||||
) -> None:
|
||||
self.min_score = model_kwargs.pop("minScore", min_score)
|
||||
super().__init__(model_name, cache_dir, **model_kwargs)
|
||||
|
||||
def _download(self) -> None:
|
||||
zip_file = self.cache_dir / f"{self.model_name}.zip"
|
||||
download_file(f"{BASE_REPO_URL}/{self.model_name}.zip", zip_file)
|
||||
with zipfile.ZipFile(zip_file, "r") as zip:
|
||||
members = zip.namelist()
|
||||
det_file = next(model for model in members if model.startswith("det_"))
|
||||
rec_file = next(model for model in members if model.startswith("w600k_"))
|
||||
zip.extractall(self.cache_dir, members=[det_file, rec_file])
|
||||
zip_file.unlink()
|
||||
super().__init__(clean_name(model_name), cache_dir, **model_kwargs)
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
det_file = next(self.cache_dir.glob("det_*.onnx"))
|
||||
rec_file = next(self.cache_dir.glob("w600k_*.onnx"))
|
||||
except StopIteration:
|
||||
raise FileNotFoundError("Facial recognition models not found in cache directory")
|
||||
|
||||
self.det_model = RetinaFace(
|
||||
session=ort.InferenceSession(
|
||||
det_file.as_posix(),
|
||||
self.det_file.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
),
|
||||
)
|
||||
self.rec_model = ArcFaceONNX(
|
||||
rec_file.as_posix(),
|
||||
self.rec_file.as_posix(),
|
||||
session=ort.InferenceSession(
|
||||
rec_file.as_posix(),
|
||||
self.rec_file.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
@@ -68,7 +52,7 @@ class FaceRecognizer(InferenceModel):
|
||||
)
|
||||
self.rec_model.prepare(ctx_id=0)
|
||||
|
||||
def _predict(self, image: np.ndarray[int, np.dtype[Any]] | bytes) -> list[dict[str, Any]]:
|
||||
def _predict(self, image: ndarray_f32 | bytes) -> list[dict[str, Any]]:
|
||||
if isinstance(image, bytes):
|
||||
image = cv2.imdecode(np.frombuffer(image, np.uint8), cv2.IMREAD_COLOR)
|
||||
bboxes, kpss = self.det_model.detect(image)
|
||||
@@ -102,7 +86,15 @@ class FaceRecognizer(InferenceModel):
|
||||
|
||||
@property
|
||||
def cached(self) -> bool:
|
||||
return self.cache_dir.is_dir() and any(self.cache_dir.glob("*.onnx"))
|
||||
return self.det_file.is_file() and self.rec_file.is_file()
|
||||
|
||||
@property
|
||||
def det_file(self) -> Path:
|
||||
return self.cache_dir / "detection" / "model.onnx"
|
||||
|
||||
@property
|
||||
def rec_file(self) -> Path:
|
||||
return self.cache_dir / "recognition" / "model.onnx"
|
||||
|
||||
def configure(self, **model_kwargs: Any) -> None:
|
||||
self.det_model.det_thresh = model_kwargs.pop("minScore", self.det_model.det_thresh)
|
||||
|
||||
35
machine-learning/app/models/transforms.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from app.schemas import ndarray_f32
|
||||
|
||||
_PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling}
|
||||
|
||||
|
||||
def resize(img: Image.Image, size: int) -> Image.Image:
|
||||
if img.width < img.height:
|
||||
return img.resize((size, int((img.height / img.width) * size)), resample=Image.BICUBIC)
|
||||
else:
|
||||
return img.resize((int((img.width / img.height) * size), size), resample=Image.BICUBIC)
|
||||
|
||||
|
||||
# https://stackoverflow.com/a/60883103
|
||||
def crop(img: Image.Image, size: int) -> Image.Image:
|
||||
left = int((img.size[0] / 2) - (size / 2))
|
||||
upper = int((img.size[1] / 2) - (size / 2))
|
||||
right = left + size
|
||||
lower = upper + size
|
||||
|
||||
return img.crop((left, upper, right, lower))
|
||||
|
||||
|
||||
def to_numpy(img: Image.Image) -> ndarray_f32:
|
||||
return np.asarray(img.convert("RGB")).astype(np.float32) / 255.0
|
||||
|
||||
|
||||
def normalize(img: ndarray_f32, mean: float | ndarray_f32, std: float | ndarray_f32) -> ndarray_f32:
|
||||
return (img - mean) / std
|
||||
|
||||
|
||||
def get_pil_resampling(resample: str) -> Image.Resampling:
|
||||
return _PIL_RESAMPLING_METHODS[resample.lower()]
|
||||
@@ -1,5 +1,7 @@
|
||||
from enum import StrEnum
|
||||
from typing import TypeAlias
|
||||
|
||||
import numpy as np
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -31,3 +33,8 @@ class ModelType(StrEnum):
|
||||
IMAGE_CLASSIFICATION = "image-classification"
|
||||
CLIP = "clip"
|
||||
FACIAL_RECOGNITION = "facial-recognition"
|
||||
|
||||
|
||||
ndarray_f32: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
|
||||
ndarray_i64: TypeAlias = np.ndarray[int, np.dtype[np.int64]]
|
||||
ndarray_i32: TypeAlias = np.ndarray[int, np.dtype[np.int32]]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import json
|
||||
import pickle
|
||||
from io import BytesIO
|
||||
from typing import Any, TypeAlias
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from unittest import mock
|
||||
|
||||
import cv2
|
||||
@@ -14,13 +15,11 @@ from pytest_mock import MockerFixture
|
||||
from .config import settings
|
||||
from .models.base import PicklableSessionOptions
|
||||
from .models.cache import ModelCache
|
||||
from .models.clip import CLIPEncoder
|
||||
from .models.clip import OpenCLIPEncoder
|
||||
from .models.facial_recognition import FaceRecognizer
|
||||
from .models.image_classification import ImageClassifier
|
||||
from .schemas import ModelType
|
||||
|
||||
ndarray: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
|
||||
|
||||
|
||||
class TestImageClassifier:
|
||||
classifier_preds = [
|
||||
@@ -56,30 +55,50 @@ class TestImageClassifier:
|
||||
|
||||
class TestCLIP:
|
||||
embedding = np.random.rand(512).astype(np.float32)
|
||||
cache_dir = Path("test_cache")
|
||||
|
||||
def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(CLIPEncoder, "download")
|
||||
def test_basic_image(
|
||||
self,
|
||||
pil_image: Image.Image,
|
||||
mocker: MockerFixture,
|
||||
clip_model_cfg: dict[str, Any],
|
||||
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
|
||||
) -> None:
|
||||
mocker.patch.object(OpenCLIPEncoder, "download")
|
||||
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
|
||||
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
|
||||
mocker.patch("app.models.clip.AutoTokenizer.from_pretrained", autospec=True)
|
||||
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
|
||||
mocked.return_value.run.return_value = [[self.embedding]]
|
||||
clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
|
||||
assert clip_encoder.mode == "vision"
|
||||
|
||||
clip_encoder = OpenCLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
|
||||
embedding = clip_encoder.predict(pil_image)
|
||||
|
||||
assert clip_encoder.mode == "vision"
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == 512
|
||||
assert len(embedding) == clip_model_cfg["embed_dim"]
|
||||
assert all([isinstance(num, float) for num in embedding])
|
||||
clip_encoder.vision_model.run.assert_called_once()
|
||||
|
||||
def test_basic_text(self, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(CLIPEncoder, "download")
|
||||
def test_basic_text(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
clip_model_cfg: dict[str, Any],
|
||||
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
|
||||
) -> None:
|
||||
mocker.patch.object(OpenCLIPEncoder, "download")
|
||||
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
|
||||
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
|
||||
mocker.patch("app.models.clip.AutoTokenizer.from_pretrained", autospec=True)
|
||||
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
|
||||
mocked.return_value.run.return_value = [[self.embedding]]
|
||||
clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
|
||||
assert clip_encoder.mode == "text"
|
||||
|
||||
clip_encoder = OpenCLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
|
||||
embedding = clip_encoder.predict("test search query")
|
||||
|
||||
assert clip_encoder.mode == "text"
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == 512
|
||||
assert len(embedding) == clip_model_cfg["embed_dim"]
|
||||
assert all([isinstance(num, float) for num in embedding])
|
||||
clip_encoder.text_model.run.assert_called_once()
|
||||
|
||||
@@ -87,13 +106,13 @@ class TestCLIP:
|
||||
class TestFaceRecognition:
|
||||
def test_set_min_score(self, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(FaceRecognizer, "load")
|
||||
face_recognizer = FaceRecognizer("test_model_name", cache_dir="test_cache", min_score=0.5)
|
||||
face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache", min_score=0.5)
|
||||
|
||||
assert face_recognizer.min_score == 0.5
|
||||
|
||||
def test_basic(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(FaceRecognizer, "load")
|
||||
face_recognizer = FaceRecognizer("test_model_name", min_score=0.0, cache_dir="test_cache")
|
||||
face_recognizer = FaceRecognizer("buffalo_s", min_score=0.0, cache_dir="test_cache")
|
||||
|
||||
det_model = mock.Mock()
|
||||
num_faces = 2
|
||||
|
||||
20
machine-learning/export/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM mambaorg/micromamba:bookworm-slim as builder
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
TRANSFORMERS_CACHE=/cache \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONPATH=/usr/src
|
||||
|
||||
COPY --chown=$MAMBA_USER:$MAMBA_USER conda-lock.yml /tmp/conda-lock.yml
|
||||
RUN micromamba install -y -n base -f /tmp/conda-lock.yml && \
|
||||
micromamba remove -y -n base cxx-compiler && \
|
||||
micromamba clean --all --yes
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY --chown=$MAMBA_USER:$MAMBA_USER export .
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/_entrypoint.sh"]
|
||||
CMD ["python -m run"]
|
||||
0
machine-learning/export/__init__.py
Normal file
3520
machine-learning/export/conda-lock.yml
Normal file
15
machine-learning/export/env.dev.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
name: base
|
||||
channels:
|
||||
- conda-forge
|
||||
platforms:
|
||||
- linux-64
|
||||
- linux-aarch64
|
||||
dependencies:
|
||||
- black
|
||||
- conda-lock
|
||||
- mypy
|
||||
- pytest
|
||||
- pytest-cov
|
||||
- pytest-mock
|
||||
- ruff
|
||||
category: dev
|
||||
25
machine-learning/export/env.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: base
|
||||
channels:
|
||||
- conda-forge
|
||||
- nvidia
|
||||
- pytorch-nightly
|
||||
platforms:
|
||||
- linux-64
|
||||
dependencies:
|
||||
- cxx-compiler
|
||||
- onnx==1.*
|
||||
- onnxruntime==1.*
|
||||
- open-clip-torch==2.*
|
||||
- orjson==3.*
|
||||
- pip
|
||||
- python==3.11.*
|
||||
- pytorch
|
||||
- rich==13.*
|
||||
- safetensors==0.*
|
||||
- setuptools==68.*
|
||||
- torchvision
|
||||
- transformers==4.*
|
||||
- pip:
|
||||
- multilingual-clip
|
||||
- onnx-simplifier
|
||||
category: main
|
||||
0
machine-learning/export/models/__init__.py
Normal file
9
machine-learning/export/models/constants.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from export.models.openclip import OpenCLIPModelConfig
|
||||
|
||||
|
||||
MCLIP_TO_OPENCLIP = {
|
||||
"XLM-Roberta-Large-Vit-B-32": OpenCLIPModelConfig("ViT-B-32", "openai"),
|
||||
"XLM-Roberta-Large-Vit-B-16Plus": OpenCLIPModelConfig("ViT-B-16-plus-240", "laion400m_e32"),
|
||||
"LABSE-Vit-L-14": OpenCLIPModelConfig("ViT-L-14", "openai"),
|
||||
"XLM-Roberta-Large-Vit-L-14": OpenCLIPModelConfig("ViT-L-14", "openai"),
|
||||
}
|
||||
60
machine-learning/export/models/mclip.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import tempfile
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from export.models.constants import MCLIP_TO_OPENCLIP
|
||||
|
||||
import torch
|
||||
from multilingual_clip.pt_multilingual_clip import MultilingualCLIP
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from .openclip import to_onnx as openclip_to_onnx
|
||||
from .optimize import optimize
|
||||
from .util import get_model_path, clean_name
|
||||
|
||||
|
||||
def to_onnx(
|
||||
model_name: str,
|
||||
output_dir_visual: Path | str,
|
||||
output_dir_textual: Path | str,
|
||||
) -> None:
|
||||
textual_path = get_model_path(output_dir_textual)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
model = MultilingualCLIP.from_pretrained(model_name, cache_dir=tmpdir)
|
||||
AutoTokenizer.from_pretrained(model_name).save_pretrained(output_dir_textual)
|
||||
|
||||
for param in model.parameters():
|
||||
param.requires_grad_(False)
|
||||
|
||||
export_text_encoder(model, textual_path)
|
||||
openclip_to_onnx(MCLIP_TO_OPENCLIP[clean_name(model_name)], output_dir_visual)
|
||||
optimize(textual_path)
|
||||
|
||||
|
||||
def export_text_encoder(model: MultilingualCLIP, output_path: Path | str) -> None:
|
||||
output_path = Path(output_path)
|
||||
|
||||
def forward(self: MultilingualCLIP, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
||||
embs = self.transformer(input_ids, attention_mask)[0]
|
||||
embs = (embs * attention_mask.unsqueeze(2)).sum(dim=1) / attention_mask.sum(dim=1)[:, None]
|
||||
embs = self.LinearTransformation(embs)
|
||||
return torch.nn.functional.normalize(embs, dim=-1)
|
||||
|
||||
# unfortunately need to monkeypatch for tracing to work here
|
||||
# otherwise it hits the 2GiB protobuf serialization limit
|
||||
MultilingualCLIP.forward = forward
|
||||
|
||||
args = (torch.ones(1, 77, dtype=torch.int32), torch.ones(1, 77, dtype=torch.int32))
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
torch.onnx.export(
|
||||
model,
|
||||
args,
|
||||
output_path.as_posix(),
|
||||
input_names=["input_ids", "attention_mask"],
|
||||
output_names=["text_embedding"],
|
||||
opset_version=17,
|
||||
dynamic_axes={
|
||||
"input_ids": {0: "batch_size", 1: "sequence_length"},
|
||||
"attention_mask": {0: "batch_size", 1: "sequence_length"},
|
||||
},
|
||||
)
|
||||
109
machine-learning/export/models/openclip.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import tempfile
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import open_clip
|
||||
import torch
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from .optimize import optimize
|
||||
from .util import get_model_path, save_config
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenCLIPModelConfig:
|
||||
name: str
|
||||
pretrained: str
|
||||
image_size: int = field(init=False)
|
||||
sequence_length: int = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
open_clip_cfg = open_clip.get_model_config(self.name)
|
||||
if open_clip_cfg is None:
|
||||
raise ValueError(f"Unknown model {self.name}")
|
||||
self.image_size = open_clip_cfg["vision_cfg"]["image_size"]
|
||||
self.sequence_length = open_clip_cfg["text_cfg"]["context_length"]
|
||||
|
||||
|
||||
def to_onnx(
|
||||
model_cfg: OpenCLIPModelConfig,
|
||||
output_dir_visual: Path | str | None = None,
|
||||
output_dir_textual: Path | str | None = None,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
model = open_clip.create_model(
|
||||
model_cfg.name,
|
||||
pretrained=model_cfg.pretrained,
|
||||
jit=False,
|
||||
cache_dir=tmpdir,
|
||||
require_pretrained=True,
|
||||
)
|
||||
|
||||
text_vision_cfg = open_clip.get_model_config(model_cfg.name)
|
||||
|
||||
for param in model.parameters():
|
||||
param.requires_grad_(False)
|
||||
|
||||
if output_dir_visual is not None:
|
||||
output_dir_visual = Path(output_dir_visual)
|
||||
visual_path = get_model_path(output_dir_visual)
|
||||
|
||||
save_config(open_clip.get_model_preprocess_cfg(model), output_dir_visual / "preprocess_cfg.json")
|
||||
save_config(text_vision_cfg, output_dir_visual.parent / "config.json")
|
||||
export_image_encoder(model, model_cfg, visual_path)
|
||||
|
||||
optimize(visual_path)
|
||||
|
||||
if output_dir_textual is not None:
|
||||
output_dir_textual = Path(output_dir_textual)
|
||||
textual_path = get_model_path(output_dir_textual)
|
||||
|
||||
tokenizer_name = text_vision_cfg["text_cfg"].get("hf_tokenizer_name", "openai/clip-vit-base-patch32")
|
||||
AutoTokenizer.from_pretrained(tokenizer_name).save_pretrained(output_dir_textual)
|
||||
export_text_encoder(model, model_cfg, textual_path)
|
||||
optimize(textual_path)
|
||||
|
||||
|
||||
def export_image_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig, output_path: Path | str) -> None:
|
||||
output_path = Path(output_path)
|
||||
|
||||
def encode_image(image: torch.Tensor) -> torch.Tensor:
|
||||
return model.encode_image(image, normalize=True)
|
||||
|
||||
args = (torch.randn(1, 3, model_cfg.image_size, model_cfg.image_size),)
|
||||
traced = torch.jit.trace(encode_image, args)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
torch.onnx.export(
|
||||
traced,
|
||||
args,
|
||||
output_path.as_posix(),
|
||||
input_names=["image"],
|
||||
output_names=["image_embedding"],
|
||||
opset_version=17,
|
||||
dynamic_axes={"image": {0: "batch_size"}},
|
||||
)
|
||||
|
||||
|
||||
def export_text_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig, output_path: Path | str) -> None:
|
||||
output_path = Path(output_path)
|
||||
|
||||
def encode_text(text: torch.Tensor) -> torch.Tensor:
|
||||
return model.encode_text(text, normalize=True)
|
||||
|
||||
args = (torch.ones(1, model_cfg.sequence_length, dtype=torch.int32),)
|
||||
traced = torch.jit.trace(encode_text, args)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
torch.onnx.export(
|
||||
traced,
|
||||
args,
|
||||
output_path.as_posix(),
|
||||
input_names=["text"],
|
||||
output_names=["text_embedding"],
|
||||
opset_version=17,
|
||||
dynamic_axes={"text": {0: "batch_size"}},
|
||||
)
|
||||
38
machine-learning/export/models/optimize.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
import onnx
|
||||
import onnxruntime as ort
|
||||
import onnxsim
|
||||
|
||||
|
||||
def optimize_onnxsim(model_path: Path | str, output_path: Path | str) -> None:
|
||||
model_path = Path(model_path)
|
||||
output_path = Path(output_path)
|
||||
model = onnx.load(model_path.as_posix())
|
||||
model, check = onnxsim.simplify(model, skip_shape_inference=True)
|
||||
assert check, "Simplified ONNX model could not be validated"
|
||||
onnx.save(model, output_path.as_posix())
|
||||
|
||||
|
||||
def optimize_ort(
|
||||
model_path: Path | str,
|
||||
output_path: Path | str,
|
||||
level: ort.GraphOptimizationLevel = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC,
|
||||
) -> None:
|
||||
model_path = Path(model_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
sess_options = ort.SessionOptions()
|
||||
sess_options.graph_optimization_level = level
|
||||
sess_options.optimized_model_filepath = output_path.as_posix()
|
||||
|
||||
ort.InferenceSession(model_path.as_posix(), providers=["CPUExecutionProvider"], sess_options=sess_options)
|
||||
|
||||
|
||||
def optimize(model_path: Path | str) -> None:
|
||||
model_path = Path(model_path)
|
||||
|
||||
optimize_ort(model_path, model_path)
|
||||
# onnxsim serializes large models as a blob, which uses much more memory when loading the model at runtime
|
||||
if not any(file.name.startswith("Constant") for file in model_path.parent.iterdir()):
|
||||
optimize_onnxsim(model_path, model_path)
|
||||
22
machine-learning/export/models/util.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
_clean_name = str.maketrans(":\\/", "___", ".")
|
||||
|
||||
|
||||
def get_model_path(output_dir: Path | str) -> Path:
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return output_dir / "model.onnx"
|
||||
|
||||
|
||||
def save_config(config: Any, output_path: Path | str) -> None:
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json.dump(config, output_path.open("w"))
|
||||
|
||||
|
||||
def clean_name(model_name: str) -> str:
|
||||
return model_name.split("/")[-1].translate(_clean_name)
|
||||
131
machine-learning/export/run.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from enum import StrEnum
|
||||
import gc
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Optional
|
||||
|
||||
from huggingface_hub import create_repo, upload_folder
|
||||
from export.models import mclip, openclip, insightface
|
||||
from export.models.util import clean_name
|
||||
from rich.progress import Progress
|
||||
import typer
|
||||
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
|
||||
class ModelLibrary(StrEnum):
|
||||
MCLIP = "mclip"
|
||||
OPENCLIP = "openclip"
|
||||
INSIGHTFACE = "insightface"
|
||||
|
||||
|
||||
def _export(model_name: str, library: ModelLibrary, export_dir: Path) -> None:
|
||||
visual_dir = export_dir / "visual"
|
||||
textual_dir = export_dir / "textual"
|
||||
match library:
|
||||
case ModelLibrary.MCLIP:
|
||||
insightface.to_onnx(model_name, visual_dir, textual_dir)
|
||||
case ModelLibrary.OPENCLIP:
|
||||
mclip.to_onnx(model_name, visual_dir, textual_dir)
|
||||
case ModelLibrary.INSIGHTFACE:
|
||||
name, _, pretrained = model_name.partition("__")
|
||||
openclip.to_onnx(openclip.OpenCLIPModelConfig(name, pretrained), visual_dir, textual_dir)
|
||||
|
||||
gc.collect()
|
||||
|
||||
|
||||
def _upload(repo_id: str, upload_dir: Path, auth_token: str | None = os.environ.get("HF_AUTH_TOKEN", None)) -> None:
|
||||
create_repo(repo_id, exist_ok=True, token=auth_token)
|
||||
upload_folder(repo_id=repo_id, folder_path=upload_dir, token=auth_token)
|
||||
|
||||
|
||||
@app.command()
|
||||
def export(
|
||||
models: list[str] = typer.Argument(
|
||||
..., help="The model(s) to be exported. Model names should be the same as used in the associated library."
|
||||
),
|
||||
library: ModelLibrary = typer.Option(
|
||||
..., "--library", "-l", help="The library associated with the models to be exported."
|
||||
),
|
||||
output_dir: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--output-dir",
|
||||
"-o",
|
||||
help="Directory where exported models will be stored. Defaults to a temporary directory.",
|
||||
),
|
||||
should_upload: bool = typer.Option(False, "--upload", "-u", help="Whether to upload the exported models."),
|
||||
auth_token: Optional[str] = typer.Option(
|
||||
os.environ.get("HF_AUTH_TOKEN", None),
|
||||
"--auth_token",
|
||||
"-t",
|
||||
help="If uploading models to Hugging Face, the auth token of the user or organisation.",
|
||||
),
|
||||
repo_prefix: str = typer.Option(
|
||||
"immich-app",
|
||||
"--repo_prefix",
|
||||
"-p",
|
||||
help="If uploading models to Hugging Face, the prefix to put before the model name. Can be a username or organisation.",
|
||||
),
|
||||
) -> None:
|
||||
if not models:
|
||||
raise ValueError("No models specified")
|
||||
|
||||
with Progress() as progress:
|
||||
task1 = progress.add_task("[green]Exporting model(s)...", total=len(models))
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
output_dir = output_dir if output_dir else Path(tmp)
|
||||
for model_name in models:
|
||||
cleaned_name = clean_name(model_name)
|
||||
model_dir = output_dir / cleaned_name
|
||||
progress.update(task1, description=f"[green]Exporting {cleaned_name}")
|
||||
_export(model_name, library, model_dir)
|
||||
progress.update(task1, advance=1, description=f"[green]Exported {cleaned_name}")
|
||||
|
||||
if should_upload:
|
||||
upload(models, output_dir, auth_token, repo_prefix)
|
||||
|
||||
|
||||
@app.command()
|
||||
def upload(
|
||||
models: list[str] = typer.Argument(
|
||||
..., help="The model(s) to be uploaded. Model names should be the same as used in the associated library."
|
||||
),
|
||||
output_dir: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--output-dir",
|
||||
"-o",
|
||||
help="Directory where exported models will be stored. Defaults to a temporary directory.",
|
||||
),
|
||||
auth_token: Optional[str] = typer.Option(
|
||||
os.environ.get("HF_AUTH_TOKEN", None),
|
||||
"--auth_token",
|
||||
"-t",
|
||||
help="The Hugging Face auth token of the user or organisation.",
|
||||
),
|
||||
repo_prefix: str = typer.Option(
|
||||
"immich-app",
|
||||
"--repo_prefix",
|
||||
"-p",
|
||||
help="The name to put before the model name to form the Hugging Face repo name. Can be a username or organisation.",
|
||||
),
|
||||
) -> None:
|
||||
if not models:
|
||||
raise ValueError("No models specified")
|
||||
|
||||
with Progress() as progress:
|
||||
task2 = progress.add_task("[yellow]Uploading models...", total=len(models))
|
||||
for model_name in models:
|
||||
cleaned_name = clean_name(model_name)
|
||||
repo_id = f"{repo_prefix}/{cleaned_name}"
|
||||
model_dir = output_dir / cleaned_name
|
||||
|
||||
progress.update(task2, description=f"[yellow]Uploading {cleaned_name}")
|
||||
_upload(repo_id, model_dir, auth_token)
|
||||
progress.update(task2, advance=1, description=f"[yellow]Uploaded {cleaned_name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -1,11 +1,12 @@
|
||||
from io import BytesIO
|
||||
import json
|
||||
from argparse import ArgumentParser
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from locust import HttpUser, events, task
|
||||
from locust.env import Environment
|
||||
from PIL import Image
|
||||
from argparse import ArgumentParser
|
||||
|
||||
byte_image = BytesIO()
|
||||
|
||||
|
||||
@@ -14,11 +15,21 @@ def _(parser: ArgumentParser) -> None:
|
||||
parser.add_argument("--tag-model", type=str, default="microsoft/resnet-50")
|
||||
parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai")
|
||||
parser.add_argument("--face-model", type=str, default="buffalo_l")
|
||||
parser.add_argument("--tag-min-score", type=int, default=0.0,
|
||||
help="Returns all tags at or above this score. The default returns all tags.")
|
||||
parser.add_argument("--face-min-score", type=int, default=0.034,
|
||||
help=("Returns all faces at or above this score. The default returns 1 face per request; "
|
||||
"setting this to 0 blows up the number of faces to the thousands."))
|
||||
parser.add_argument(
|
||||
"--tag-min-score",
|
||||
type=int,
|
||||
default=0.0,
|
||||
help="Returns all tags at or above this score. The default returns all tags.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--face-min-score",
|
||||
type=int,
|
||||
default=0.034,
|
||||
help=(
|
||||
"Returns all faces at or above this score. The default returns 1 face per request; "
|
||||
"setting this to 0 blows up the number of faces to the thousands."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--image-size", type=int, default=1000)
|
||||
|
||||
|
||||
@@ -62,7 +73,7 @@ class CLIPTextFormDataLoadTest(InferenceLoadTest):
|
||||
("modelName", self.environment.parsed_options.clip_model),
|
||||
("modelType", "clip"),
|
||||
("options", json.dumps({"mode": "text"})),
|
||||
("text", "test search query")
|
||||
("text", "test search query"),
|
||||
]
|
||||
self.client.post("/predict", data=data)
|
||||
|
||||
@@ -88,5 +99,5 @@ class RecognitionFormDataLoadTest(InferenceLoadTest):
|
||||
("options", json.dumps({"minScore": self.environment.parsed_options.face_min_score})),
|
||||
]
|
||||
files = {"image": self.data}
|
||||
|
||||
|
||||
self.client.post("/predict", data=data, files=files)
|
||||
|
||||
3875
machine-learning/poetry.lock
generated
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.83.0"
|
||||
version = "1.85.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
@@ -9,8 +9,8 @@ packages = [{include = "app"}]
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
torch = [
|
||||
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=2.0.1", source = "pypi"},
|
||||
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"}
|
||||
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=2.1.0", source = "pypi"},
|
||||
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.1.0", source = "pytorch-cpu"}
|
||||
]
|
||||
transformers = "^4.29.2"
|
||||
onnxruntime = "^1.15.0"
|
||||
@@ -22,14 +22,9 @@ uvicorn = {extras = ["standard"], version = "^0.22.0"}
|
||||
pydantic = "^1.10.8"
|
||||
aiocache = "^0.12.1"
|
||||
optimum = "^1.9.1"
|
||||
torchvision = [
|
||||
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=0.15.2", source = "pypi"},
|
||||
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=0.15.2", source = "pytorch-cpu"}
|
||||
]
|
||||
rich = "^13.4.2"
|
||||
ftfy = "^6.1.1"
|
||||
setuptools = "^68.0.0"
|
||||
open-clip-torch = "^2.20.0"
|
||||
python-multipart = "^0.0.6"
|
||||
orjson = "^3.9.5"
|
||||
safetensors = "0.3.2"
|
||||
@@ -63,6 +58,7 @@ warn_redundant_casts = true
|
||||
disallow_any_generics = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_defs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pydantic-mypy]
|
||||
init_forbid_extra = true
|
||||
@@ -70,30 +66,6 @@ init_typed = true
|
||||
warn_required_dynamic_aliases = true
|
||||
warn_untyped_fields = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"huggingface_hub",
|
||||
"transformers",
|
||||
"gunicorn",
|
||||
"cv2",
|
||||
"insightface.model_zoo",
|
||||
"insightface.utils.face_align",
|
||||
"insightface.utils.storage",
|
||||
"onnxruntime",
|
||||
"optimum",
|
||||
"optimum.pipelines",
|
||||
"optimum.onnxruntime",
|
||||
"clip_server.model.clip",
|
||||
"clip_server.model.clip_onnx",
|
||||
"clip_server.model.pretrained_models",
|
||||
"clip_server.model.tokenization",
|
||||
"torchvision.transforms",
|
||||
"aiocache.backends.memory",
|
||||
"aiocache.lock",
|
||||
"aiocache.plugins"
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# requirements to be installed with `--no-deps` flag
|
||||
clip-server==0.8.*
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 107,
|
||||
"android.injected.version.name" => "1.83.0",
|
||||
"android.injected.version.code" => 109,
|
||||
"android.injected.version.name" => "1.85.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
* User can now download assets to local device
|
||||
* Increased the font size for curated image thumbnail information on the seach page
|
||||
* Increased the font size for curated image thumbnail information on the search page
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000244">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.160108">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="67.0562">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.176668">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="33.087498">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"album_viewer_appbar_share_err_title": "Failed to change album title",
|
||||
"album_viewer_appbar_share_leave": "Leave album",
|
||||
"album_viewer_appbar_share_remove": "Remove from album",
|
||||
"album_viewer_appbar_share_to": "Share To",
|
||||
"album_viewer_page_share_add_users": "Add users",
|
||||
"all_people_page_title": "People",
|
||||
"all_videos_page_title": "Videos",
|
||||
@@ -113,7 +114,7 @@
|
||||
"cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)",
|
||||
"cache_settings_title": "Caching Settings",
|
||||
"change_password_form_confirm_password": "Confirm Password",
|
||||
"change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
|
||||
"change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
|
||||
"change_password_form_new_password": "New Password",
|
||||
"change_password_form_password_mismatch": "Passwords do not match",
|
||||
"change_password_form_reenter_new_password": "Re-enter New Password",
|
||||
@@ -253,6 +254,8 @@
|
||||
"profile_drawer_settings": "Settings",
|
||||
"profile_drawer_sign_out": "Sign Out",
|
||||
"profile_drawer_trash": "Trash",
|
||||
"profile_drawer_documentation": "Documentation",
|
||||
"profile_drawer_github": "GitHub",
|
||||
"recently_added_page_title": "Recently Added",
|
||||
"search_bar_hint": "Search your photos",
|
||||
"search_page_categories": "Categories",
|
||||
@@ -277,6 +280,7 @@
|
||||
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||
"server_info_box_app_version": "App Version",
|
||||
"server_info_box_server_version": "Server Version",
|
||||
"server_info_box_server_url": "Server URL",
|
||||
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
|
||||
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
|
||||
"setting_image_viewer_original_title": "Load original image",
|
||||
@@ -311,6 +315,9 @@
|
||||
"shared_link_edit_change_expiry": "Change expiration time",
|
||||
"shared_link_edit_description": "Description",
|
||||
"shared_link_edit_description_hint": "Enter the share description",
|
||||
"shared_link_edit_password": "Password",
|
||||
"shared_link_edit_expire_after": "Expire after",
|
||||
"shared_link_edit_password_hint": "Enter the share password",
|
||||
"shared_link_edit_show_meta": "Show metadata",
|
||||
"shared_link_edit_submit_button": "Update link",
|
||||
"shared_link_empty": "You don't have any shared links",
|
||||
@@ -364,5 +371,14 @@
|
||||
"viewer_unstack": "Un-Stack",
|
||||
"cache_settings_tile_title": "Local Storage",
|
||||
"cache_settings_tile_subtitle": "Control the local storage behaviour",
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset"
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
"app_bar_signout_dialog_title": "Sign out",
|
||||
"app_bar_signout_dialog_content": "Are you sure you wanna sign out?",
|
||||
"app_bar_signout_dialog_ok": "Yes",
|
||||
"shared_album_activities_input_hint": "Say something",
|
||||
"shared_album_activity_remove_title": "Delete Activity",
|
||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||
"shared_album_activity_setting_title": "Comments & likes",
|
||||
"shared_album_activity_setting_subtitle": "Let others respond",
|
||||
"shared_album_activities_input_disable": "Comment is disabled"
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 118;
|
||||
CURRENT_PROJECT_VERSION = 125;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -515,7 +515,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 118;
|
||||
CURRENT_PROJECT_VERSION = 125;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -543,7 +543,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 118;
|
||||
CURRENT_PROJECT_VERSION = 125;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -59,11 +59,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.78.1</string>
|
||||
<string>1.85.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>118</string>
|
||||
<string>125</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.83.0"
|
||||
version_number: "1.85.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000291">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="7.645306">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.199372">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.669798">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.104477">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.218788">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.164465">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="97.596654">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="108.828838">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="89.490906">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="60.89387">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
54
mobile/lib/extensions/build_context_extensions.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension ContextHelper on BuildContext {
|
||||
// Returns the current size from MediaQuery
|
||||
Size get size => MediaQuery.sizeOf(this);
|
||||
|
||||
// Returns the current width from MediaQuery
|
||||
double get width => size.width;
|
||||
|
||||
// Returns the current height from MediaQuery
|
||||
double get height => size.height;
|
||||
|
||||
// Returns true if the app is running on a mobile device (!tablets)
|
||||
bool get isMobile => width < 550;
|
||||
|
||||
// Returns the current ThemeData
|
||||
ThemeData get themeData => Theme.of(this);
|
||||
|
||||
// Returns true if the app is using a dark theme
|
||||
bool get isDarkTheme => themeData.brightness == Brightness.dark;
|
||||
|
||||
// Returns the current Primary color of the Theme
|
||||
Color get primaryColor => themeData.primaryColor;
|
||||
|
||||
// Returns the Scaffold background color of the Theme
|
||||
Color get scaffoldBackgroundColor => themeData.scaffoldBackgroundColor;
|
||||
|
||||
// Returns the current TextTheme
|
||||
TextTheme get textTheme => themeData.textTheme;
|
||||
|
||||
// Current ColorScheme used
|
||||
ColorScheme get colorScheme => themeData.colorScheme;
|
||||
|
||||
// Pop-out from the current context with optional result
|
||||
void pop<T>([T? result]) => Navigator.of(this).pop(result);
|
||||
|
||||
// Auto-Push new route from the current context
|
||||
Future<T?> autoPush<T extends Object?>(PageRouteInfo<dynamic> route) =>
|
||||
AutoRouter.of(this).push(route);
|
||||
|
||||
// Auto-Push navigate route from the current context
|
||||
Future<dynamic> autoNavigate<T extends Object?>(
|
||||
PageRouteInfo<dynamic> route,
|
||||
) =>
|
||||
AutoRouter.of(this).navigate(route);
|
||||
|
||||
// Auto-Push replace route from the current context
|
||||
Future<T?> autoReplace<T extends Object?>(PageRouteInfo<dynamic> route) =>
|
||||
AutoRouter.of(this).replace(route);
|
||||
|
||||
// Auto-Pop from the current context
|
||||
Future<bool> autoPop<T>([T? result]) => AutoRouter.of(this).pop(result);
|
||||
}
|
||||
@@ -2,27 +2,6 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
extension DurationExtension on String {
|
||||
Duration? toDuration() {
|
||||
try {
|
||||
final parts = split(':')
|
||||
.map((e) => double.parse(e).toInt())
|
||||
.toList(growable: false);
|
||||
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
double toDouble() {
|
||||
return double.parse(this);
|
||||
}
|
||||
|
||||
int toInt() {
|
||||
return int.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
extension ListExtension<E> on List<E> {
|
||||
List<E> uniqueConsecutive({
|
||||
int Function(E a, E b)? compare,
|
||||
36
mobile/lib/extensions/datetime_extensions.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
extension TimeAgoExtension on DateTime {
|
||||
String timeAgo({bool numericDates = true}) {
|
||||
DateTime date = toLocal();
|
||||
final date2 = DateTime.now().toLocal();
|
||||
final difference = date2.difference(date);
|
||||
|
||||
if (difference.inSeconds < 5) {
|
||||
return 'Just now';
|
||||
} else if (difference.inSeconds < 60) {
|
||||
return '${difference.inSeconds} seconds ago';
|
||||
} else if (difference.inMinutes <= 1) {
|
||||
return (numericDates) ? '1 minute ago' : 'A minute ago';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} minutes ago';
|
||||
} else if (difference.inHours <= 1) {
|
||||
return (numericDates) ? '1 hour ago' : 'An hour ago';
|
||||
} else if (difference.inHours < 60) {
|
||||
return '${difference.inHours} hours ago';
|
||||
} else if (difference.inDays <= 1) {
|
||||
return (numericDates) ? '1 day ago' : 'Yesterday';
|
||||
} else if (difference.inDays < 6) {
|
||||
return '${difference.inDays} days ago';
|
||||
} else if ((difference.inDays / 7).ceil() <= 1) {
|
||||
return (numericDates) ? '1 week ago' : 'Last week';
|
||||
} else if ((difference.inDays / 7).ceil() < 4) {
|
||||
return '${(difference.inDays / 7).ceil()} weeks ago';
|
||||
} else if ((difference.inDays / 30).ceil() <= 1) {
|
||||
return (numericDates) ? '1 month ago' : 'Last month';
|
||||
} else if ((difference.inDays / 30).ceil() < 30) {
|
||||
return '${(difference.inDays / 30).ceil()} months ago';
|
||||
} else if ((difference.inDays / 365).ceil() <= 1) {
|
||||
return (numericDates) ? '1 year ago' : 'Last year';
|
||||
}
|
||||
return '${(difference.inDays / 365).floor()} years ago';
|
||||
}
|
||||
}
|
||||
30
mobile/lib/extensions/string_extensions.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return split(" ")
|
||||
.map(
|
||||
(str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
extension DurationExtension on String {
|
||||
Duration? toDuration() {
|
||||
try {
|
||||
final parts = split(':')
|
||||
.map((e) => double.parse(e).toInt())
|
||||
.toList(growable: false);
|
||||
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
double toDouble() {
|
||||
return double.parse(this);
|
||||
}
|
||||
|
||||
int toInt() {
|
||||
return int.parse(this);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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:timezone/data/latest.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
@@ -77,6 +78,8 @@ Future<void> initApp() async {
|
||||
log.severe('Catch all error: ${error.toString()} - $error', error, stack);
|
||||
return true;
|
||||
};
|
||||
|
||||
initializeTimeZones();
|
||||
}
|
||||
|
||||
Future<Isar> loadDb() async {
|
||||
|
||||
89
mobile/lib/modules/activities/models/activity.model.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
enum ActivityType { comment, like }
|
||||
|
||||
class Activity {
|
||||
final String id;
|
||||
final String? assetId;
|
||||
final String? comment;
|
||||
final DateTime createdAt;
|
||||
final ActivityType type;
|
||||
final User user;
|
||||
|
||||
const Activity({
|
||||
required this.id,
|
||||
this.assetId,
|
||||
this.comment,
|
||||
required this.createdAt,
|
||||
required this.type,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
Activity copyWith({
|
||||
String? id,
|
||||
String? assetId,
|
||||
String? comment,
|
||||
DateTime? createdAt,
|
||||
ActivityType? type,
|
||||
User? user,
|
||||
}) {
|
||||
return Activity(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
comment: comment ?? this.comment,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
Activity.fromDto(ActivityResponseDto dto)
|
||||
: id = dto.id,
|
||||
assetId = dto.assetId,
|
||||
comment = dto.comment,
|
||||
createdAt = dto.createdAt,
|
||||
type = dto.type == ActivityResponseDtoTypeEnum.comment
|
||||
? ActivityType.comment
|
||||
: ActivityType.like,
|
||||
user = User(
|
||||
email: dto.user.email,
|
||||
name: dto.user.name,
|
||||
profileImagePath: dto.user.profileImagePath,
|
||||
id: dto.user.id,
|
||||
// Placeholder values
|
||||
isAdmin: false,
|
||||
updatedAt: DateTime.now(),
|
||||
isPartnerSharedBy: false,
|
||||
isPartnerSharedWith: false,
|
||||
memoryEnabled: false,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is Activity &&
|
||||
other.id == id &&
|
||||
other.assetId == assetId &&
|
||||
other.comment == comment &&
|
||||
other.createdAt == createdAt &&
|
||||
other.type == type &&
|
||||
other.user == user;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
assetId.hashCode ^
|
||||
comment.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
type.hashCode ^
|
||||
user.hashCode;
|
||||
}
|
||||
}
|
||||
130
mobile/lib/modules/activities/providers/activity.provider.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/activities/services/activity.service.dart';
|
||||
|
||||
class ActivityNotifier extends StateNotifier<AsyncValue<List<Activity>>> {
|
||||
final Ref _ref;
|
||||
final ActivityService _activityService;
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
|
||||
ActivityNotifier(
|
||||
this._ref,
|
||||
this._activityService,
|
||||
this.albumId,
|
||||
this.assetId,
|
||||
) : super(
|
||||
const AsyncData([]),
|
||||
) {
|
||||
fetchActivity();
|
||||
}
|
||||
|
||||
Future<void> fetchActivity() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => _activityService.getAllActivities(albumId, assetId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeActivity(String id) async {
|
||||
final activities = state.asData?.value ?? [];
|
||||
if (await _activityService.removeActivity(id)) {
|
||||
final removedActivity = activities.firstWhere((a) => a.id == id);
|
||||
activities.remove(removedActivity);
|
||||
state = AsyncData(activities);
|
||||
if (removedActivity.type == ActivityType.comment) {
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: assetId),
|
||||
).notifier,
|
||||
)
|
||||
.removeActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addComment(String comment) async {
|
||||
final activity = await _activityService.addActivity(
|
||||
albumId,
|
||||
ActivityType.comment,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
);
|
||||
|
||||
if (activity != null) {
|
||||
final activities = state.asData?.value ?? [];
|
||||
state = AsyncData([...activities, activity]);
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: assetId),
|
||||
).notifier,
|
||||
)
|
||||
.addActivity();
|
||||
if (assetId != null) {
|
||||
// Add a count to the current album's provider as well
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: null),
|
||||
).notifier,
|
||||
)
|
||||
.addActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addLike() async {
|
||||
final activity = await _activityService
|
||||
.addActivity(albumId, ActivityType.like, assetId: assetId);
|
||||
if (activity != null) {
|
||||
final activities = state.asData?.value ?? [];
|
||||
state = AsyncData([...activities, activity]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ActivityStatisticsNotifier extends StateNotifier<int> {
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
final ActivityService _activityService;
|
||||
ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId)
|
||||
: super(0) {
|
||||
fetchStatistics();
|
||||
}
|
||||
|
||||
Future<void> fetchStatistics() async {
|
||||
state = await _activityService.getStatistics(albumId, assetId: assetId);
|
||||
}
|
||||
|
||||
Future<void> addActivity() async {
|
||||
state = state + 1;
|
||||
}
|
||||
|
||||
Future<void> removeActivity() async {
|
||||
state = state - 1;
|
||||
}
|
||||
}
|
||||
|
||||
typedef ActivityParams = ({String albumId, String? assetId});
|
||||
|
||||
final activityStateProvider = StateNotifierProvider.autoDispose
|
||||
.family<ActivityNotifier, AsyncValue<List<Activity>>, ActivityParams>(
|
||||
(ref, args) {
|
||||
return ActivityNotifier(
|
||||
ref,
|
||||
ref.watch(activityServiceProvider),
|
||||
args.albumId,
|
||||
args.assetId,
|
||||
);
|
||||
});
|
||||
|
||||
final activityStatisticsStateProvider = StateNotifierProvider.autoDispose
|
||||
.family<ActivityStatisticsNotifier, int, ActivityParams>((ref, args) {
|
||||
return ActivityStatisticsNotifier(
|
||||
ref.watch(activityServiceProvider),
|
||||
args.albumId,
|
||||
args.assetId,
|
||||
);
|
||||
});
|
||||
85
mobile/lib/modules/activities/services/activity.service.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final activityServiceProvider =
|
||||
Provider((ref) => ActivityService(ref.watch(apiServiceProvider)));
|
||||
|
||||
class ActivityService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("ActivityService");
|
||||
|
||||
ActivityService(this._apiService);
|
||||
|
||||
Future<List<Activity>> getAllActivities(
|
||||
String albumId,
|
||||
String? assetId,
|
||||
) async {
|
||||
try {
|
||||
final list = await _apiService.activityApi
|
||||
.getActivities(albumId, assetId: assetId);
|
||||
return list != null ? list.map(Activity.fromDto).toList() : [];
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to fetch activities for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getStatistics(String albumId, {String? assetId}) async {
|
||||
try {
|
||||
final dto = await _apiService.activityApi
|
||||
.getActivityStatistics(albumId, assetId: assetId);
|
||||
return dto?.comments ?? 0;
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to fetch activity statistics for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<bool> removeActivity(String id) async {
|
||||
try {
|
||||
await _apiService.activityApi.deleteActivity(id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to remove activity id - $id -> $e",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<Activity?> addActivity(
|
||||
String albumId,
|
||||
ActivityType type, {
|
||||
String? assetId,
|
||||
String? comment,
|
||||
}) async {
|
||||
try {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to add activity for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
317
mobile/lib/modules/activities/views/activities_page.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class ActivitiesPage extends HookConsumerWidget {
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
final bool withAssetThumbs;
|
||||
final String appBarTitle;
|
||||
final bool isOwner;
|
||||
final bool isReadOnly;
|
||||
const ActivitiesPage(
|
||||
this.albumId, {
|
||||
this.appBarTitle = "",
|
||||
this.assetId,
|
||||
this.withAssetThumbs = true,
|
||||
this.isOwner = false,
|
||||
this.isReadOnly = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider =
|
||||
activityStateProvider((albumId: albumId, assetId: assetId));
|
||||
final activities = ref.watch(provider);
|
||||
final inputController = useTextEditingController();
|
||||
final inputFocusNode = useFocusNode();
|
||||
final listViewScrollController = useScrollController();
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
inputFocusNode.requestFocus();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
buildTitleWithTimestamp(Activity activity, {bool leftAlign = true}) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final textStyle = context.textTheme.bodyMedium
|
||||
?.copyWith(color: textColor.withOpacity(0.6));
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: leftAlign
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: leftAlign ? MainAxisSize.min : MainAxisSize.max,
|
||||
children: [
|
||||
Text(
|
||||
activity.user.name,
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (leftAlign)
|
||||
Text(
|
||||
" • ",
|
||||
style: textStyle,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activity.createdAt.copyWith().timeAgo(),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: leftAlign ? TextAlign.left : TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
buildAssetThumbnail(Activity activity) {
|
||||
return withAssetThumbs && activity.assetId != null
|
||||
? Container(
|
||||
width: 40,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
getThumbnailUrlForRemoteId(
|
||||
activity.assetId!,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKeyForRemoteId(
|
||||
activity.assetId!,
|
||||
),
|
||||
headers: {
|
||||
"Authorization":
|
||||
'Bearer ${Store.get(StoreKey.accessToken)}',
|
||||
},
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: const SizedBox.shrink(),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
buildTextField(String? likedId) {
|
||||
final liked = likedId != null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: TextField(
|
||||
controller: inputController,
|
||||
enabled: !isReadOnly,
|
||||
focusNode: inputFocusNode,
|
||||
textInputAction: TextInputAction.send,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
prefixIcon: currentUser != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: UserCircleAvatar(
|
||||
user: currentUser,
|
||||
size: 30,
|
||||
radius: 15,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
),
|
||||
onPressed: () async {
|
||||
liked
|
||||
? await ref
|
||||
.read(provider.notifier)
|
||||
.removeActivity(likedId)
|
||||
: await ref.read(provider.notifier).addLike();
|
||||
},
|
||||
),
|
||||
),
|
||||
suffixIconColor: liked ? Colors.red[700] : null,
|
||||
hintText: isReadOnly
|
||||
? 'shared_album_activities_input_disable'.tr()
|
||||
: 'shared_album_activities_input_hint'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
onEditingComplete: () async {
|
||||
await ref.read(provider.notifier).addComment(inputController.text);
|
||||
inputController.clear();
|
||||
inputFocusNode.unfocus();
|
||||
listViewScrollController.animateTo(
|
||||
listViewScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
},
|
||||
onTapOutside: (_) => inputFocusNode.unfocus(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getDismissibleWidget(
|
||||
Widget widget,
|
||||
Activity activity,
|
||||
bool canDelete,
|
||||
) {
|
||||
return Dismissible(
|
||||
key: Key(activity.id),
|
||||
dismissThresholds: const {
|
||||
DismissDirection.horizontal: 0.7,
|
||||
},
|
||||
direction: DismissDirection.horizontal,
|
||||
confirmDismiss: (direction) => canDelete
|
||||
? showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
onOk: () {},
|
||||
title: "shared_album_activity_remove_title",
|
||||
content: "shared_album_activity_remove_content",
|
||||
ok: "delete_dialog_ok",
|
||||
),
|
||||
)
|
||||
: Future.value(false),
|
||||
onDismissed: (direction) async =>
|
||||
await ref.read(provider.notifier).removeActivity(activity.id),
|
||||
background: Container(
|
||||
color: canDelete ? Colors.red[400] : Colors.grey[600],
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: canDelete
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: Icon(
|
||||
Icons.delete_sweep_rounded,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
secondaryBackground: Container(
|
||||
color: canDelete ? Colors.red[400] : Colors.grey[600],
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: canDelete
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: Icon(
|
||||
Icons.delete_sweep_rounded,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(appBarTitle)),
|
||||
body: activities.maybeWhen(
|
||||
orElse: () {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
},
|
||||
data: (data) {
|
||||
final liked = data.firstWhereOrNull(
|
||||
(a) =>
|
||||
a.type == ActivityType.like &&
|
||||
a.user.id == currentUser?.id &&
|
||||
a.assetId == assetId,
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: listViewScrollController,
|
||||
itemCount: data.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
// Vertical gap after the last element
|
||||
if (index == data.length) {
|
||||
return const SizedBox(
|
||||
height: 80,
|
||||
);
|
||||
}
|
||||
|
||||
final activity = data[index];
|
||||
final canDelete =
|
||||
activity.user.id == currentUser?.id || isOwner;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: activity.type == ActivityType.comment
|
||||
? getDismissibleWidget(
|
||||
ListTile(
|
||||
minVerticalPadding: 15,
|
||||
leading: UserCircleAvatar(user: activity.user),
|
||||
title: buildTitleWithTimestamp(
|
||||
activity,
|
||||
leftAlign: withAssetThumbs &&
|
||||
activity.assetId != null,
|
||||
),
|
||||
titleAlignment: ListTileTitleAlignment.top,
|
||||
trailing: buildAssetThumbnail(activity),
|
||||
subtitle: Text(activity.comment!),
|
||||
),
|
||||
activity,
|
||||
canDelete,
|
||||
)
|
||||
: getDismissibleWidget(
|
||||
ListTile(
|
||||
minVerticalPadding: 15,
|
||||
leading: Container(
|
||||
width: 44,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Colors.red[700],
|
||||
),
|
||||
),
|
||||
title: buildTitleWithTimestamp(activity),
|
||||
trailing: buildAssetThumbnail(activity),
|
||||
),
|
||||
activity,
|
||||
canDelete,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: context.scaffoldBackgroundColor,
|
||||
child: buildTextField(liked?.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
@@ -10,7 +11,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
SharedAlbumNotifier(this._albumService, Isar db, this._ref) : super([]) {
|
||||
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
|
||||
query.findAll().then((value) => state = value);
|
||||
_streamSub = query.watch().listen((data) => state = data);
|
||||
@@ -18,6 +19,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
|
||||
final AlbumService _albumService;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
final Ref _ref;
|
||||
|
||||
Future<Album?> createSharedAlbum(
|
||||
String albumName,
|
||||
@@ -66,6 +68,17 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool activityEnabled) async {
|
||||
final result =
|
||||
await _albumService.setActivityEnabled(album, activityEnabled);
|
||||
|
||||
if (result) {
|
||||
_ref.invalidate(albumDetailProvider(album.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
@@ -78,5 +91,6 @@ final sharedAlbumProvider =
|
||||
return SharedAlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -284,6 +284,23 @@ class AlbumService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool enabled) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
UpdateAlbumDto(isActivityEnabled: enabled),
|
||||
);
|
||||
if (result != null) {
|
||||
album.activityEnabled = enabled;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error setActivityEnabled ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
try {
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
@@ -95,20 +95,19 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
'common_add_to_album'.tr(),
|
||||
style: Theme.of(context).textTheme.displayMedium,
|
||||
style: context.textTheme.displayMedium,
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
label: Text(
|
||||
'common_create_new_album'.tr(),
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).primaryColor),
|
||||
style: TextStyle(color: context.primaryColor),
|
||||
),
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(
|
||||
context.autoPush(
|
||||
CreateAlbumRoute(
|
||||
isSharedAlbum: false,
|
||||
initialAssets: assets,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class AlbumActionOutlinedButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
@@ -14,8 +15,6 @@ class AlbumActionOutlinedButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: OutlinedButton.icon(
|
||||
@@ -26,7 +25,7 @@ class AlbumActionOutlinedButton extends StatelessWidget {
|
||||
),
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: isDarkTheme
|
||||
color: context.isDarkTheme
|
||||
? const Color.fromARGB(255, 63, 63, 63)
|
||||
: const Color.fromARGB(255, 206, 206, 206),
|
||||
),
|
||||
@@ -34,13 +33,13 @@ class AlbumActionOutlinedButton extends StatelessWidget {
|
||||
icon: Icon(
|
||||
iconData,
|
||||
size: 15,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
label: Text(
|
||||
labelText,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
@@ -22,7 +23,8 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
var isDarkTheme = context.isDarkTheme;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
var cardSize = constraints.maxWidth;
|
||||
@@ -32,7 +34,7 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
height: cardSize,
|
||||
width: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
|
||||
color: isDarkTheme ? Colors.grey[800] : Colors.grey[200],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
@@ -73,14 +75,14 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontFamily: 'WorkSans',
|
||||
fontSize: 12,
|
||||
color: isDarkMode ? Colors.white : Colors.black,
|
||||
color: isDarkTheme ? Colors.white : Colors.black,
|
||||
),
|
||||
),
|
||||
if (owner != null) const TextSpan(text: ' · '),
|
||||
if (owner != null)
|
||||
TextSpan(
|
||||
text: owner,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
style: context.textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -114,8 +116,8 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
album.name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkMode
|
||||
? Theme.of(context).primaryColor
|
||||
color: isDarkTheme
|
||||
? context.primaryColor
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
@@ -21,12 +21,11 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var cardSize = 68.0;
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
buildEmptyThumbnail() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
|
||||
color: context.isDarkTheme ? Colors.grey[800] : Colors.grey[200],
|
||||
),
|
||||
child: SizedBox(
|
||||
height: cardSize,
|
||||
@@ -61,7 +60,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap ??
|
||||
() {
|
||||
AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id));
|
||||
context.autoPush(AlbumViewerRoute(albumId: album.id));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
|
||||
@@ -1,6 +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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
|
||||
class AlbumTitleTextField extends ConsumerWidget {
|
||||
@@ -19,7 +20,7 @@ class AlbumTitleTextField extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
|
||||
return TextField(
|
||||
onChanged: (v) {
|
||||
@@ -55,7 +56,7 @@ class AlbumTitleTextField extends ConsumerWidget {
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.cancel_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
splashRadius: 10,
|
||||
)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
@@ -24,6 +27,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
required this.titleFocusNode,
|
||||
this.onAddPhotos,
|
||||
this.onAddUsers,
|
||||
required this.onActivities,
|
||||
}) : super(key: key);
|
||||
|
||||
final Album album;
|
||||
@@ -33,11 +37,19 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
final FocusNode titleFocusNode;
|
||||
final Function(Album album)? onAddPhotos;
|
||||
final Function(Album album)? onAddUsers;
|
||||
final Function(Album album) onActivities;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
|
||||
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||
final comments = album.shared
|
||||
? ref.watch(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: album.remoteId!, assetId: null),
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
deleteAlbum() async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
@@ -46,12 +58,12 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
if (album.shared) {
|
||||
success =
|
||||
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
context
|
||||
.autoNavigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
|
||||
context
|
||||
.autoNavigate(const TabControllerRoute(children: [LibraryRoute()]));
|
||||
}
|
||||
if (!success) {
|
||||
ImmichToast.show(
|
||||
@@ -81,7 +93,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -95,9 +107,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
'Confirm',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.red
|
||||
: Colors.red[300],
|
||||
color: !context.isDarkTheme ? Colors.red : Colors.red[300],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -118,8 +128,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(album);
|
||||
|
||||
if (isSuccess) {
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
context
|
||||
.autoNavigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
ImmichToast.show(
|
||||
@@ -160,40 +170,81 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
|
||||
buildBottomSheetActionButton() {
|
||||
void handleShareAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
Set<Asset> selection,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref.watch(shareServiceProvider).shareAssets(selection.toList()).then(
|
||||
(bool status) {
|
||||
if (!status) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'image_viewer_page_state_provider_share_error'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
context.pop();
|
||||
},
|
||||
);
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
void onShareAssetsTo() async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
handleShareAssets(ref, context, selected);
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
|
||||
buildBottomSheetActions() {
|
||||
if (selected.isNotEmpty) {
|
||||
if (album.ownerId == userId) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.delete_sweep_rounded),
|
||||
return [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.ios_share_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_remove',
|
||||
'album_viewer_appbar_share_to',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onRemoveFromAlbumPressed(),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
onTap: () => onShareAssetsTo(),
|
||||
),
|
||||
album.ownerId == userId
|
||||
? ListTile(
|
||||
leading: const Icon(Icons.delete_sweep_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_remove',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onRemoveFromAlbumPressed(),
|
||||
)
|
||||
: const SizedBox(),
|
||||
];
|
||||
} else {
|
||||
if (album.ownerId == userId) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.delete_forever_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_delete',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onDeleteAlbumPressed(),
|
||||
);
|
||||
} else {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person_remove_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_leave',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onLeaveAlbumPressed(),
|
||||
);
|
||||
}
|
||||
return [
|
||||
album.ownerId == userId
|
||||
? ListTile(
|
||||
leading: const Icon(Icons.delete_forever_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_delete',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onDeleteAlbumPressed(),
|
||||
)
|
||||
: ListTile(
|
||||
leading: const Icon(Icons.person_remove_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_leave',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onLeaveAlbumPressed(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +264,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share_rounded),
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
.push(SharedLinkEditRoute(albumId: album.remoteId));
|
||||
context.autoPush(SharedLinkEditRoute(albumId: album.remoteId));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
title: const Text(
|
||||
@@ -224,8 +274,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_rounded),
|
||||
onTap: () =>
|
||||
AutoRouter.of(context).navigate(AlbumOptionsRoute(album: album)),
|
||||
onTap: () => context.autoNavigate(AlbumOptionsRoute(album: album)),
|
||||
title: const Text(
|
||||
"translated_text_options",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
@@ -247,7 +296,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
),
|
||||
];
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
@@ -257,7 +306,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildBottomSheetActionButton(),
|
||||
...buildBottomSheetActions(),
|
||||
if (selected.isEmpty && onAddPhotos != null) ...commonActions,
|
||||
if (selected.isEmpty &&
|
||||
onAddPhotos != null &&
|
||||
@@ -271,6 +320,33 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildActivitiesButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onActivities(album);
|
||||
},
|
||||
icon: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.mode_comment_outlined,
|
||||
),
|
||||
if (comments != 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
child: Text(
|
||||
comments.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildLeadingButton() {
|
||||
if (selected.isNotEmpty) {
|
||||
return IconButton(
|
||||
@@ -301,7 +377,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
);
|
||||
} else {
|
||||
return IconButton(
|
||||
onPressed: () async => await AutoRouter.of(context).pop(),
|
||||
onPressed: () async => await context.autoPop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
splashRadius: 25,
|
||||
);
|
||||
@@ -314,6 +390,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
title: selected.isNotEmpty ? Text('${selected.length}') : null,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
if (album.shared && (album.activityEnabled || comments != 0))
|
||||
buildActivitiesButton(),
|
||||
if (album.isRemote)
|
||||
IconButton(
|
||||
splashRadius: 25,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
@@ -17,7 +18,6 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final titleTextEditController = useTextEditingController(text: album.name);
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
void onFocusModeChange() {
|
||||
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
|
||||
@@ -65,7 +65,7 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.cancel_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
splashRadius: 10,
|
||||
)
|
||||
@@ -79,14 +79,14 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
focusColor: Colors.grey[300],
|
||||
fillColor: isDarkTheme
|
||||
fillColor: context.isDarkTheme
|
||||
? const Color.fromARGB(255, 32, 33, 35)
|
||||
: Colors.grey[200],
|
||||
filled: titleFocusNode.hasFocus,
|
||||
hintText: 'share_add_title'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 28,
|
||||
color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
|
||||
color: context.isDarkTheme ? Colors.grey[300] : Colors.grey[700],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -23,6 +23,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
final sharedUsers = useState(album.sharedUsers.toList());
|
||||
final owner = album.owner.value;
|
||||
final userId = ref.watch(authenticationProvider).userId;
|
||||
final activityEnabled = useState(album.activityEnabled);
|
||||
final isOwner = owner?.id == userId;
|
||||
|
||||
void showErrorMessage() {
|
||||
@@ -43,8 +44,9 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album);
|
||||
|
||||
if (isSuccess) {
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
context.autoNavigate(
|
||||
const TabControllerRoute(children: [SharingRoute()]),
|
||||
);
|
||||
} else {
|
||||
showErrorMessage();
|
||||
}
|
||||
@@ -96,7 +98,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
@@ -122,7 +124,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
)
|
||||
: const SizedBox(),
|
||||
title: Text(
|
||||
album.owner.value?.firstName ?? "",
|
||||
album.owner.value?.name ?? "",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -153,7 +155,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
radius: 22,
|
||||
),
|
||||
title: Text(
|
||||
user.firstName,
|
||||
user.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -176,7 +178,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
buildSectionTitle(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(text, style: Theme.of(context).textTheme.bodySmall),
|
||||
child: Text(text, style: context.textTheme.bodySmall),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,7 +187,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop(null);
|
||||
context.autoPop(null);
|
||||
},
|
||||
),
|
||||
centerTitle: true,
|
||||
@@ -195,6 +197,29 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isOwner && album.shared)
|
||||
SwitchListTile.adaptive(
|
||||
value: activityEnabled.value,
|
||||
onChanged: (bool value) async {
|
||||
activityEnabled.value = value;
|
||||
if (await ref
|
||||
.read(sharedAlbumProvider.notifier)
|
||||
.setActivityEnabled(album, value)) {
|
||||
album.activityEnabled = value;
|
||||
}
|
||||
},
|
||||
activeColor: activityEnabled.value
|
||||
? context.primaryColor
|
||||
: context.themeData.disabledColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"shared_album_activity_setting_title",
|
||||
style: context.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
subtitle:
|
||||
const Text("shared_album_activity_setting_subtitle").tr(),
|
||||
),
|
||||
buildSectionTitle("PEOPLE"),
|
||||
buildOwnerInfo(),
|
||||
buildSharedUsersList(),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
@@ -67,7 +67,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
/// If they exist, add to selected asset state to show they are already selected.
|
||||
void onAddPhotosPressed(Album albumInfo) async {
|
||||
AssetSelectionPageResult? returnPayload =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||
await context.autoPush<AssetSelectionPageResult?>(
|
||||
AssetSelectionRoute(
|
||||
existingAssets: albumInfo.assets,
|
||||
canDeselect: false,
|
||||
@@ -97,8 +97,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void onAddUsersPressed(Album album) async {
|
||||
List<String>? sharedUserIds =
|
||||
await AutoRouter.of(context).push<List<String>?>(
|
||||
List<String>? sharedUserIds = await context.autoPush<List<String>?>(
|
||||
SelectAdditionalUserForSharingRoute(album: album),
|
||||
);
|
||||
|
||||
@@ -171,11 +170,19 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final String startDateText = (startDate.year == endDate.year
|
||||
? DateFormat.MMMd()
|
||||
: DateFormat.yMMMd())
|
||||
.format(startDate);
|
||||
final String endDateText = DateFormat.yMMMd().format(endDate);
|
||||
final String dateRangeText;
|
||||
if (startDate.day == endDate.day &&
|
||||
startDate.month == endDate.month &&
|
||||
startDate.year == endDate.year) {
|
||||
dateRangeText = DateFormat.yMMMd().format(startDate);
|
||||
} else {
|
||||
final String startDateText = (startDate.year == endDate.year
|
||||
? DateFormat.MMMd()
|
||||
: DateFormat.yMMMd())
|
||||
.format(startDate);
|
||||
final String endDateText = DateFormat.yMMMd().format(endDate);
|
||||
dateRangeText = "$startDateText - $endDateText";
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -183,7 +190,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
bottom: album.shared ? 0.0 : 8.0,
|
||||
),
|
||||
child: Text(
|
||||
"$startDateText - $endDateText",
|
||||
dateRangeText,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -195,7 +202,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
Widget buildSharedUserIconsRow(Album album) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
await AutoRouter.of(context).push(AlbumOptionsRoute(album: album));
|
||||
await context.autoPush(AlbumOptionsRoute(album: album));
|
||||
ref.invalidate(albumDetailProvider(album.id));
|
||||
},
|
||||
child: SizedBox(
|
||||
@@ -232,6 +239,19 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
onActivitiesPressed(Album album) {
|
||||
if (album.remoteId != null) {
|
||||
context.autoPush(
|
||||
ActivitiesRoute(
|
||||
albumId: album.remoteId!,
|
||||
appBarTitle: album.name,
|
||||
isOwner: userId == album.ownerId,
|
||||
isReadOnly: !album.activityEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: album.when(
|
||||
data: (data) => AlbumViewerAppbar(
|
||||
@@ -242,6 +262,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
selectionDisabled: disableSelection,
|
||||
onAddPhotos: onAddPhotosPressed,
|
||||
onAddUsers: onAddUsersPressed,
|
||||
onActivities: onActivitiesPressed,
|
||||
),
|
||||
error: (error, stackTrace) => AppBar(title: const Text("Error")),
|
||||
loading: () => AppBar(),
|
||||
@@ -265,6 +286,9 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
if (data.isRemote) buildControlButton(data),
|
||||
],
|
||||
),
|
||||
isOwner: userId == data.ownerId,
|
||||
sharedAlbumId:
|
||||
data.shared && data.activityEnabled ? data.remoteId : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ 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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
@@ -78,7 +79,7 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
canDeselect ? "share_done" : "share_add",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
@@ -34,11 +34,11 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
final selectedAssets = useState<Set<Asset>>(
|
||||
initialAssets != null ? Set.from(initialAssets!) : const {},
|
||||
);
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
showSelectUserPage() async {
|
||||
final bool? ok = await AutoRouter.of(context)
|
||||
.push<bool?>(SelectUserForSharingRoute(assets: selectedAssets.value));
|
||||
final bool? ok = await context.autoPush<bool?>(
|
||||
SelectUserForSharingRoute(assets: selectedAssets.value),
|
||||
);
|
||||
if (ok == true) {
|
||||
selectedAssets.value = {};
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
|
||||
onSelectPhotosButtonPressed() async {
|
||||
AssetSelectionPageResult? selectedAsset =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||
await context.autoPush<AssetSelectionPageResult?>(
|
||||
AssetSelectionRoute(
|
||||
existingAssets: selectedAssets.value,
|
||||
canDeselect: true,
|
||||
@@ -94,10 +94,10 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 200, left: 18),
|
||||
child: Text(
|
||||
'create_shared_album_page_share_add_assets',
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
style: context.textTheme.displayMedium?.copyWith(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
);
|
||||
@@ -117,7 +117,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 22, horizontal: 16),
|
||||
side: BorderSide(
|
||||
color: isDarkTheme
|
||||
color: context.isDarkTheme
|
||||
? const Color.fromARGB(255, 63, 63, 63)
|
||||
: const Color.fromARGB(255, 206, 206, 206),
|
||||
),
|
||||
@@ -128,16 +128,16 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
onPressed: onSelectPhotosButtonPressed,
|
||||
icon: Icon(
|
||||
Icons.add_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
'create_shared_album_page_share_select_photos',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
@@ -206,7 +206,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
selectedAssets.value = {};
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
|
||||
AutoRouter.of(context).replace(AlbumViewerRoute(albumId: newAlbum.id));
|
||||
context.autoReplace(AlbumViewerRoute(albumId: newAlbum.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,19 +214,19 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
selectedAssets.value = {};
|
||||
AutoRouter.of(context).pop();
|
||||
context.autoPop();
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
title: Text(
|
||||
'share_create_album',
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
style: context.textTheme.displayMedium?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
actions: [
|
||||
if (isSharedAlbum)
|
||||
@@ -239,8 +239,8 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: albumTitleController.text.isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).primaryColor,
|
||||
? context.themeData.disabledColor
|
||||
: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -254,7 +254,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
'create_shared_album_page_create'.tr(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -265,7 +265,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
elevation: 5,
|
||||
automaticallyImplyLeading: false,
|
||||
pinned: true,
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
|
||||
|
||||
class LibraryPage extends HookConsumerWidget {
|
||||
const LibraryPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
final albums = ref.watch(albumProvider);
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
var isDarkTheme = context.isDarkTheme;
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
useEffect(
|
||||
@@ -28,21 +32,6 @@ class LibraryPage extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
AppBar buildAppBar() {
|
||||
return AppBar(
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text(
|
||||
'IMMICH',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final selectedAlbumSortOrder =
|
||||
useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
|
||||
|
||||
@@ -107,15 +96,14 @@ class LibraryPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: selected
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.transparent,
|
||||
color:
|
||||
selected ? context.primaryColor : Colors.transparent,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
option,
|
||||
style: TextStyle(
|
||||
color: selected ? Theme.of(context).primaryColor : null,
|
||||
color: selected ? context.primaryColor : null,
|
||||
fontSize: 12.0,
|
||||
),
|
||||
),
|
||||
@@ -133,13 +121,13 @@ class LibraryPage extends HookConsumerWidget {
|
||||
Icon(
|
||||
Icons.swap_vert_rounded,
|
||||
size: 18,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
Text(
|
||||
options[selectedAlbumSortOrder.value],
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
fontSize: 12.0,
|
||||
),
|
||||
),
|
||||
@@ -151,7 +139,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
Widget buildCreateAlbumButton() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false));
|
||||
context.autoPush(CreateAlbumRoute(isSharedAlbum: false));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
@@ -163,18 +151,18 @@ class LibraryPage extends HookConsumerWidget {
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDarkMode
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 53, 53, 53)
|
||||
: const Color.fromARGB(255, 203, 203, 203),
|
||||
),
|
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[50],
|
||||
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.add_rounded,
|
||||
size: 28,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -212,21 +200,21 @@ class LibraryPage extends HookConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13.0,
|
||||
color: isDarkMode ? Colors.white : Colors.grey[800],
|
||||
color: isDarkTheme ? Colors.white : Colors.grey[800],
|
||||
),
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.grey[50],
|
||||
backgroundColor: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
side: BorderSide(
|
||||
color: isDarkMode ? Colors.grey[800]! : Colors.grey[300]!,
|
||||
color: isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!,
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
icon: Icon(
|
||||
icon,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -236,8 +224,23 @@ class LibraryPage extends HookConsumerWidget {
|
||||
|
||||
final local = albums.where((a) => a.isLocal).toList();
|
||||
|
||||
Widget? shareTrashButton() {
|
||||
return trashEnabled
|
||||
? InkWell(
|
||||
onTap: () => context.autoPush(const TrashRoute()),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: const Icon(
|
||||
Icons.delete_rounded,
|
||||
size: 25,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: buildAppBar(),
|
||||
appBar: ImmichAppBar(
|
||||
action: shareTrashButton(),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
@@ -253,12 +256,12 @@ class LibraryPage extends HookConsumerWidget {
|
||||
children: [
|
||||
buildLibraryNavButton(
|
||||
"library_page_favorites".tr(), Icons.favorite_border, () {
|
||||
AutoRouter.of(context).navigate(const FavoritesRoute());
|
||||
context.autoNavigate(const FavoritesRoute());
|
||||
}),
|
||||
const SizedBox(width: 12.0),
|
||||
buildLibraryNavButton(
|
||||
"library_page_archive".tr(), Icons.archive_outlined, () {
|
||||
AutoRouter.of(context).navigate(const ArchiveRoute());
|
||||
context.autoNavigate(const ArchiveRoute());
|
||||
}),
|
||||
],
|
||||
),
|
||||
@@ -302,7 +305,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
|
||||
return AlbumThumbnailCard(
|
||||
album: sorted[index - 1],
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
onTap: () => context.autoPush(
|
||||
AlbumViewerRoute(
|
||||
albumId: sorted[index - 1].id,
|
||||
),
|
||||
@@ -344,7 +347,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
childCount: local.length,
|
||||
(context, index) => AlbumThumbnailCard(
|
||||
album: local[index],
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
onTap: () => context.autoPush(
|
||||
AlbumViewerRoute(
|
||||
albumId: local[index].id,
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
@@ -22,14 +22,13 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
final sharedUsersList = useState<Set<User>>({});
|
||||
|
||||
addNewUsersHandler() {
|
||||
AutoRouter.of(context)
|
||||
.pop(sharedUsersList.value.map((e) => e.id).toList());
|
||||
context.autoPop(sharedUsersList.value.map((e) => e.id).toList());
|
||||
}
|
||||
|
||||
buildTileIcon(User user) {
|
||||
if (sharedUsersList.value.contains(user)) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
backgroundColor: context.primaryColor,
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
size: 25,
|
||||
@@ -50,7 +49,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Chip(
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.15),
|
||||
backgroundColor: context.primaryColor.withOpacity(0.15),
|
||||
label: Text(
|
||||
user.email,
|
||||
style: const TextStyle(
|
||||
@@ -124,7 +123,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop(null);
|
||||
context.autoPop(null);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
|
||||
@@ -35,9 +35,9 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
// ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
AutoRouter.of(context).pop(true);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
context.autoPop(true);
|
||||
context
|
||||
.autoNavigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
}
|
||||
|
||||
ScaffoldMessenger(
|
||||
@@ -50,7 +50,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
buildTileIcon(User user) {
|
||||
if (sharedUsersList.value.contains(user)) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
backgroundColor: context.primaryColor,
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
size: 25,
|
||||
@@ -71,7 +71,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Chip(
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.15),
|
||||
backgroundColor: context.primaryColor.withOpacity(0.15),
|
||||
label: Text(
|
||||
user.email,
|
||||
style: const TextStyle(
|
||||
@@ -139,20 +139,20 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'share_invite',
|
||||
style: TextStyle(color: Theme.of(context).primaryColor),
|
||||
style: TextStyle(color: context.primaryColor),
|
||||
).tr(),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () async {
|
||||
AutoRouter.of(context).pop();
|
||||
context.autoPop();
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).primaryColor,
|
||||
foregroundColor: context.primaryColor,
|
||||
),
|
||||
onPressed: sharedUsersList.value.isEmpty ? null : createSharedAlbum,
|
||||
child: const Text(
|
||||
@@ -160,7 +160,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
// color: Theme.of(context).primaryColor,
|
||||
// color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/partner/ui/partner_list.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
|
||||
class SharingPage extends HookConsumerWidget {
|
||||
@@ -20,7 +21,6 @@ class SharingPage extends HookConsumerWidget {
|
||||
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
final partner = ref.watch(partnerSharedWithProvider);
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -46,8 +46,9 @@ class SharingPage extends HookConsumerWidget {
|
||||
album: sharedAlbums[index],
|
||||
showOwner: true,
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
.push(AlbumViewerRoute(albumId: sharedAlbums[index].id));
|
||||
context.autoPush(
|
||||
AlbumViewerRoute(albumId: sharedAlbums[index].id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -78,12 +79,11 @@ class SharingPage extends HookConsumerWidget {
|
||||
album.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkMode
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.black,
|
||||
),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
context.isDarkTheme ? context.primaryColor : Colors.black,
|
||||
),
|
||||
),
|
||||
subtitle: isOwner
|
||||
? Text(
|
||||
@@ -102,8 +102,9 @@ class SharingPage extends HookConsumerWidget {
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
.push(AlbumViewerRoute(albumId: sharedAlbums[index].id));
|
||||
context.autoPush(
|
||||
AlbumViewerRoute(albumId: sharedAlbums[index].id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -126,8 +127,7 @@ class SharingPage extends HookConsumerWidget {
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context)
|
||||
.push(CreateAlbumRoute(isSharedAlbum: true));
|
||||
context.autoPush(CreateAlbumRoute(isSharedAlbum: true));
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.photo_album_outlined,
|
||||
@@ -146,8 +146,7 @@ class SharingPage extends HookConsumerWidget {
|
||||
const SizedBox(width: 12.0),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () =>
|
||||
AutoRouter.of(context).push(const SharedLinkRoute()),
|
||||
onPressed: () => context.autoPush(const SharedLinkRoute()),
|
||||
icon: const Icon(
|
||||
Icons.link,
|
||||
size: 20,
|
||||
@@ -167,32 +166,6 @@ class SharingPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
AppBar buildAppBar() {
|
||||
return AppBar(
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text(
|
||||
'IMMICH',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
splashRadius: 25,
|
||||
iconSize: 20,
|
||||
icon: const Icon(
|
||||
Icons.swap_horizontal_circle_outlined,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => AutoRouter.of(context).push(const PartnerRoute()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
buildEmptyListIndication() {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -216,21 +189,21 @@ class SharingPage extends HookConsumerWidget {
|
||||
child: Icon(
|
||||
Icons.insert_photo_rounded,
|
||||
size: 50,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'sharing_page_empty_list',
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
style: context.textTheme.displaySmall,
|
||||
).tr(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'sharing_page_description',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
style: context.textTheme.bodyMedium,
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
@@ -241,8 +214,21 @@ class SharingPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget sharePartnerButton() {
|
||||
return InkWell(
|
||||
onTap: () => context.autoPush(const PartnerRoute()),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: const Icon(
|
||||
Icons.swap_horizontal_circle_rounded,
|
||||
size: 25,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: buildAppBar(),
|
||||
appBar: ImmichAppBar(
|
||||
action: sharePartnerButton(),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: buildTopBottons()),
|
||||
|
||||