Compare commits

..

3 Commits

Author SHA1 Message Date
shenlong-tanwen
023d2195f9 feat: inline storage columns in remote and local tables 2025-07-11 01:37:02 +05:30
mertalev
c482bdfae7 thumbhash render box 2025-07-10 16:41:53 +03:00
mertalev
0dadfc52dd thumbhash improvements 2025-07-08 16:20:11 +03:00
657 changed files with 10658 additions and 39714 deletions

View File

@@ -11,8 +11,8 @@ services:
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION}/photos/upload:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro
database:

View File

@@ -74,7 +74,7 @@ install_dependencies() {
(
cd "${IMMICH_WORKSPACE}" || exit 1
export CI=1 FROZEN=1 OFFLINE=1
run_cmd make setup-web-dev setup-server-dev
run_cmd make setup-dev
)
log ""
}

View File

@@ -13,8 +13,8 @@ services:
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/usr/src/app/upload
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/usr/src/app/upload/upload
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro
immich-web:

View File

@@ -3,6 +3,9 @@
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Setting up Immich dev container..."
fix_permissions
log "Installing npm dependencies (node_modules)..."
install_dependencies

View File

@@ -1,41 +1,41 @@
.vscode/
.github/
.git/
.env*
*.log
*.tmp
*.temp
**/Dockerfile
**/node_modules/
**/.pnpm-store/
**/dist/
**/coverage/
**/build/
design/
docker/
Dockerfile
!docker/scripts
docs/
!docs/package.json
!docs/package-lock.json
e2e/
!e2e/package.json
!e2e/package-lock.json
fastlane/
machine-learning/
misc/
mobile/
open-api/typescript-sdk/build/
!open-api/typescript-sdk/package.json
!open-api/typescript-sdk/package-lock.json
cli/coverage/
cli/dist/
cli/node_modules/
cli/Dockerfile
open-api/typescript-sdk/build/
open-api/typescript-sdk/node_modules/
server/coverage/
server/node_modules/
server/upload/
server/src/queries
server/dist/
server/www/
server/Dockerfile
web/node_modules/
web/coverage/
web/.svelte-kit
web/build/
web/.env
web/Dockerfile

View File

@@ -122,17 +122,17 @@ jobs:
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
if [[ $IS_MAIN == 'true' ]]; then
flutter build apk --release --flavor production
flutter build apk --release --flavor production --split-per-abi --target-platform android-arm,android-arm64,android-x64
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
flutter build apk --debug --flavor production --split-per-abi --target-platform android-arm64
flutter build apk --debug --split-per-abi --target-platform android-arm64
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/**/*.apk
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache
id: cache-gradle-save

View File

@@ -131,7 +131,7 @@ jobs:
tag-suffix: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@094bfb927b8cd75b343abaac27b3241be0fccfe9 # multi-runner-build-workflow-0.1.0
permissions:
contents: read
actions: read
@@ -154,7 +154,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@094bfb927b8cd75b343abaac27b3241be0fccfe9 # multi-runner-build-workflow-0.1.0
permissions:
contents: read
actions: read

View File

@@ -1,13 +0,0 @@
name: Org Checks
on:
pull_request_review:
pull_request:
jobs:
check-approvals:
name: Check for Team/Admin Review
uses: immich-app/devtools/.github/workflows/required-approval.yml@main
permissions:
pull-requests: read
contents: read

View File

@@ -61,7 +61,8 @@ jobs:
run: dart pub get
- name: Install DCM
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
# TODO: Move to upstream after https://github.com/CQLabs/setup-dcm/pull/235 merges
uses: bo0tzz/setup-dcm@b4952ab813659c03513b57bd78bfe3f634171f8a
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
version: auto

1
.gitignore vendored
View File

@@ -24,4 +24,3 @@ mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml
vite.config.js.timestamp-*
.pnpm-store

19
.vscode/launch.json vendored
View File

@@ -18,25 +18,6 @@
"name": "Immich Workers",
"remoteRoot": "/usr/src/app",
"localRoot": "${workspaceFolder}/server"
},
{
"name": "Flavor - Production",
"request": "launch",
"type": "dart",
"codeLens": {
"for": [
"run-test",
"run-test-file",
"run-file",
"debug-test",
"debug-test-file",
"debug-file",
],
"title": "${debugType}",
},
"args": [
"--flavor", "production"
],
}
]
}

View File

@@ -89,7 +89,7 @@ test-medium-dev:
docker exec -it immich_server /bin/sh -c "npm run test:medium"
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
install-all: $(foreach M,$(MODULES),install-$M) ;
install-all: $(foreach M,$(MODULES),install-$M) ;
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
@@ -106,5 +106,4 @@ clean:
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
setup-server-dev: install-server
setup-web-dev: install-sdk build-sdk install-web
setup-dev: install-server install-sdk build-sdk install-web

162
cli/package-lock.json generated
View File

@@ -42,7 +42,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
@@ -1365,17 +1365,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz",
"integrity": "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
"integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.36.0",
"@typescript-eslint/type-utils": "8.36.0",
"@typescript-eslint/utils": "8.36.0",
"@typescript-eslint/visitor-keys": "8.36.0",
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/type-utils": "8.35.1",
"@typescript-eslint/utils": "8.35.1",
"@typescript-eslint/visitor-keys": "8.35.1",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -1389,7 +1389,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.36.0",
"@typescript-eslint/parser": "^8.35.1",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
@@ -1405,16 +1405,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.36.0.tgz",
"integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz",
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.36.0",
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/typescript-estree": "8.36.0",
"@typescript-eslint/visitor-keys": "8.36.0",
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.35.1",
"@typescript-eslint/typescript-estree": "8.35.1",
"@typescript-eslint/visitor-keys": "8.35.1",
"debug": "^4.3.4"
},
"engines": {
@@ -1430,14 +1430,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz",
"integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz",
"integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.36.0",
"@typescript-eslint/types": "^8.36.0",
"@typescript-eslint/tsconfig-utils": "^8.35.1",
"@typescript-eslint/types": "^8.35.1",
"debug": "^4.3.4"
},
"engines": {
@@ -1452,14 +1452,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz",
"integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz",
"integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/visitor-keys": "8.36.0"
"@typescript-eslint/types": "8.35.1",
"@typescript-eslint/visitor-keys": "8.35.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1470,9 +1470,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz",
"integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz",
"integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1487,14 +1487,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.36.0.tgz",
"integrity": "sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz",
"integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.36.0",
"@typescript-eslint/utils": "8.36.0",
"@typescript-eslint/typescript-estree": "8.35.1",
"@typescript-eslint/utils": "8.35.1",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -1511,9 +1511,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz",
"integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz",
"integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1525,16 +1525,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz",
"integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz",
"integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.36.0",
"@typescript-eslint/tsconfig-utils": "8.36.0",
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/visitor-keys": "8.36.0",
"@typescript-eslint/project-service": "8.35.1",
"@typescript-eslint/tsconfig-utils": "8.35.1",
"@typescript-eslint/types": "8.35.1",
"@typescript-eslint/visitor-keys": "8.35.1",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -1580,16 +1580,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz",
"integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz",
"integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.36.0",
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/typescript-estree": "8.36.0"
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.35.1",
"@typescript-eslint/typescript-estree": "8.35.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1604,13 +1604,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz",
"integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz",
"integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/types": "8.35.1",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -3466,9 +3466,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"dev": true,
"funding": [
{
@@ -3486,7 +3486,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -4125,15 +4125,15 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz",
"integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz",
"integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.36.0",
"@typescript-eslint/parser": "8.36.0",
"@typescript-eslint/utils": "8.36.0"
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@typescript-eslint/utils": "8.35.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4196,24 +4196,24 @@
}
},
"node_modules/vite": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz",
"integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==",
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -4222,14 +4222,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -4314,9 +4314,9 @@
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
"dev": true,
"license": "MIT",
"peerDependencies": {

View File

@@ -36,7 +36,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",

View File

@@ -16,25 +16,22 @@ name: immich-dev
services:
immich-server:
container_name: immich_server
command: ['immich-dev']
command: ['/usr/src/app/bin/immich-dev']
image: immich-server-dev:latest
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
build:
args:
- SERVER_USER=${SERVER_USER:-0}
- SERVER_GROUP=${SERVER_GROUP:-0}
context: ../
dockerfile: server/Dockerfile
target: dev
restart: unless-stopped
volumes:
- ../server:/usr/src/app/server
- ../open-api:/usr/src/app/open-api
- ../server:/usr/src/app
- ../open-api:/usr/src/open-api
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
- /usr/src/app/server/node_modules
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@@ -72,23 +69,19 @@ services:
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
# user: 0:0
build:
args:
- WEB_USER=${WEB_USER:-1000}
- WEB_GROUP=${WEB_GROUP:-1000}
context: ../
dockerfile: web/Dockerfile
command: ['immich-web']
context: ../web
command: ['/usr/src/app/bin/immich-web']
env_file:
- .env
ports:
- 3000:3000
- 24678:24678
volumes:
- ../web:/usr/src/app/web
- ../i18n:/usr/src/app/i18n
- ../open-api/:/usr/src/app/open-api/
- ../web:/usr/src/app
- ../i18n:/usr/src/i18n
- ../open-api/:/usr/src/open-api/
# - ../../ui:/usr/ui
- /usr/src/app/web/node_modules
- /usr/src/app/node_modules
ulimits:
nofile:
soft: 1048576
@@ -123,7 +116,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996
image: prom/prometheus@sha256:7a34573f0b9c952286b33d537f233cd5b708e12263733aa646e50c33f598f16c
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177
healthcheck:
test: redis-cli ping || exit 1
restart: always

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -46,12 +46,6 @@ services:
When a new asset is uploaded it kicks off a series of jobs, which include metadata extraction, thumbnail generation, machine learning tasks, and storage template migration, if enabled. To view the status of a job navigate to the Administration -> Jobs page.
Additionally, some jobs run on a schedule, which is every night at midnight. This schedule, with the exception of [External Libraries](/docs/features/libraries) scanning, cannot be changed.
<img src={require('./img/admin-jobs.webp').default} width="60%" title="Admin jobs" />
Additionally, some jobs (such as memories generation) run on a schedule, which is every night at midnight by default. To change when they run or enable/disable a job navigate to System Settings -> [Nightly Tasks Settings](https://my.immich.app/admin/system-settings?isOpen=nightly-tasks).
<img src={require('./img/admin-nightly-tasks.webp').default} width="60%" title="Admin nightly tasks" />
:::note
Some jobs ([External Libraries](/docs/features/libraries) scanning, Database Dump) are configured in their own sections in System Settings.
:::

View File

@@ -2,17 +2,16 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| Command | Description |
| ------------------------ | ------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
## How to run a command
@@ -89,24 +88,3 @@ Print Immich Version
immich-admin version
v1.129.0
```
Change media location
```
immich-admin change-media-location
? Enter the previous value of IMMICH_MEDIA_LOCATION: /usr/src/app/upload
? Enter the new value of IMMICH_MEDIA_LOCATION: /data
Previous value: /usr/src/app/upload
Current value: /data
Changing database paths from "/usr/src/app/upload/*" to "/data/*"
? Do you want to proceed? [Y/n] y
Database file paths updated successfully! 🎉
You may now set IMMICH_MEDIA_LOCATION=/data and restart!
(please remember to update applicable volume mounts e.g. ${UPLOAD_LOCATION}:/data)
```

View File

@@ -7,7 +7,7 @@ sidebar_position: 3
Dev Containers provide a consistent, reproducible development environment using Docker containers. With a single click, you can get started with an Immich development environment on Mac, Linux, Windows, or in the cloud using GitHub Codespaces.
Get started fast!
[![Open in VSCode Containers](https://img.shields.io/static/v1?label=VSCode%20DevContainer&message=Immich&color=blue)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/immich-app/immich/)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/immich-app/immich/)
@@ -71,7 +71,7 @@ cd immich
The immich dev containers read environment variables from your shell environment, not from `.env` files. This allows them to work in cloud environments without pre-configuration.
:::important Configuration
:::important Required Configuration
When running locally, and if you want to create (or use an existing) DB and/or photo storage folder, you must set the `UPLOAD_LOCATION` variable in your shell environment before launching the Dev Container. This determines where uploaded files are stored and also where the DB stores it data.
```bash
@@ -88,10 +88,6 @@ source ~/.bashrc
### Step 3: Launch the Dev Container
:::tip
Immich development makes extensive use of specialized [base images](https://github.com/immich-app/base-images) for its docker-compose based development. For this reason, you won't be able to use VSCode's **_Clone Repository in a Container Volume_** command.
:::
#### Using VS Code UI:
1. Open the cloned repository in VS Code

View File

@@ -2,7 +2,7 @@
Folder view provides an additional view besides the timeline that is similar to a file explorer. It allows you to navigate through the folders and files in the library. This feature is handy for a highly curated and customized external library or a nicely configured storage template.
You can enable this feature under [`Account Settings > Features > Folders`](https://my.immich.app/user-settings?isOpen=feature+folders)
You can enable this feature under [`Account Settings > Features > Folder View`](https://my.immich.app/user-settings?isOpen=feature+folders)
## Enable folder view

View File

@@ -56,7 +56,7 @@ Internally, Immich uses the [glob](https://www.npmjs.com/package/glob) package t
### Automatic watching (EXPERIMENTAL)
This feature is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan.
This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan.
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes.
@@ -112,7 +112,7 @@ _Remember to run `docker compose up -d` to register the changes. Make sure you c
These actions must be performed by the Immich administrator.
- Click on your avatar in the upper right corner
- Click on your avatar on the upper right corner
- Click on Administration -> External Libraries
- Click on Create an external library…
- Select which user owns the library, this can not be changed later
@@ -159,7 +159,9 @@ Within seconds, the assets from the old-pics and videos folders should show up i
Folder view provides an additional view besides the timeline that is similar to a file explorer. It allows you to navigate through the folders and files in the library. This feature is handy for a highly curated and customized external library or a nicely configured storage template.
You can enable this feature under [`Account Settings > Features > Folders`](https://my.immich.app/user-settings?isOpen=feature+folders)
You can enable this feature under [`Account Settings > Features > Folder View`](https://my.immich.app/user-settings?isOpen=feature+folders)
The UI is currently only available for the web; mobile will come in a subsequent release.
<img src={require('./img/folder-view-1.webp').default} width="100%" title='Folder-view' />
@@ -169,7 +171,7 @@ You can enable this feature under [`Account Settings > Features > Folders`](http
Only an admin can do this.
:::
You can define a custom interval for the trigger external library rescan under Administration -> Settings -> External Library.
You can define a custom interval for the trigger external library rescan under Administration -> Settings -> Library.
You can set the scanning interval using the preset or cron format. For more information you can refer to [Crontab Guru](https://crontab.guru/).
<img src={require('./img/library-custom-scan-interval.webp').default} width="75%" title='Set custom scan interval for external library' />

View File

@@ -41,7 +41,7 @@ In the Immich web UI:
- Click Add path
<img src={require('./img/add-path-button.webp').default} width="50%" title="Add Path button" />
- Enter **/home/user/photos1** as the path and click Add
- Enter **/usr/src/app/external** as the path and click Add
<img src={require('./img/add-path-field.webp').default} width="50%" title="Add Path field" />
- Save the new path

View File

@@ -29,20 +29,20 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## General
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :---------------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/usr/src/app/upload`<sup>\*3</sup> | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `./upload`<sup>\*3</sup> | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@@ -85,7 +85,6 @@ import React from 'react';
import { Item, Timeline } from '../components/timeline';
const releases = {
'v1.135.0': new Date(2025, 5, 18),
'v1.133.0': new Date(2025, 4, 21),
'v1.130.0': new Date(2025, 2, 25),
'v1.127.0': new Date(2025, 1, 26),
@@ -197,6 +196,14 @@ const roadmap: Item[] = [
description: 'Automate tasks with workflows',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiTableKey,
iconColor: 'gray',
title: 'Fine grained access controls',
description: 'Granular access controls for users and api keys',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiImageEdit,
@@ -232,26 +239,12 @@ const roadmap: Item[] = [
];
const milestones: Item[] = [
{
icon: mdiStar,
iconColor: 'gold',
title: '70,000 Stars',
description: 'Reached 70K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 6, 9)),
},
withRelease({
icon: mdiTableKey,
iconColor: 'gray',
title: 'Fine grained access controls',
description: 'Granular access controls for api keys',
release: 'v1.135.0',
}),
withRelease({
icon: mdiCast,
iconColor: 'aqua',
title: 'Google Cast (web and mobile)',
title: 'Google Cast (web)',
description: 'Cast assets to Google Cast/Chromecast compatible devices',
release: 'v1.135.0',
release: 'v1.133.0',
}),
withRelease({
icon: mdiLockOutline,

View File

@@ -1,5 +1,4 @@
/docs /docs/overview/welcome 307
/docs/ /docs/overview/welcome 307
/docs /docs/overview/introduction 307
/docs/mobile-app-beta-program /docs/features/mobile-app 307
/docs/contribution-guidelines /docs/overview/support-the-project#contributing 307
/docs/install /docs/install/docker-compose 307
@@ -31,4 +30,4 @@
/docs/guides/api-album-sync /docs/community-projects 307
/docs/guides/remove-offline-files /docs/community-projects 307
/milestones /roadmap 307
/docs/overview/introduction /docs/overview/welcome 307
/docs/overview/introduction /docs/overview/welcome 307

View File

@@ -3,6 +3,7 @@ name: immich-e2e
services:
immich-server:
container_name: immich-e2e-server
command: ['./start.sh']
image: immich-server:latest
build:
context: ../

141
e2e/package-lock.json generated
View File

@@ -14,7 +14,6 @@
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.15.33",
"@types/oidc-provider": "^9.0.0",
@@ -83,7 +82,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
@@ -2101,17 +2100,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz",
"integrity": "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
"integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.36.0",
"@typescript-eslint/type-utils": "8.36.0",
"@typescript-eslint/utils": "8.36.0",
"@typescript-eslint/visitor-keys": "8.36.0",
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/type-utils": "8.35.1",
"@typescript-eslint/utils": "8.35.1",
"@typescript-eslint/visitor-keys": "8.35.1",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -2125,7 +2124,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.36.0",
"@typescript-eslint/parser": "^8.35.1",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
@@ -2141,16 +2140,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.36.0.tgz",
"integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz",
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.36.0",
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/typescript-estree": "8.36.0",
"@typescript-eslint/visitor-keys": "8.36.0",
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.35.1",
"@typescript-eslint/typescript-estree": "8.35.1",
"@typescript-eslint/visitor-keys": "8.35.1",
"debug": "^4.3.4"
},
"engines": {
@@ -2166,14 +2165,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz",
"integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz",
"integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.36.0",
"@typescript-eslint/types": "^8.36.0",
"@typescript-eslint/tsconfig-utils": "^8.35.1",
"@typescript-eslint/types": "^8.35.1",
"debug": "^4.3.4"
},
"engines": {
@@ -2188,14 +2187,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz",
"integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz",
"integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/visitor-keys": "8.36.0"
"@typescript-eslint/types": "8.35.1",
"@typescript-eslint/visitor-keys": "8.35.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2206,9 +2205,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz",
"integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz",
"integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2223,14 +2222,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.36.0.tgz",
"integrity": "sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz",
"integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.36.0",
"@typescript-eslint/utils": "8.36.0",
"@typescript-eslint/typescript-estree": "8.35.1",
"@typescript-eslint/utils": "8.35.1",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -2247,9 +2246,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz",
"integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz",
"integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2261,16 +2260,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz",
"integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz",
"integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.36.0",
"@typescript-eslint/tsconfig-utils": "8.36.0",
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/visitor-keys": "8.36.0",
"@typescript-eslint/project-service": "8.35.1",
"@typescript-eslint/tsconfig-utils": "8.35.1",
"@typescript-eslint/types": "8.35.1",
"@typescript-eslint/visitor-keys": "8.35.1",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -2316,16 +2315,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz",
"integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz",
"integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.36.0",
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/typescript-estree": "8.36.0"
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.35.1",
"@typescript-eslint/typescript-estree": "8.35.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2340,13 +2339,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz",
"integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz",
"integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.36.0",
"@typescript-eslint/types": "8.35.1",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -4812,9 +4811,9 @@
"license": "ISC"
},
"node_modules/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==",
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6430,9 +6429,9 @@
}
},
"node_modules/superagent": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.2.tgz",
"integrity": "sha512-vWMq11OwWCC84pQaFPzF/VO3BrjkCeewuvJgt1jfV0499Z1QSAWN4EqfMM5WlFDDX9/oP8JjlDKpblrmEoyu4Q==",
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.1.tgz",
"integrity": "sha512-O+PCv11lgTNJUzy49teNAWLjBZfc+A1enOwTpLlH6/rsvKcTwcdTT8m9azGkVqM7HBl5jpyZ7KTPhHweokBcdg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6451,14 +6450,14 @@
}
},
"node_modules/supertest": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.3.tgz",
"integrity": "sha512-ORY0gPa6ojmg/C74P/bDoS21WL6FMXq5I8mawkEz30/zkwdu0gOeqstFy316vHG6OKxqQ+IbGneRemHI8WraEw==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz",
"integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"methods": "^1.1.2",
"superagent": "^10.2.2"
"superagent": "^10.2.1"
},
"engines": {
"node": ">=14.18.0"
@@ -6778,15 +6777,15 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz",
"integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==",
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz",
"integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.36.0",
"@typescript-eslint/parser": "8.36.0",
"@typescript-eslint/utils": "8.36.0"
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@typescript-eslint/utils": "8.35.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@@ -24,7 +24,6 @@
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.15.33",
"@types/oidc-provider": "^9.0.0",

View File

@@ -7,7 +7,6 @@ import {
ReactionType,
createActivity as create,
createAlbum,
removeAssetFromAlbum,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@@ -343,36 +342,5 @@ describe('/activities', () => {
expect(status).toBe(204);
});
it('should return empty list when asset is removed', async () => {
const album3 = await createAlbum(
{
createAlbumDto: {
albumName: 'Album 3',
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like });
await removeAssetFromAlbum(
{
id: album3.id,
bulkIdsDto: {
ids: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body).toEqual([]);
});
});
});

View File

@@ -20,7 +20,7 @@ describe('/api-keys', () => {
});
beforeEach(async () => {
await utils.resetDatabase(['api_key']);
await utils.resetDatabase(['api_keys']);
});
describe('POST /api-keys', () => {

View File

@@ -15,6 +15,12 @@ describe('/system-config', () => {
});
describe('PUT /system-config', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put('/system-config');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should always return the new config', async () => {
const config = await getSystemConfig(admin.accessToken);

View File

@@ -37,7 +37,7 @@ describe('/tags', () => {
beforeEach(async () => {
// tagging assets eventually triggers metadata extraction which can impact other tests
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.resetDatabase(['tag']);
await utils.resetDatabase(['tags']);
});
describe('POST /tags', () => {

View File

@@ -97,7 +97,7 @@ describe(`immich upload`, () => {
});
beforeEach(async () => {
await utils.resetDatabase(['asset', 'album']);
await utils.resetDatabase(['assets', 'albums']);
});
describe(`immich upload /path/to/file.jpg`, () => {

View File

@@ -116,7 +116,6 @@ export const deviceDto = {
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
},

View File

@@ -154,19 +154,19 @@ export const utils = {
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
'shared_link',
'asset_stack',
'libraries',
'shared_links',
'person',
'album',
'asset',
'asset_face',
'albums',
'assets',
'asset_faces',
'activity',
'api_key',
'session',
'user',
'api_keys',
'sessions',
'users',
'system_metadata',
'tag',
'tags',
];
const sql: string[] = [];
@@ -175,7 +175,7 @@ export const utils = {
if (table === 'system_metadata') {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
sql.push(`DELETE FROM "${table}" CASCADE;`);
sql.push(`DELETE FROM ${table} CASCADE;`);
}
}
@@ -451,7 +451,7 @@ export const utils = {
return;
}
await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
await client.query('INSERT INTO asset_faces ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
},
setPersonThumbnail: async (personId: string) => {

View File

@@ -166,20 +166,6 @@
"metadata_settings_description": "Manage metadata settings",
"migration_job": "Migration",
"migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure",
"nightly_tasks_cluster_faces_setting_description": "Run facial recognition on newly detected faces",
"nightly_tasks_cluster_new_faces_setting": "Cluster new faces",
"nightly_tasks_database_cleanup_setting": "Database cleanup tasks",
"nightly_tasks_database_cleanup_setting_description": "Clean up old, expired data from the database",
"nightly_tasks_generate_memories_setting": "Generate memories",
"nightly_tasks_generate_memories_setting_description": "Create new memories from assets",
"nightly_tasks_missing_thumbnails_setting": "Generate missing thumbnails",
"nightly_tasks_missing_thumbnails_setting_description": "Queue assets without thumbnails for thumbnail generation",
"nightly_tasks_settings": "Nightly Tasks Settings",
"nightly_tasks_settings_description": "Manage nightly tasks",
"nightly_tasks_start_time_setting": "Start time",
"nightly_tasks_start_time_setting_description": "The time at which the server starts running the nightly tasks",
"nightly_tasks_sync_quota_usage_setting": "Sync quota usage",
"nightly_tasks_sync_quota_usage_setting_description": "Update user storage quota, based on current usage",
"no_paths_added": "No paths added",
"no_pattern_added": "No pattern added",
"note_apply_storage_label_previous_assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
@@ -373,12 +359,10 @@
"admin_password": "Admin Password",
"administration": "Administration",
"advanced": "Advanced",
"advanced_settings_beta_timeline_subtitle": "Try the new app experience",
"advanced_settings_beta_timeline_title": "Beta Timeline",
"advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
"advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
"advanced_settings_log_level_title": "Log level: {level}",
"advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from local assets. Activate this setting to load remote images instead.",
"advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.",
"advanced_settings_prefer_remote_title": "Prefer remote images",
"advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request",
"advanced_settings_proxy_headers_title": "Proxy Headers",
@@ -751,7 +735,6 @@
"delete_key": "Delete key",
"delete_library": "Delete Library",
"delete_link": "Delete link",
"delete_local_action_prompt": "{count} deleted locally",
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
"delete_local_dialog_ok_force": "Delete Anyway",
"delete_others": "Delete others",
@@ -1149,7 +1132,6 @@
"library_page_sort_created": "Created date",
"library_page_sort_last_modified": "Last modified",
"library_page_sort_title": "Album title",
"licenses": "Licenses",
"light": "Light",
"like_deleted": "Like deleted",
"link_motion_video": "Link motion video",
@@ -1519,7 +1501,6 @@
"remove_custom_date_range": "Remove custom date range",
"remove_deleted_assets": "Remove Deleted Assets",
"remove_from_album": "Remove from album",
"remove_from_album_action_prompt": "{count} removed from the album",
"remove_from_favorites": "Remove from favorites",
"remove_from_lock_folder_action_prompt": "{count} removed from the locked folder",
"remove_from_locked_folder": "Remove from locked folder",
@@ -1693,7 +1674,6 @@
"settings_saved": "Settings saved",
"setup_pin_code": "Setup a PIN code",
"share": "Share",
"share_action_prompt": "Shared {count} assets",
"share_add_photos": "Add photos",
"share_assets_selected": "{count} selected",
"share_dialog_preparing": "Preparing...",
@@ -1795,7 +1775,6 @@
"sort_title": "Title",
"source": "Source",
"stack": "Stack",
"stack_action_prompt": "{count} stacked",
"stack_duplicates": "Stack duplicates",
"stack_select_one_photo": "Select one main photo for the stack",
"stack_selected_photos": "Stack selected photos",
@@ -1906,9 +1885,7 @@
"unselect_all_duplicates": "Unselect all duplicates",
"unselect_all_in": "Unselect all in {group}",
"unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"untagged": "Untagged",
"up_next": "Up next",
"updated_at": "Updated",
"updated_password": "Updated password",
@@ -1945,7 +1922,6 @@
"user_usage_stats_description": "View account usage statistics",
"username": "Username",
"users": "Users",
"users_added_to_album_count": "Added {count, plural, one {# user} other {# users}} to the album",
"utilities": "Utilities",
"validate": "Validate",
"validate_endpoint_error": "Please enter a valid URL",

View File

@@ -4,12 +4,9 @@ import sys
import requests
port = os.getenv("IMMICH_PORT", 3003)
host = os.getenv("IMMICH_HOST", "0.0.0.0")
host = "localhost" if host == "0.0.0.0" else host
try:
response = requests.get(f"http://{host}:{port}/ping", timeout=2)
response = requests.get(f"http://localhost:{port}/ping", timeout=2)
if response.status_code == 200:
sys.exit(0)
sys.exit(1)

View File

@@ -1,3 +1,3 @@
{
"flutter": "3.32.6"
"flutter": "3.29.3"
}

View File

@@ -1,5 +1,5 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.32.6",
"dart.flutterSdkPath": ".fvm/versions/3.29.3",
"search.exclude": {
"**/.fvm": true
},

View File

@@ -106,7 +106,6 @@ custom_lint:
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
- lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database
- lib/domain/services/search.service.dart
# refactor
- lib/models/map/map_marker.model.dart

View File

@@ -3,8 +3,6 @@ plugins {
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
}
def localProperties = new Properties()
@@ -47,10 +45,6 @@ android {
main.java.srcDirs += 'src/main/kotlin'
}
buildFeatures {
compose true
}
defaultConfig {
applicationId "app.alextran.immich"
minSdkVersion 26
@@ -72,20 +66,6 @@ android {
}
}
flavorDimensions "default"
productFlavors {
production {
dimension "default"
applicationId "app.alextran.immich"
}
beta {
dimension "default"
applicationId "app.alextran.immich.beta"
versionNameSuffix "-BETA"
}
}
buildTypes {
debug {
applicationIdSuffix '.debug'
@@ -111,8 +91,6 @@ dependencies {
def guava_version = '33.3.1-android'
def glide_version = '4.16.0'
def serialization_version = '1.8.1'
def compose_version = '1.1.1'
def gson_version = '2.10.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
@@ -124,17 +102,6 @@ dependencies {
ksp "com.github.bumptech.glide:ksp:$glide_version"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
//Glance Widget
implementation "androidx.glance:glance-appwidget:$compose_version"
implementation "com.google.code.gson:gson:$gson_version"
// Glance Configure
implementation "androidx.activity:activity-compose:1.8.2"
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.compose.material3:material3:1.2.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
}
// This is uncommented in F-Droid build script

View File

@@ -25,15 +25,8 @@
@com.google.gson.annotations.SerializedName <fields>;
}
# TypeToken preventions
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
##---------------End: proguard configuration for Gson ----------
# Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; }
##---------------End: proguard configuration for Gson ----------

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application android:label="Immich Beta" tools:replace="android:label" />
</manifest>

View File

@@ -100,24 +100,24 @@
<!-- my.immich.app deep link -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="https" />
<data
android:host="my.immich.app"
android:path="/" />
<data
android:host="my.immich.app"
android:pathPrefix="/albums/" />
<data
android:host="my.immich.app"
android:pathPrefix="/memories/" />
<data
android:host="my.immich.app"
android:pathPrefix="/photos/" />
<data
android:host="my.immich.app"
android:path="/" />
<data
android:host="my.immich.app"
android:pathPrefix="/albums/" />
<data
android:host="my.immich.app"
android:pathPrefix="/memories/" />
<data
android:host="my.immich.app"
android:pathPrefix="/photos/" />
</intent-filter>
</activity>
@@ -141,41 +141,6 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- Widgets -->
<receiver
android:name=".widget.RandomReceiver"
android:exported="true"
android:label="@string/random_widget_title">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/random_widget" />
</receiver>
<receiver
android:name=".widget.MemoryReceiver"
android:exported="true"
android:label="@string/memory_widget_title">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/memory_widget" />
</receiver>
<activity android:name=".widget.configure.RandomConfigure"
android:exported="true"
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
</application>
@@ -189,4 +154,4 @@
<data android:scheme="geo" />
</intent>
</queries>
</manifest>
</manifest>

View File

@@ -87,8 +87,7 @@ data class PlatformAsset (
val updatedAt: Long? = null,
val width: Long? = null,
val height: Long? = null,
val durationInSeconds: Long,
val orientation: Long
val durationInSeconds: Long
)
{
companion object {
@@ -101,8 +100,7 @@ data class PlatformAsset (
val width = pigeonVar_list[5] as Long?
val height = pigeonVar_list[6] as Long?
val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation)
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds)
}
}
fun toList(): List<Any?> {
@@ -115,7 +113,6 @@ data class PlatformAsset (
width,
height,
durationInSeconds,
orientation,
)
}
override fun equals(other: Any?): Boolean {

View File

@@ -40,8 +40,7 @@ open class NativeSyncApiImplBase(context: Context) {
MediaStore.MediaColumns.BUCKET_ID,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DURATION,
MediaStore.MediaColumns.ORIENTATION,
MediaStore.MediaColumns.DURATION
)
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
@@ -75,8 +74,6 @@ open class NativeSyncApiImplBase(context: Context) {
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
while (c.moveToNext()) {
val id = c.getLong(idColumn).toString()
@@ -104,7 +101,6 @@ open class NativeSyncApiImplBase(context: Context) {
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
else c.getLong(durationColumn) / 1000
val bucketId = c.getString(bucketIdColumn)
val orientation = c.getInt(orientationColumn)
val asset = PlatformAsset(
id,
@@ -114,8 +110,7 @@ open class NativeSyncApiImplBase(context: Context) {
modifiedAt,
width,
height,
duration,
orientation.toLong(),
duration
)
yield(AssetResult.ValidAsset(asset, bucketId))
}

View File

@@ -1,33 +0,0 @@
package app.alextran.immich.widget
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.absolutePath, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(file.absolutePath, options)
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}

View File

@@ -1,241 +0,0 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import app.alextran.immich.widget.model.*
import java.time.LocalDate
class ImageDownloadWorker(
private val context: Context,
workerParameters: WorkerParameters
) : CoroutineWorker(context, workerParameters) {
companion object {
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
private fun buildConstraints(): Constraints {
return Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
}
private fun buildInputData(appWidgetId: Int, widgetType: WidgetType): Data {
return Data.Builder()
.putString(kWorkerWidgetType, widgetType.toString())
.putInt(kWorkerWidgetID, appWidgetId)
.build()
}
fun enqueuePeriodic(context: Context, appWidgetId: Int, widgetType: WidgetType) {
val manager = WorkManager.getInstance(context)
val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>(
20, TimeUnit.MINUTES
)
.setConstraints(buildConstraints())
.setInputData(buildInputData(appWidgetId, widgetType))
.addTag(appWidgetId.toString())
.build()
manager.enqueueUniquePeriodicWork(
"$uniqueWorkName-$appWidgetId",
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
fun singleShot(context: Context, appWidgetId: Int, widgetType: WidgetType) {
val manager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequestBuilder<ImageDownloadWorker>()
.setConstraints(buildConstraints())
.setInputData(buildInputData(appWidgetId, widgetType))
.addTag(appWidgetId.toString())
.build()
manager.enqueueUniqueWork(
"$uniqueWorkName-$appWidgetId",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
suspend fun cancel(context: Context, appWidgetId: Int) {
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
// delete cached image
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
if (!currentImgUUID.isNullOrEmpty()) {
val file = File(context.cacheDir, imageFilename(currentImgUUID))
file.delete()
}
}
}
override suspend fun doWork(): Result {
return try {
val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "")
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context)
// clear any image caches and go to "login" state if no credentials
if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
updateWidget(
glanceId,
"",
"",
"immich://",
WidgetState.LOG_IN
)
}
return Result.success()
}
// fetch new image
val entry = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig)
}
// clear current image if it exists
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
}
// save a new image
val imgUUID = UUID.randomUUID().toString()
saveImage(entry.image, imgUUID)
// trigger the update routine with new image uuid
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
Result.success()
} catch (e: Exception) {
Log.e(uniqueWorkName, "Error while loading image", e)
if (runAttemptCount < 10) {
Result.retry()
} else {
Result.failure()
}
}
}
private suspend fun updateWidget(
glanceId: GlanceId,
imageUUID: String,
subtitle: String?,
deeplink: String?,
widgetState: WidgetState = WidgetState.SUCCESS
) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis()
prefs[kImageUUID] = imageUUID
prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: ""
prefs[kDeeplinkURL] = deeplink ?: ""
}
PhotoWidget().update(context,glanceId)
}
private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val filters = SearchFilters(AssetType.IMAGE)
val albumId = widgetConfig[kSelectedAlbum]
val showSubtitle = widgetConfig[kShowAlbumName]
val albumName = widgetConfig[kSelectedAlbumName]
var subtitle: String? = if (showSubtitle == true) albumName else ""
if (albumId != null) {
filters.albumIds = listOf(albumId)
}
var randomSearch = api.fetchSearchResults(filters)
// handle an empty album, fallback to random
if (randomSearch.isEmpty() && albumId != null) {
randomSearch = api.fetchSearchResults(SearchFilters(AssetType.IMAGE))
subtitle = ""
}
val random = randomSearch.first()
val image = api.fetchImage(random)
return WidgetEntry(
image,
subtitle,
assetDeeplink(random)
)
}
private suspend fun fetchMemory(
serverConfig: ServerConfig
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val today = LocalDate.now()
val memories = api.fetchMemory(today)
val asset: Asset
var subtitle: String? = null
if (memories.isNotEmpty()) {
// pick a random asset from a random memory
val memory = memories.random()
asset = memory.assets.random()
val yearDiff = today.year - memory.data.year
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
} else {
val filters = SearchFilters(AssetType.IMAGE, size=1)
asset = api.fetchSearchResults(filters).first()
}
val image = api.fetchImage(asset)
return WidgetEntry(
image,
subtitle,
assetDeeplink(asset)
)
}
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
file.delete()
}
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
}
}

View File

@@ -1,103 +0,0 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.alextran.immich.widget.model.*
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class ImmichAPI(cfg: ServerConfig) {
companion object {
fun getServerConfig(context: Context): ServerConfig? {
val prefs = HomeWidgetPlugin.getData(context)
val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
if (serverURL.isBlank() || sessionKey.isBlank()) {
return null
}
return ServerConfig(
serverURL,
sessionKey
)
}
}
private val gson = Gson()
private val serverConfig = cfg
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return URL(urlString.toString())
}
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
}
connection.outputStream.use {
OutputStreamWriter(it).use { writer ->
writer.write(gson.toJson(filters))
writer.flush()
}
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data")
}
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(response, type)
}
}

View File

@@ -1,56 +0,0 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MemoryReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
CoroutineScope(Dispatchers.Default).launch {
val provider = ComponentName(context, MemoryReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
}
}
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}

View File

@@ -1,124 +0,0 @@
package app.alextran.immich.widget
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.glance.appwidget.*
import androidx.glance.*
import androidx.glance.action.clickable
import androidx.glance.layout.*
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import app.alextran.immich.R
import app.alextran.immich.widget.model.*
import java.io.File
class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<MutablePreferences>()
val imageUUID = prefs[kImageUUID]
val subtitle = prefs[kSubtitleText]
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
val widgetState = prefs[kWidgetState]
var bitmap: Bitmap? = null
if (imageUUID != null) {
// fetch a random photo from server
val file = File(context.cacheDir, imageFilename(imageUUID))
if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500)
}
}
// WIDGET CONTENT
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.clickable {
val intent = Intent(Intent.ACTION_VIEW, deeplinkURL ?: "immich://".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}
) {
if (bitmap != null) {
Image(
provider = ImageProvider(bitmap),
contentDescription = "Widget Image",
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize()
)
if (!subtitle.isNullOrBlank()) {
Column(
verticalAlignment = Alignment.Bottom,
horizontalAlignment = Alignment.Start,
modifier = GlanceModifier
.fillMaxSize()
.padding(12.dp)
) {
Text(
text = subtitle,
style = TextStyle(
color = ColorProvider(Color.White),
fontSize = 16.sp
),
modifier = GlanceModifier
.background(ColorProvider(Color(0x99000000))) // 60% black
.padding(8.dp)
.cornerRadius(8.dp)
)
}
}
} else {
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
provider = ImageProvider(R.drawable.splash),
contentDescription = null,
)
if (widgetState == WidgetState.LOG_IN.toString()) {
Box(
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text("Log in to your Immich server", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary))
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = GlanceModifier.fillMaxWidth().padding(16.dp)
) {
CircularProgressIndicator(
modifier = GlanceModifier.size(12.dp)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text("Loading widget...", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary))
}
}
}
}
}
}
}
}

View File

@@ -1,55 +0,0 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import es.antonborri.home_widget.HomeWidgetPlugin
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RandomReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
CoroutineScope(Dispatchers.Default).launch {
val provider = ComponentName(context, RandomReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
}
}
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}

View File

@@ -1,64 +0,0 @@
package app.alextran.immich.widget.configure
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
data class DropdownItem (
val label: String,
val id: String,
)
// Creating a composable to display a drop down menu
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Dropdown(items: List<DropdownItem>,
selectedItem: DropdownItem?,
onItemSelected: (DropdownItem) -> Unit,
enabled: Boolean = true
) {
var expanded by remember { mutableStateOf(false) }
var selectedOption by remember { mutableStateOf(selectedItem?.label ?: items[0].label) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded && enabled },
) {
TextField(
value = selectedOption,
onValueChange = {},
readOnly = true,
enabled = enabled,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
items.forEach { option ->
DropdownMenuItem(
text = { Text(option.label, color = MaterialTheme.colorScheme.onSurface) },
onClick = {
selectedOption = option.label
onItemSelected(option)
expanded = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
)
}
}
}
}

View File

@@ -1,28 +0,0 @@
package app.alextran.immich.widget.configure
import android.os.Build
import androidx.compose.foundation.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun LightDarkTheme(
content: @Composable () -> Unit
) {
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isDarkTheme ->
dynamicDarkColorScheme(context)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isDarkTheme ->
dynamicLightColorScheme(context)
isDarkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -1,210 +0,0 @@
package app.alextran.immich.widget.configure
import android.appwidget.AppWidgetManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import app.alextran.immich.widget.ImageDownloadWorker
import app.alextran.immich.widget.ImmichAPI
import app.alextran.immich.widget.model.*
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
class RandomConfigure : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Get widget ID from intent
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
val glanceId = GlanceAppWidgetManager(applicationContext)
.getGlanceIdBy(appWidgetId)
setContent {
LightDarkTheme {
RandomConfiguration(applicationContext, appWidgetId, glanceId, onDone = {
finish()
Log.w("WIDGET_ACTIVITY", "SAVING")
})
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, onDone: () -> Unit) {
var selectedAlbum by remember { mutableStateOf<DropdownItem?>(null) }
var showAlbumName by remember { mutableStateOf(false) }
var availableAlbums by remember { mutableStateOf<List<DropdownItem>>(listOf()) }
var state by remember { mutableStateOf(WidgetConfigState.LOADING) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// get albums from server
val serverCfg = ImmichAPI.getServerConfig(context)
if (serverCfg == null) {
state = WidgetConfigState.LOG_IN
return@LaunchedEffect
}
val api = ImmichAPI(serverCfg)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
var albumItems: List<DropdownItem>
try {
albumItems = api.fetchAlbums().map {
DropdownItem(it.albumName, it.id)
}
state = WidgetConfigState.SUCCESS
} catch (e: FileNotFoundException) {
Log.e("WidgetWorker", "Error fetching albums: ${e.message}")
state = WidgetConfigState.NO_CONNECTION
albumItems = listOf(DropdownItem(currentAlbumName, currentAlbumId))
}
availableAlbums = listOf(DropdownItem("None", "NONE")) + albumItems
// load selected configuration
val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId }
selectedAlbum = albumEntity ?: availableAlbums.first()
// load showAlbumName
showAlbumName = currentState[kShowAlbumName] == true
}
suspend fun saveConfiguration() {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kSelectedAlbum] = selectedAlbum?.id ?: ""
prefs[kSelectedAlbumName] = selectedAlbum?.label ?: ""
prefs[kShowAlbumName] = showAlbumName
}
ImageDownloadWorker.singleShot(context, appWidgetId, WidgetType.RANDOM)
}
Scaffold(
topBar = {
TopAppBar (
title = { Text("Widget Configuration") },
actions = {
IconButton(onClick = {
scope.launch {
saveConfiguration()
onDone()
}
}) {
Icon(Icons.Default.Check, contentDescription = "Close", tint = MaterialTheme.colorScheme.primary)
}
}
)
}
) { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding), // Respect the top bar
color = MaterialTheme.colorScheme.background
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
when (state) {
WidgetConfigState.LOADING -> CircularProgressIndicator(modifier = Modifier.size(48.dp))
WidgetConfigState.LOG_IN -> Text("You must log in inside the Immich App to configure this widget.")
else -> {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("View a random image from your library or a specific album.", style = MaterialTheme.typography.bodyMedium)
// no connection warning
if (state == WidgetConfigState.NO_CONNECTION) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Warning",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "No connection to the server is available. Please try again later.",
style = MaterialTheme.typography.bodyMedium
)
}
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Album")
Dropdown(
items = availableAlbums,
selectedItem = selectedAlbum,
onItemSelected = { selectedAlbum = it },
enabled = (state != WidgetConfigState.NO_CONNECTION)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Show Album Name")
Switch(
checked = showAlbumName,
onCheckedChange = { showAlbumName = it },
enabled = (state != WidgetConfigState.NO_CONNECTION)
)
}
}
}
}
}
}
}
}
}

View File

@@ -1,79 +0,0 @@
package app.alextran.immich.widget.model
import android.graphics.Bitmap
import androidx.datastore.preferences.core.*
// MARK: Immich Entities
enum class AssetType {
IMAGE, VIDEO, AUDIO, OTHER
}
data class Asset(
val id: String,
val type: AssetType,
)
data class SearchFilters(
var type: AssetType = AssetType.IMAGE,
val size: Int = 1,
var albumIds: List<String> = listOf()
)
data class MemoryResult(
val id: String,
var assets: List<Asset>,
val type: String,
val data: MemoryData
) {
data class MemoryData(val year: Int)
}
data class Album(
val id: String,
val albumName: String
)
// MARK: Widget Specific
enum class WidgetType {
RANDOM, MEMORIES;
}
enum class WidgetState {
LOADING, SUCCESS, LOG_IN;
}
enum class WidgetConfigState {
LOADING, SUCCESS, LOG_IN, NO_CONNECTION
}
data class WidgetEntry (
val image: Bitmap,
val subtitle: String?,
val deeplink: String?
)
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
// MARK: Widget State Keys
val kImageUUID = stringPreferencesKey("uuid")
val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state")
val kSelectedAlbum = stringPreferencesKey("albumID")
val kSelectedAlbumName = stringPreferencesKey("albumName")
val kShowAlbumName = booleanPreferencesKey("showAlbumName")
val kDeeplinkURL = stringPreferencesKey("deeplink")
const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId"
const val kTriggeredFromApp = "triggeredFromApp"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}
fun assetDeeplink(asset: Asset): String {
return "immich://asset?id=${asset.id}"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="memory_widget_title">Memories</string>
<string name="random_widget_title">Random</string>
<string name="memory_widget_description">See memories from Immich.</string>
<string name="random_widget_description">View a random image from your library or a specific album.</string>
</resources>

View File

@@ -1,9 +0,0 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1200000"
android:description="@string/memory_widget_description"
android:previewImage="@drawable/memory_preview"
/>

View File

@@ -1,13 +0,0 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1200000"
android:configure="app.alextran.immich.widget.configure.RandomConfigure"
android:widgetFeatures="reconfigurable|configuration_optional"
tools:targetApi="28"
android:description="@string/random_widget_description"
android:previewImage="@drawable/random_preview"
/>

View File

@@ -1 +1 @@
version: '>=1.29.0 <=1.30.0'
version: '>=1.29.0 <1.30.0'

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,7 +23,7 @@ class ImmichLinter extends PluginBase {
return rules;
}
static LintCode makeCode(String name, LintOptions options) => LintCode(
static makeCode(String name, LintOptions options) => LintCode(
name: name,
problemMessage: options.json["message"] as String,
errorSeverity: ErrorSeverity.WARNING,

View File

@@ -5,34 +5,34 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f
sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
url: "https://pub.dev"
source: hosted
version: "82.0.0"
version: "80.0.0"
analyzer:
dependency: "direct main"
description:
name: analyzer
sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0"
sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
url: "https://pub.dev"
source: hosted
version: "7.4.5"
version: "7.3.0"
analyzer_plugin:
dependency: "direct main"
description:
name: analyzer_plugin
sha256: ee188b6df6c85f1441497c7171c84f1392affadc0384f71089cb10a3bc508cef
sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4
url: "https://pub.dev"
source: hosted
version: "0.13.1"
version: "0.13.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
url: "https://pub.dev"
source: hosted
version: "2.7.0"
version: "2.6.0"
async:
dependency: transitive
description:
@@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.4"
version: "2.0.3"
ci:
dependency: transitive
description:
@@ -125,10 +125,10 @@ packages:
dependency: transitive
description:
name: custom_lint_visitor
sha256: cba5b6d7a6217312472bf4468cdf68c949488aed7ffb0eab792cd0b6c435054d
sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.4.5"
version: "1.0.0+7.3.0"
dart_style:
dependency: transitive
description:
@@ -157,10 +157,10 @@ packages:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.0.0"
glob:
dependency: "direct main"
description:
@@ -213,18 +213,18 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.1"
path:
dependency: transitive
description:
@@ -317,10 +317,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.4"
typed_data:
dependency: transitive
description:
@@ -341,18 +341,18 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.0.2"
version: "15.0.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.1.1"
yaml:
dependency: transitive
description:
@@ -362,4 +362,4 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
dart: ">=3.8.0-0 <4.0.0"

View File

@@ -26,7 +26,6 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
@@ -44,7 +43,6 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
disableMainThreadChecker = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"

View File

@@ -138,7 +138,6 @@ struct PlatformAsset: Hashable {
var width: Int64? = nil
var height: Int64? = nil
var durationInSeconds: Int64
var orientation: Int64
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -151,7 +150,6 @@ struct PlatformAsset: Hashable {
let width: Int64? = nilOrValue(pigeonVar_list[5])
let height: Int64? = nilOrValue(pigeonVar_list[6])
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
return PlatformAsset(
id: id,
@@ -161,8 +159,7 @@ struct PlatformAsset: Hashable {
updatedAt: updatedAt,
width: width,
height: height,
durationInSeconds: durationInSeconds,
orientation: orientation
durationInSeconds: durationInSeconds
)
}
func toList() -> [Any?] {
@@ -175,7 +172,6 @@ struct PlatformAsset: Hashable {
width,
height,
durationInSeconds,
orientation,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {

View File

@@ -27,8 +27,7 @@ extension PHAsset {
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
width: Int64(pixelWidth),
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0
durationInSeconds: Int64(duration)
)
}
}
@@ -170,8 +169,7 @@ class NativeSyncApiImpl: NativeSyncApi {
id: asset.localIdentifier,
name: "",
type: 0,
durationInSeconds: 0,
orientation: 0
durationInSeconds: 0
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue

View File

@@ -0,0 +1,59 @@
import SwiftUI
import WidgetKit
func buildEntry(
api: ImmichAPI,
asset: Asset,
dateOffset: Int,
subtitle: String? = nil
)
async throws -> ImageEntry
{
let entryDate = Calendar.current.date(
byAdding: .minute,
value: dateOffset * 20,
to: Date.now
)!
let image = try await api.fetchImage(asset: asset)
return ImageEntry(date: entryDate, image: image, subtitle: subtitle, deepLink: asset.deepLink)
}
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
albumId: String? = nil,
subtitle: String? = nil
)
async throws -> [ImageEntry]
{
var entries: [ImageEntry] = []
let albumIds = albumId != nil ? [albumId!] : []
let randomAssets = try await api.fetchSearchResults(
with: SearchFilters(size: count, albumIds: albumIds)
)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {
group.addTask {
return try? await buildEntry(
api: api,
asset: asset,
dateOffset: dateOffset,
subtitle: subtitle
)
}
}
for await result in group {
if let entry = result {
entries.append(entry)
}
}
}
return entries
}

View File

@@ -1,148 +0,0 @@
import SwiftUI
import WidgetKit
typealias EntryMetadata = ImageEntry.Metadata
struct ImageEntry: TimelineEntry {
let date: Date
var image: UIImage?
var metadata: Metadata = Metadata()
struct Metadata: Codable {
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
}
static func build(
api: ImmichAPI,
asset: Asset,
dateOffset: Int,
subtitle: String? = nil
)
async throws -> Self
{
let entryDate = Calendar.current.date(
byAdding: .minute,
value: dateOffset * 20,
to: Date.now
)!
let image = try await api.fetchImage(asset: asset)
return Self(
date: entryDate,
image: image,
metadata: EntryMetadata(
subtitle: subtitle,
deepLink: asset.deepLink
)
)
}
func cache(for key: String) throws {
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
// build metadata JSON
let entryMetadata = try JSONEncoder().encode(self.metadata)
// write to disk
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
try entryMetadata.write(to: metadataURL, options: .atomic)
}
}
static func loadCached(for key: String, at date: Date = Date.now)
-> ImageEntry?
{
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL),
let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else {
return nil
}
return ImageEntry(
date: date,
image: UIImage(data: imageData),
metadata: decodedMetadata
)
}
return nil
}
static func handleError(
for key: String,
error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> {
var timelineEntry = ImageEntry(
date: Date.now,
image: nil,
metadata: EntryMetadata(error: error)
)
// use cache if generic failed error
// we want to show the other errors to the user since without intervention,
// it will never succeed
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
{
timelineEntry = cachedEntry
}
return Timeline(entries: [timelineEntry], policy: .atEnd)
}
}
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
filter: SearchFilter = Album.NONE.filter,
subtitle: String? = nil
)
async throws -> [ImageEntry]
{
var entries: [ImageEntry] = []
let randomAssets = try await api.fetchSearchResults(with: filter)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {
group.addTask {
return try? await ImageEntry.build(
api: api,
asset: asset,
dateOffset: dateOffset,
subtitle: subtitle
)
}
}
for await result in group {
if let entry = result {
entries.append(entry)
}
}
}
return entries
}

View File

@@ -1,14 +1,23 @@
import SwiftUI
import WidgetKit
extension Image {
@ViewBuilder
func tintedWidgetImageModifier() -> some View {
if #available(iOS 18.0, *) {
self
.widgetAccentedRenderingMode(.accentedDesaturated)
} else {
self
struct ImageEntry: TimelineEntry {
let date: Date
var image: UIImage?
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
// Resizes the stored image to a maximum width of 450 pixels
mutating func resize() {
if (image == nil || image!.size.height < 450 || image!.size.width < 450 ) {
return
}
image = image?.resized(toWidth: 450)
if image == nil {
error = .unableToResize
}
}
}
@@ -20,8 +29,7 @@ struct ImmichWidgetView: View {
if entry.image == nil {
VStack {
Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "")
Text(entry.error?.errorDescription ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
@@ -32,13 +40,11 @@ struct ImmichWidgetView: View {
Color.clear.overlay(
Image(uiImage: entry.image!)
.resizable()
.tintedWidgetImageModifier()
.scaledToFill()
)
VStack {
Spacer()
if let subtitle = entry.metadata.subtitle {
if let subtitle = entry.subtitle {
Text(subtitle)
.foregroundColor(.white)
.padding(8)
@@ -49,7 +55,7 @@ struct ImmichWidgetView: View {
}
.padding(16)
}
.widgetURL(entry.metadata.deepLink)
.widgetURL(entry.deepLink)
}
}
}
@@ -64,9 +70,7 @@ struct ImmichWidgetView: View {
ImageEntry(
date: date,
image: UIImage(named: "ImmichLogo"),
metadata: EntryMetadata(
subtitle: "1 year ago"
)
subtitle: "1 year ago"
)
}
)

View File

@@ -2,20 +2,14 @@ import Foundation
import SwiftUI
import WidgetKit
let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable {
enum WidgetError: Error {
case noLogin
case fetchFailed
case unknown
case albumNotFound
case noAssetsAvailable
}
enum FetchError: Error {
case unableToResize
case invalidImage
case invalidURL
case fetchFailed
}
extension WidgetError: LocalizedError {
@@ -29,9 +23,15 @@ extension WidgetError: LocalizedError {
case .albumNotFound:
return "Album not found"
case .invalidURL:
return "An invalid URL was used"
case .invalidImage:
return "An invalid image was received"
case .noAssetsAvailable:
return "No assets available"
default:
return "An unknown error occured"
}
}
}
@@ -46,17 +46,16 @@ enum AssetType: String, Codable {
struct Asset: Codable {
let id: String
let type: AssetType
var deepLink: URL? {
return URL(string: "immich://asset?id=\(id)")
}
}
struct SearchFilter: Codable {
var type = AssetType.image
var size = 1
struct SearchFilters: Codable {
var type: AssetType = .image
let size: Int
var albumIds: [String] = []
var isFavorite: Bool? = nil
}
struct MemoryResult: Codable {
@@ -71,34 +70,9 @@ struct MemoryResult: Codable {
let data: MemoryData
}
struct Album: Codable, Equatable {
struct Album: Codable {
let id: String
let albumName: String
static let NONE = Album(id: "NONE", albumName: "None")
static let FAVORITES = Album(id: "FAVORITES", albumName: "Favorites")
var filter: SearchFilter {
switch self {
case Album.NONE:
return SearchFilter()
case Album.FAVORITES:
return SearchFilter(isFavorite: true)
// regular album
default:
return SearchFilter(albumIds: [id])
}
}
var isVirtual: Bool {
switch self {
case Album.NONE, Album.FAVORITES:
return true
default:
return false
}
}
}
// MARK: API
@@ -112,7 +86,7 @@ class ImmichAPI {
init() async throws {
// fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"),
let serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token")
else {
@@ -156,8 +130,7 @@ class ImmichAPI {
return components?.url
}
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws
func fetchSearchResults(with filters: SearchFilters) async throws
-> [Asset]
{
// get URL
@@ -203,7 +176,7 @@ class ImmichAPI {
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
func fetchImage(asset: Asset) async throws(WidgetError) -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
@@ -216,25 +189,18 @@ class ImmichAPI {
else {
throw .invalidURL
}
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
else {
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) else {
throw .invalidURL
}
let decodeOptions: [NSString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 400,
kCGImageSourceCreateThumbnailWithTransform: true
]
guard
let thumbnail = CGImageSourceCreateThumbnailAtIndex(
imageSource,
0,
decodeOptions as CFDictionary
)
else {
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions as CFDictionary) else {
throw .fetchFailed
}

View File

@@ -2,11 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -7,17 +7,14 @@
import UIKit
extension UIImage {
/// Crops the image to ensure width and height do not exceed maxSize.
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
let canvas = CGSize(
width: width,
height: CGFloat(ceil(width / size.width * size.height))
)
let format = imageRendererFormat
format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image {
_ in draw(in: CGRect(origin: .zero, size: canvas))
/// Crops the image to ensure width and height do not exceed maxSize.
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height)))
let format = imageRendererFormat
format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image {
_ in draw(in: CGRect(origin: .zero, size: canvas))
}
}
}
}

View File

@@ -19,31 +19,28 @@ struct ImmichMemoryProvider: TimelineProvider {
in context: Context,
completion: @escaping @Sendable (ImageEntry) -> Void
) {
let cacheKey = "memory_\(context.family.rawValue)"
Task {
guard let api = try? await ImmichAPI() else {
completion(
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
)
completion(ImageEntry(date: Date(), image: nil, error: .noLogin))
return
}
guard let memories = try? await api.fetchMemory(for: Date.now)
else {
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
return
}
for memory in memories {
if let asset = memory.assets.first(where: { $0.type == .image }),
let entry = try? await ImageEntry.build(
var entry = try? await buildEntry(
api: api,
asset: asset,
dateOffset: 0,
subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year)
)
{
entry.resize()
completion(entry)
return
}
@@ -51,17 +48,26 @@ struct ImmichMemoryProvider: TimelineProvider {
// fallback to random image
guard
let randomImage = try? await api.fetchSearchResults().first,
let imageEntry = try? await ImageEntry.build(
let randomImage = try? await api.fetchSearchResults(
with: SearchFilters(size: 1)
).first
else {
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
return
}
guard
var imageEntry = try? await buildEntry(
api: api,
asset: randomImage,
dateOffset: 0
)
else {
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
return
}
imageEntry.resize()
completion(imageEntry)
}
}
@@ -74,12 +80,9 @@ struct ImmichMemoryProvider: TimelineProvider {
var entries: [ImageEntry] = []
let now = Date()
let cacheKey = "memory_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else {
completion(
ImageEntry.handleError(for: cacheKey, error: .noLogin)
)
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
completion(Timeline(entries: entries, policy: .atEnd))
return
}
@@ -92,7 +95,7 @@ struct ImmichMemoryProvider: TimelineProvider {
for asset in memory.assets {
if asset.type == .image && totalAssets < 12 {
group.addTask {
try? await ImageEntry.build(
try? await buildEntry(
api: api,
asset: asset,
dateOffset: totalAssets,
@@ -117,32 +120,25 @@ struct ImmichMemoryProvider: TimelineProvider {
// If we didnt add any memory images (some failure occured or no images in memory),
// default to 12 hours of random photos
if entries.count == 0 {
// this must be a do/catch since we need to
// distinguish between a network fail and an empty search
do {
let search = try await generateRandomEntries(
entries.append(
contentsOf: (try? await generateRandomEntries(
api: api,
now: now,
count: 12
)
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
completion(
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
)
return
}
entries.append(contentsOf: search)
} catch {
completion(ImageEntry.handleError(for: cacheKey))
return
}
)) ?? []
)
}
// cache the last image
try? entries.last!.cache(for: cacheKey)
// If we fail to fetch images, we still want to add an entry
// with a nil image and an error
if entries.count == 0 {
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
}
// Resize all images to something that can be stored by iOS
for i in entries.indices {
entries[i].resize()
}
completion(Timeline(entries: entries, policy: .atEnd))
}

View File

@@ -8,21 +8,20 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable {
struct AlbumQuery: EntityQuery {
func entities(for identifiers: [Album.ID]) async throws -> [Album] {
return await suggestedEntities().filter {
// use cached albums to search
var albums = (try? await AlbumCache.shared.getAlbums()) ?? []
albums.insert(NO_ALBUM, at: 0)
return albums.filter {
identifiers.contains($0.id)
}
}
func suggestedEntities() async -> [Album] {
let albums = (try? await AlbumCache.shared.getAlbums()) ?? []
func suggestedEntities() async throws -> [Album] {
var albums = (try? await AlbumCache.shared.getAlbums(refresh: true)) ?? []
albums.insert(NO_ALBUM, at: 0)
let options =
[
NONE,
FAVORITES,
] + albums
return options
return albums
}
}
@@ -36,6 +35,8 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable {
}
}
let NO_ALBUM = Album(id: "NONE", albumName: "None")
struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Select Album" }
static var description: IntentDescription {
@@ -44,7 +45,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
@Parameter(title: "Album")
var album: Album?
@Parameter(title: "Show Album Name", default: false)
var showAlbumName: Bool
}
@@ -53,7 +54,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
struct ImmichRandomProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> ImageEntry {
ImageEntry(date: Date())
ImageEntry(date: Date(), image: nil)
}
func snapshot(
@@ -62,26 +63,30 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
) async
-> ImageEntry
{
let cacheKey = "random_none_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else {
return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
.first!
return ImageEntry(date: Date(), image: nil, error: .noLogin)
}
guard
let randomImage = try? await api.fetchSearchResults(
with: Album.NONE.filter
).first,
let entry = try? await ImageEntry.build(
with: SearchFilters(size: 1)
).first
else {
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
}
guard
var entry = try? await buildEntry(
api: api,
asset: randomImage,
dateOffset: 0
)
else {
return ImageEntry.handleError(for: cacheKey).entries.first!
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
}
entry.resize()
return entry
}
@@ -94,41 +99,50 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
var entries: [ImageEntry] = []
let now = Date()
// nil if album is NONE or nil
let album = configuration.album ?? Album.NONE
let albumName = album.isVirtual ? nil : album.albumName
let cacheKey = "random_\(album.id)_\(context.family.rawValue)"
// If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else {
return ImageEntry.handleError(for: cacheKey, error: .noLogin)
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
return Timeline(entries: entries, policy: .atEnd)
}
// build entries
// this must be a do/catch since we need to
// distinguish between a network fail and an empty search
do {
let search = try await generateRandomEntries(
// nil if album is NONE or nil
let albumId =
configuration.album?.id != "NONE" ? configuration.album?.id : nil
var albumName: String? = albumId != nil ? configuration.album?.albumName : nil
if albumId != nil {
// make sure the album exists on server, otherwise show error
guard let albums = try? await api.fetchAlbums() else {
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
return Timeline(entries: entries, policy: .atEnd)
}
if !albums.contains(where: { $0.id == albumId }) {
entries.append(ImageEntry(date: now, image: nil, error: .albumNotFound))
return Timeline(entries: entries, policy: .atEnd)
}
}
entries.append(
contentsOf: (try? await generateRandomEntries(
api: api,
now: now,
count: 12,
filter: album.filter,
albumId: albumId,
subtitle: configuration.showAlbumName ? albumName : nil
)
))
?? []
)
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
}
entries.append(contentsOf: search)
} catch {
return ImageEntry.handleError(for: cacheKey)
// If we fail to fetch images, we still want to add an entry with a nil image and an error
if entries.count == 0 {
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
}
// cache the last image
try? entries.last!.cache(for: cacheKey)
// Resize all images to something that can be stored by iOS
for i in entries.indices {
entries[i].resize()
}
return Timeline(entries: entries, policy: .atEnd)
}

View File

@@ -0,0 +1 @@
{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":["<all>"]},"commands":{"<all>":{"tool":"phony","inputs":["<WorkspaceHeaderMapVFSFilesWritten>"],"outputs":["<all>"]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":["<WorkspaceHeaderMapVFSFilesWritten>"]}}}

View File

@@ -18,7 +18,7 @@ const String kSecuredPinCode = "secured_pin_code";
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 256;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;
// Widget keys
@@ -28,8 +28,7 @@ const String appShareGroupId = "group.app.immich.share";
// add widget identifiers here for new widgets
// these are used to force a widget refresh
// (iOSName, androidFQDN)
const List<(String, String)> kWidgetNames = [
('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'),
('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'),
const List<String> kWidgetNames = [
'com.immich.widget.random',
'com.immich.widget.memory',
];

View File

@@ -11,7 +11,7 @@ enum AlbumUserRole {
}
// Model for an album stored in the server
class RemoteAlbum {
class Album {
final String id;
final String name;
final String ownerId;
@@ -24,7 +24,7 @@ class RemoteAlbum {
final int assetCount;
final String ownerName;
const RemoteAlbum({
const Album({
required this.id,
required this.name,
required this.ownerId,
@@ -57,7 +57,7 @@ class RemoteAlbum {
@override
bool operator ==(Object other) {
if (other is! RemoteAlbum) return false;
if (other is! Album) return false;
if (identical(this, other)) return true;
return id == other.id &&
name == other.name &&
@@ -86,32 +86,4 @@ class RemoteAlbum {
assetCount.hashCode ^
ownerName.hashCode;
}
RemoteAlbum copyWith({
String? id,
String? name,
String? ownerId,
String? description,
DateTime? createdAt,
DateTime? updatedAt,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
int? assetCount,
String? ownerName,
}) {
return RemoteAlbum(
id: id ?? this.id,
name: name ?? this.name,
ownerId: ownerId ?? this.ownerId,
description: description ?? this.description,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId,
isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled,
order: order ?? this.order,
assetCount: assetCount ?? this.assetCount,
ownerName: ownerName ?? this.ownerName,
);
}
}

View File

@@ -25,7 +25,6 @@ sealed class BaseAsset {
final int? height;
final int? durationInSeconds;
final bool isFavorite;
final String? livePhotoVideoId;
const BaseAsset({
required this.name,
@@ -37,20 +36,16 @@ sealed class BaseAsset {
this.height,
this.durationInSeconds,
this.isFavorite = false,
this.livePhotoVideoId,
});
bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => livePhotoVideoId != null;
Duration get duration {
final durationInSeconds = this.durationInSeconds;
if (durationInSeconds != null) {
return Duration(seconds: durationInSeconds);
double? get aspectRatio {
if (width != null && height != null && height! > 0) {
return width! / height!;
}
return const Duration();
return null;
}
bool get hasRemote =>

View File

@@ -3,7 +3,6 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset {
final String id;
final String? remoteId;
final int orientation;
const LocalAsset({
required this.id,
@@ -17,8 +16,6 @@ class LocalAsset extends BaseAsset {
super.height,
super.durationInSeconds,
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
});
@override
@@ -41,21 +38,18 @@ class LocalAsset extends BaseAsset {
durationInSeconds: ${durationInSeconds ?? "<NA>"},
remoteId: ${remoteId ?? "<NA>"}
isFavorite: $isFavorite,
orientation: $orientation,
}''';
}
// Not checking for remoteId here
@override
bool operator ==(Object other) {
if (other is! LocalAsset) return false;
if (identical(this, other)) return true;
return super == other && id == other.id && orientation == other.orientation;
return super == other && id == other.id && remoteId == other.remoteId;
}
@override
int get hashCode =>
super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode;
LocalAsset copyWith({
String? id,
@@ -69,7 +63,6 @@ class LocalAsset extends BaseAsset {
int? height,
int? durationInSeconds,
bool? isFavorite,
int? orientation,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -83,7 +76,6 @@ class LocalAsset extends BaseAsset {
height: height ?? this.height,
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
);
}
}

View File

@@ -14,8 +14,6 @@ class RemoteAsset extends BaseAsset {
final String? thumbHash;
final AssetVisibility visibility;
final String ownerId;
final String? stackId;
final int stackCount;
const RemoteAsset({
required this.id,
@@ -32,9 +30,6 @@ class RemoteAsset extends BaseAsset {
super.isFavorite = false,
this.thumbHash,
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.stackId,
this.stackCount = 0,
});
@override
@@ -60,14 +55,9 @@ class RemoteAsset extends BaseAsset {
isFavorite: $isFavorite,
thumbHash: ${thumbHash ?? "<NA>"},
visibility: $visibility,
stackId: ${stackId ?? "<NA>"},
stackCount: $stackCount,
checksum: $checksum,
livePhotoVideoId: ${livePhotoVideoId ?? "<NA>"},
}''';
}
// Not checking for localId here
@override
bool operator ==(Object other) {
if (other is! RemoteAsset) return false;
@@ -75,10 +65,9 @@ class RemoteAsset extends BaseAsset {
return super == other &&
id == other.id &&
ownerId == other.ownerId &&
localId == other.localId &&
thumbHash == other.thumbHash &&
visibility == other.visibility &&
stackId == other.stackId &&
stackCount == other.stackCount;
visibility == other.visibility;
}
@override
@@ -88,9 +77,7 @@ class RemoteAsset extends BaseAsset {
ownerId.hashCode ^
localId.hashCode ^
thumbHash.hashCode ^
visibility.hashCode ^
stackId.hashCode ^
stackCount.hashCode;
visibility.hashCode;
RemoteAsset copyWith({
String? id,
@@ -107,9 +94,6 @@ class RemoteAsset extends BaseAsset {
bool? isFavorite,
String? thumbHash,
AssetVisibility? visibility,
String? livePhotoVideoId,
String? stackId,
int? stackCount,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -126,9 +110,6 @@ class RemoteAsset extends BaseAsset {
isFavorite: isFavorite ?? this.isFavorite,
thumbHash: thumbHash ?? this.thumbHash,
visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
stackCount: stackCount ?? this.stackCount,
);
}
}

View File

@@ -124,21 +124,7 @@ class DriftMemory {
@override
String toString() {
return '''Memory {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: ${deletedAt ?? "<NA>"},
ownerId: $ownerId,
type: $type,
data: $data,
isSaved: $isSaved,
memoryAt: $memoryAt,
seenAt: ${seenAt ?? "<NA>"},
showAt: ${showAt ?? "<NA>"},
hideAt: ${hideAt ?? "<NA>"},
assets: $assets
}''';
return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt, assets: $assets)';
}
@override

View File

@@ -1,8 +1,7 @@
import 'dart:convert';
// TODO: Remove PersonDto once Isar is removed
class PersonDto {
const PersonDto({
class Person {
const Person({
required this.id,
this.birthDate,
required this.isHidden,
@@ -23,7 +22,7 @@ class PersonDto {
return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)';
}
PersonDto copyWith({
Person copyWith({
String? id,
DateTime? birthDate,
bool? isHidden,
@@ -31,7 +30,7 @@ class PersonDto {
String? thumbnailPath,
DateTime? updatedAt,
}) {
return PersonDto(
return Person(
id: id ?? this.id,
birthDate: birthDate ?? this.birthDate,
isHidden: isHidden ?? this.isHidden,
@@ -52,8 +51,8 @@ class PersonDto {
};
}
factory PersonDto.fromMap(Map<String, dynamic> map) {
return PersonDto(
factory Person.fromMap(Map<String, dynamic> map) {
return Person(
id: map['id'] as String,
birthDate: map['birthDate'] != null
? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int)
@@ -69,11 +68,11 @@ class PersonDto {
String toJson() => json.encode(toMap());
factory PersonDto.fromJson(String source) =>
PersonDto.fromMap(json.decode(source) as Map<String, dynamic>);
factory Person.fromJson(String source) =>
Person.fromMap(json.decode(source) as Map<String, dynamic>);
@override
bool operator ==(covariant PersonDto other) {
bool operator ==(covariant Person other) {
if (identical(this, other)) return true;
return other.id == id &&
@@ -94,109 +93,3 @@ class PersonDto {
updatedAt.hashCode;
}
}
// Model for a person stored in the server
class Person {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String name;
final String? faceAssetId;
final String thumbnailPath;
final bool isFavorite;
final bool isHidden;
final String? color;
final DateTime? birthDate;
const Person({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.name,
this.faceAssetId,
required this.thumbnailPath,
required this.isFavorite,
required this.isHidden,
required this.color,
this.birthDate,
});
Person copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
String? name,
String? faceAssetId,
String? thumbnailPath,
bool? isFavorite,
bool? isHidden,
String? color,
DateTime? birthDate,
}) {
return Person(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
faceAssetId: faceAssetId ?? this.faceAssetId,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
isFavorite: isFavorite ?? this.isFavorite,
isHidden: isHidden ?? this.isHidden,
color: color ?? this.color,
birthDate: birthDate ?? this.birthDate,
);
}
@override
String toString() {
return '''Person {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
ownerId: $ownerId,
name: $name,
faceAssetId: ${faceAssetId ?? "<NA>"},
thumbnailPath: $thumbnailPath,
isFavorite: $isFavorite,
isHidden: $isHidden,
color: ${color ?? "<NA>"},
birthDate: ${birthDate ?? "<NA>"}
}''';
}
@override
bool operator ==(covariant Person other) {
if (identical(this, other)) return true;
return other.id == id &&
other.createdAt == createdAt &&
other.updatedAt == updatedAt &&
other.ownerId == ownerId &&
other.name == name &&
other.faceAssetId == faceAssetId &&
other.thumbnailPath == thumbnailPath &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.color == color &&
other.birthDate == birthDate;
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
ownerId.hashCode ^
name.hashCode ^
faceAssetId.hashCode ^
thumbnailPath.hashCode ^
isFavorite.hashCode ^
isHidden.hashCode ^
color.hashCode ^
birthDate.hashCode;
}
}

View File

@@ -1,38 +0,0 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult {
final List<BaseAsset> assets;
final int? nextPage;
const SearchResult({
required this.assets,
this.nextPage,
});
int get totalAssets => assets.length;
SearchResult copyWith({
List<BaseAsset>? assets,
int? nextPage,
}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
);
}
@override
String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)';
@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}

View File

@@ -5,7 +5,6 @@ enum Setting<T> {
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
showStorageIndicator<bool>(StoreKey.storageIndicator, true),
loadOriginal<bool>(StoreKey.loadOriginal, false),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
;

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
// Model for a stack stored in the server
class Stack {
final String id;
@@ -30,15 +32,34 @@ class Stack {
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'createdAt': createdAt.millisecondsSinceEpoch,
'updatedAt': updatedAt.millisecondsSinceEpoch,
'ownerId': ownerId,
'primaryAssetId': primaryAssetId,
};
}
factory Stack.fromMap(Map<String, dynamic> map) {
return Stack(
id: map['id'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int),
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int),
ownerId: map['ownerId'] as String,
primaryAssetId: map['primaryAssetId'] as String,
);
}
String toJson() => json.encode(toMap());
factory Stack.fromJson(String source) =>
Stack.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return '''Stack {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
ownerId: $ownerId,
primaryAssetId: $primaryAssetId
}''';
return 'Stack(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, ownerId: $ownerId, primaryAssetId: $primaryAssetId)';
}
@override
@@ -61,27 +82,3 @@ class Stack {
primaryAssetId.hashCode;
}
}
class StackResponse {
final String id;
final String primaryAssetId;
final List<String> assetIds;
const StackResponse({
required this.id,
required this.primaryAssetId,
required this.assetIds,
});
@override
bool operator ==(covariant StackResponse other) {
if (identical(this, other)) return true;
return other.id == id &&
other.primaryAssetId == primaryAssetId &&
other.assetIds == assetIds;
}
@override
int get hashCode => id.hashCode ^ primaryAssetId.hashCode ^ assetIds.hashCode;
}

View File

@@ -68,9 +68,7 @@ enum StoreKey<T> {
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),
betaTimeline<bool>._(1002);
photoManagerCustomFilter<bool>._(1000);
const StoreKey._(this.id);
final int id;

View File

@@ -1,5 +1,3 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
enum GroupAssetsBy {
day,
month,
@@ -40,7 +38,3 @@ class TimeBucket extends Bucket {
@override
int get hashCode => super.hashCode ^ date.hashCode;
}
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}

View File

@@ -1,12 +1,5 @@
import 'dart:ui';
enum UserMetadataKey {
// do not change this order!
onboarding,
preferences,
license,
}
enum AvatarColor {
// do not change this order or reuse indices for other purposes, adding is OK
primary("primary"),
@@ -38,45 +31,7 @@ enum AvatarColor {
};
}
class Onboarding {
final bool isOnboarded;
const Onboarding({required this.isOnboarded});
Onboarding copyWith({bool? isOnboarded}) {
return Onboarding(isOnboarded: isOnboarded ?? this.isOnboarded);
}
Map<String, Object?> toMap() {
final onboarding = <String, Object?>{};
onboarding["isOnboarded"] = isOnboarded;
return onboarding;
}
factory Onboarding.fromMap(Map<String, Object?> map) {
return Onboarding(isOnboarded: map["isOnboarded"] as bool);
}
@override
String toString() {
return '''Onboarding {
isOnboarded: $isOnboarded,
}''';
}
@override
bool operator ==(covariant Onboarding other) {
if (identical(this, other)) return true;
return isOnboarded == other.isOnboarded;
}
@override
int get hashCode => isOnboarded.hashCode;
}
// TODO: wait to be overwritten
class Preferences {
class UserPreferences {
final bool foldersEnabled;
final bool memoriesEnabled;
final bool peopleEnabled;
@@ -86,7 +41,7 @@ class Preferences {
final AvatarColor userAvatarColor;
final bool showSupportBadge;
const Preferences({
const UserPreferences({
this.foldersEnabled = false,
this.memoriesEnabled = true,
this.peopleEnabled = true,
@@ -97,7 +52,7 @@ class Preferences {
this.showSupportBadge = true,
});
Preferences copyWith({
UserPreferences copyWith({
bool? foldersEnabled,
bool? memoriesEnabled,
bool? peopleEnabled,
@@ -107,7 +62,7 @@ class Preferences {
AvatarColor? userAvatarColor,
bool? showSupportBadge,
}) {
return Preferences(
return UserPreferences(
foldersEnabled: foldersEnabled ?? this.foldersEnabled,
memoriesEnabled: memoriesEnabled ?? this.memoriesEnabled,
peopleEnabled: peopleEnabled ?? this.peopleEnabled,
@@ -132,8 +87,8 @@ class Preferences {
return preferences;
}
factory Preferences.fromMap(Map<String, Object?> map) {
return Preferences(
factory UserPreferences.fromMap(Map<String, Object?> map) {
return UserPreferences(
foldersEnabled: map["folders-Enabled"] as bool? ?? false,
memoriesEnabled: map["memories-Enabled"] as bool? ?? true,
peopleEnabled: map["people-Enabled"] as bool? ?? true,
@@ -147,173 +102,4 @@ class Preferences {
showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true,
);
}
@override
String toString() {
return '''Preferences: {
foldersEnabled: $foldersEnabled,
memoriesEnabled: $memoriesEnabled,
peopleEnabled: $peopleEnabled,
ratingsEnabled: $ratingsEnabled,
sharedLinksEnabled: $sharedLinksEnabled,
tagsEnabled: $tagsEnabled,
userAvatarColor: $userAvatarColor,
showSupportBadge: $showSupportBadge,
}''';
}
@override
bool operator ==(covariant Preferences other) {
if (identical(this, other)) return true;
return other.foldersEnabled == foldersEnabled &&
other.memoriesEnabled == memoriesEnabled &&
other.peopleEnabled == peopleEnabled &&
other.ratingsEnabled == ratingsEnabled &&
other.sharedLinksEnabled == sharedLinksEnabled &&
other.tagsEnabled == tagsEnabled &&
other.userAvatarColor == userAvatarColor &&
other.showSupportBadge == showSupportBadge;
}
@override
int get hashCode {
return foldersEnabled.hashCode ^
memoriesEnabled.hashCode ^
peopleEnabled.hashCode ^
ratingsEnabled.hashCode ^
sharedLinksEnabled.hashCode ^
tagsEnabled.hashCode ^
userAvatarColor.hashCode ^
showSupportBadge.hashCode;
}
}
class License {
final DateTime activatedAt;
final String activationKey;
final String licenseKey;
const License({
required this.activatedAt,
required this.activationKey,
required this.licenseKey,
});
License copyWith({
DateTime? activatedAt,
String? activationKey,
String? licenseKey,
}) {
return License(
activatedAt: activatedAt ?? this.activatedAt,
activationKey: activationKey ?? this.activationKey,
licenseKey: licenseKey ?? this.licenseKey,
);
}
Map<String, Object?> toMap() {
final license = <String, Object?>{};
license["activatedAt"] = activatedAt;
license["activationKey"] = activationKey;
license["licenseKey"] = licenseKey;
return license;
}
factory License.fromMap(Map<String, Object?> map) {
return License(
activatedAt: map["activatedAt"] as DateTime,
activationKey: map["activationKey"] as String,
licenseKey: map["licenseKey"] as String,
);
}
@override
String toString() {
return '''License {
activatedAt: $activatedAt,
activationKey: $activationKey,
licenseKey: $licenseKey,
}''';
}
@override
bool operator ==(covariant License other) {
if (identical(this, other)) return true;
return activatedAt == other.activatedAt &&
activationKey == other.activationKey &&
licenseKey == other.licenseKey;
}
@override
int get hashCode =>
activatedAt.hashCode ^ activationKey.hashCode ^ licenseKey.hashCode;
}
// Model for a user metadata stored in the server
class UserMetadata {
final String userId;
final UserMetadataKey key;
final Onboarding? onboarding;
final Preferences? preferences;
final License? license;
const UserMetadata({
required this.userId,
required this.key,
this.onboarding,
this.preferences,
this.license,
}) : assert(
onboarding != null || preferences != null || license != null,
'One of onboarding, preferences and license must be provided',
);
UserMetadata copyWith({
String? userId,
UserMetadataKey? key,
Onboarding? onboarding,
Preferences? preferences,
License? license,
}) {
return UserMetadata(
userId: userId ?? this.userId,
key: key ?? this.key,
onboarding: onboarding ?? this.onboarding,
preferences: preferences ?? this.preferences,
license: license ?? this.license,
);
}
@override
String toString() {
return '''UserMetadata: {
userId: $userId,
key: $key,
onboarding: ${onboarding ?? "<NA>"},
preferences: ${preferences ?? "<NA>"},
license: ${license ?? "<NA>"},
}''';
}
@override
bool operator ==(covariant UserMetadata other) {
if (identical(this, other)) return true;
return other.userId == userId &&
other.key == key &&
other.onboarding == onboarding &&
other.preferences == preferences &&
other.license == license;
}
@override
int get hashCode {
return userId.hashCode ^
key.hashCode ^
onboarding.hashCode ^
preferences.hashCode ^
license.hashCode;
}
}

View File

@@ -2,20 +2,16 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:platform/platform.dart';
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
final Platform _platform;
const AssetService({
required RemoteAssetRepository remoteAssetRepository,
required DriftLocalAssetRepository localAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository,
_localAssetRepository = localAssetRepository,
_platform = const LocalPlatform();
_localAssetRepository = localAssetRepository;
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
@@ -24,56 +20,11 @@ class AssetService {
: _remoteAssetRepository.watchAsset(id);
}
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
if (asset.stackId == null) {
return [];
}
return _remoteAssetRepository.getStackChildren(asset).then((assets) {
// Include the primary asset in the stack as the first item
return [asset, ...assets];
});
}
Future<ExifInfo?> getExif(BaseAsset asset) async {
if (!asset.hasRemote) {
if (asset is LocalAsset || asset is! RemoteAsset) {
return null;
}
final id =
asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
return _remoteAssetRepository.getExif(id);
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = exif?.width ?? asset.width?.toDouble();
height = exif?.height ?? asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = _platform.isAndroid &&
(asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
}
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
}
return 1.0;
}
Future<List<(String, String)>> getPlaces() {
return _remoteAssetRepository.getPlaces();
return _remoteAssetRepository.getExif(asset.id);
}
}

View File

@@ -61,7 +61,7 @@ class HashService {
final toHash = <_AssetToPath>[];
for (final asset in assetsToHash) {
final file = await _storageRepository.getFileForAsset(asset.id);
final file = await _storageRepository.getFileForAsset(asset);
if (file == null) {
continue;
}

View File

@@ -1,17 +0,0 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
class LocalAlbumService {
final DriftLocalAlbumRepository _repository;
const LocalAlbumService(this._repository);
Future<List<LocalAlbum>> getAll() {
return _repository.getAll();
}
Future<LocalAsset?> getThumbnail(String albumId) {
return _repository.getThumbnail(albumId);
}
}

View File

@@ -359,7 +359,6 @@ extension on Iterable<PlatformAsset> {
width: e.width,
height: e.height,
durationInSeconds: e.durationInSeconds,
orientation: e.orientation,
),
).toList();
}

View File

@@ -1,43 +1,33 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository;
final DriftAlbumApiRepository _albumApiRepository;
const RemoteAlbumService(this._repository, this._albumApiRepository);
const RemoteAlbumService(this._repository);
Stream<RemoteAlbum?> watchAlbum(String albumId) {
return _repository.watchAlbum(albumId);
}
Future<List<RemoteAlbum>> getAll() {
Future<List<Album>> getAll() {
return _repository.getAll();
}
List<RemoteAlbum> sortAlbums(
List<RemoteAlbum> albums,
List<Album> sortAlbums(
List<Album> albums,
RemoteAlbumSortMode sortMode, {
bool isReverse = false,
}) {
return sortMode.sortFn(albums, isReverse);
}
List<RemoteAlbum> searchAlbums(
List<RemoteAlbum> albums,
List<Album> searchAlbums(
List<Album> albums,
String query,
String? userId, [
QuickFilterMode filterMode = QuickFilterMode.all,
]) {
final lowerQuery = query.toLowerCase();
List<RemoteAlbum> filtered = albums;
List<Album> filtered = albums;
// Apply text search filter
if (query.isNotEmpty) {
@@ -67,84 +57,4 @@ class RemoteAlbumService {
return filtered;
}
Future<RemoteAlbum> createAlbum({
required String title,
required List<String> assetIds,
String? description,
}) async {
final album = await _albumApiRepository.createDriftAlbum(
title,
description: description,
assetIds: assetIds,
);
await _repository.create(album, assetIds);
return album;
}
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
}) async {
final updatedAlbum = await _albumApiRepository.updateAlbum(
albumId,
name: name,
description: description,
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
);
// Update the local database
await _repository.update(updatedAlbum);
return updatedAlbum;
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
return _repository.getDateRange(albumId);
}
Future<List<UserDto>> getSharedUsers(String albumId) {
return _repository.getSharedUsers(albumId);
}
Future<List<RemoteAsset>> getAssets(String albumId) {
return _repository.getAssets(albumId);
}
Future<int> addAssets({
required String albumId,
required List<String> assetIds,
}) async {
final album = await _albumApiRepository.addAssets(
albumId,
assetIds,
);
await _repository.addAssets(albumId, album.added);
return album.added.length;
}
Future<void> deleteAlbum(String albumId) async {
await _albumApiRepository.deleteAlbum(albumId);
await _repository.deleteAlbum(albumId);
}
Future<void> addUsers({
required String albumId,
required List<String> userIds,
}) async {
await _albumApiRepository.addUsers(albumId, userIds);
return _repository.addUsers(albumId, userIds);
}
}

View File

@@ -1,92 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
class SearchService {
final _log = Logger("SearchService");
final SearchApiRepository _searchApiRepository;
SearchService(this._searchApiRepository);
Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) async {
try {
return await _searchApiRepository.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
} catch (e) {
_log.warning("Failed to get search suggestions", e);
}
return [];
}
Future<SearchResult?> search(SearchFilter filter, int page) async {
try {
final response = await _searchApiRepository.search(filter, page);
if (response == null || response.assets.items.isEmpty) {
return null;
}
return SearchResult(
assets: response.assets.items.map((e) => e.toDto()).toList(),
nextPage: response.assets.nextPage?.toInt(),
);
} catch (error, stackTrace) {
_log.severe("Failed to search for assets", error, stackTrace);
}
return null;
}
}
extension on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
);
}
}
extension on AssetTypeEnum {
AssetType toAssetType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}

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