Compare commits

...

71 Commits

Author SHA1 Message Date
Alex Tran
fc194021a4 Pump server version 2022-09-22 11:38:50 -05:00
bo0tzz
39f8ca3bf1 Only run scheduled geocoding task once per day (#730) 2022-09-21 07:17:59 -05:00
Alex
7a807f7216 Update README.md 2022-09-19 16:04:22 -05:00
Alex
bedfb51b1c Add demo URL 2022-09-19 16:00:51 -05:00
Alex
b2afb95c19 Create codeql-analysis.yml 2022-09-19 14:03:49 -05:00
Alex
10239161fd fix(mobile): app crash when there is no object detection result on search page (#725)
* fix(mobile): app crash when there is no data for object detection

* Up version for release

* Up version for release
2022-09-19 11:03:51 -05:00
Alex
242f10952d fix(server): query only image (#724) 2022-09-19 10:48:44 -05:00
Alex Tran
e997bd371b Up server version 2022-09-18 21:44:55 -05:00
Alex
400167f4ef fix(server): sanitization error that crash the server (#721) 2022-09-18 21:44:13 -05:00
Alex
572f6d833d Up mobile version and update deprecated api 2022-09-18 16:11:30 -05:00
Alex
2e06be5155 Up mobile version and update deprecated api 2022-09-18 16:11:24 -05:00
Alex Tran
62121470a8 Up server version 2022-09-18 15:37:10 -05:00
Alex
e3ccc3ee6b feat(server): sanitized path for asset creation process to avoid security risk (#717)
* feat(server): sanitized path for asset creation process to avoid security risk

* Sanitize resize path
2022-09-18 15:16:53 -05:00
Alex
ece94f6bdc fix(server): correct user permission to update user info (#716) 2022-09-18 09:27:06 -05:00
Jamie Slome
03fc0703c0 Create SECURITY.md (#712) 2022-09-17 13:07:12 -05:00
Alex
0d13b25f56 feat(web): Update to latest version of SvelteKit (#705) 2022-09-16 23:13:22 -05:00
Alex
75c2067836 feat(web) Remove fetching fonts from GoogleFonts (#703) 2022-09-16 17:23:31 -05:00
Alex
824da6a07b Up server version 2022-09-16 16:55:04 -05:00
Alex
2c2ea24dc4 test(web) Add tests for asset repository (#680)
* Added back tests for asset repository

* Added more tests

* Added asset count test
2022-09-16 16:47:45 -05:00
Alex
47b73a5b64 fix(mobile): Fixed iOS 16 overflow cache and memory leaked in gallery viewer. (#700) 2022-09-16 16:46:23 -05:00
bo0tzz
6b3f8e548d Merge pull request #699 from JaCoB1123/patch-1
Fix spelling of Proxmox in Readme
2022-09-15 23:07:00 +02:00
Jan Bader
0ea483f901 Fix spelling of Proxmox in Readme 2022-09-15 23:05:15 +02:00
Jonas Janz
97aed8ef23 fix(nginx): revert nginx image to support arm/v7 (#692) 2022-09-14 13:36:29 -05:00
Alex
0ee3fe9157 Update install.sh to use latest released tag 2022-09-14 11:07:37 -05:00
Alex
434770155f Up version for release 2022-09-14 10:27:34 -05:00
Alex
7e8bf94543 fix/cache read write error ios16 (#691)
* Fix(mobile) cache read/write issue, cannot load image on ios16

* Update
2022-09-14 10:18:25 -05:00
Zack Pollard
8d8944705c Merge pull request #690 from beune/fix-typo
Fix typo
2022-09-14 13:16:47 +01:00
Pim Beune
7c9c1a5169 Fix typo 2022-09-14 13:53:34 +02:00
Jonas Janz
1a6c16d8ea breaking(setup): use non-root image for immich-proxy (#651)
* feat(nginx): use non-root container for immich-proxy

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>

* re-add test env

* feat(nginx): add correct port for staging

* add the new port to the default docker-compose.yml

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-09-13 21:50:10 -05:00
Alex
ccf792f9d3 fix(server): mismatch createdAt value in table and table (#688) 2022-09-13 20:12:42 -05:00
Fynn Petersen-Frey
789bc8563c fix Android BackgroundServiceStartNotAllowedException (#687) 2022-09-13 20:12:31 -05:00
Manuel
99a50f70dd readme: add app store links (#689) 2022-09-13 18:23:27 -05:00
Alex Tran
9bef411056 Up server version: 2022-09-13 12:14:36 -05:00
Alex
e79e92c60f Added Log level to background service (#685) 2022-09-13 12:09:57 -05:00
Alex
858ad43d3b fix(server): harden inserting process, self-healing timestamp info on bad timestamp (#682)
* fix(server): harden inserting process, self-healing timestamp info
2022-09-12 23:35:44 -05:00
Alex
5761765ea7 fix(server): remove album thumbnail when the asset is deleted from the database (#681) 2022-09-12 22:06:52 -05:00
Thanh Pham
6abc733763 fix(web): datetime display and add TZ into environment (#618)
* fix(web): timezone

* doc(): update readme.md

* feat(web): keep using UTC timezone in default

* chore(): update doc and remove debug code

* chore(): update readme.md

* Move timezone into to .env.example

* Run prettier check

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-12 14:40:18 -05:00
Alex Tran
4271e24e59 Up version for release 2022-09-11 16:05:53 -05:00
Alex
9e4ed2214b fix(web): incorrect shared album count (#677) 2022-09-11 10:07:04 -05:00
Alex
011332e509 fix(mobile) memory leaked causes app to crash when swiping (#673)
* Dispose image provider when swiping away from the asset
2022-09-11 09:56:26 -05:00
Alex
5403ef4d84 Fix(mobile) oversize play button (#672) 2022-09-11 00:25:04 -05:00
Alex Tran
31739aca02 Up version for release 2022-09-10 11:58:59 -05:00
Thanh Pham
8f2e7b6f65 fix(server): loop on checksum generation (#662) 2022-09-10 11:52:39 -05:00
Brett Profitt
4ed647c43d fix(install): Fix checking for docker compose. (#663) 2022-09-10 11:48:50 -05:00
Alex
f88ff4fb5c fix(mobile): background backup not working in release mode (#664) 2022-09-10 11:46:51 -05:00
Alex Tran
cc4881d633 Up version for release 2022-09-09 23:23:37 -05:00
Alex
d856b35afc feat(web) add scrollbar with timeline information (#658)
- Implement a scrollbar with a timeline similar to Google Photos
- The scrollbar can also be dragged
2022-09-09 15:55:20 -05:00
Jaime Baez
b6d025da09 Fix Notification components possible memory leaks (#650)
Dispose subscriptions and timeouts when
the components are removed from the DOM
2022-09-09 07:40:35 -05:00
Jaime Baez
cc79ff1ca3 Merge pull request #642 from immich-app/add/ci-web-checks
Add web test / check commands and workflow to run in CI
2022-09-08 19:12:39 +02:00
Jaime Baez
131aa2b6be Add command to test/check code in dev-setup docs 2022-09-08 17:54:45 +02:00
Jaime Baez
02a6b73122 Add web-unit-test workflow to run in CI 2022-09-08 17:44:13 +02:00
Jaime Baez
d87366c095 Add dev-setup documentation 2022-09-08 17:41:24 +02:00
Jaime Baez
4f7a3afbfc Fix web lint issues 2022-09-08 17:30:49 +02:00
Jaime Baez
6725954b70 Add web check / lint npm commands
`svelte-check` returns some "hints" that can be ignored since some
are not true and others are not relevant.
2022-09-08 17:17:15 +02:00
Fynn Petersen-Frey
4fe535e5e8 improve Android background service reliability (#603)
This change greatly reduces the chance that a backup is not performed
when a new photo/video is made.
Instead of combining the change trigger and additonal constraints (wifi
or charging) into a single worker, these aspects are now separated.
Thus, it is now reliably possible to take pictures while the wifi
constraint is not satisfied and upload them hours/days later once
connected to wifi without taking a new photo.
As a positive side effect, this simplifies the error/retry handling
by directly leveraging Android's WorkManager without workarounds.
The separation also allows to notify the currently running BackupWorker
that new assets were added while backing up other assets to also upload
those newly added assets.
Further, a new tiny service checks if the app is killed, to reschedule
the content change worker and allow to detect the first new photo.
Bonus: The home screen now shows backup as enabled if background backup
is active.

* use separate worker/task for listening on changed/added assets
* use separate worker/task for performing the backup
* content observer worker enqueues backup worker on each new asset
* wifi/charging constraints only apply to backup worker
* backupworker is notified of assets added while running to re-run
* new service to catch app being killed to workaround WorkManager issue
2022-09-08 08:36:08 -05:00
Jaime Baez
aed94bfc4c Format web code with prettier
Added `.md` and `.json` to .prettierignore
2022-09-08 12:53:09 +02:00
Jaime Baez
de996c0a81 Merge pull request #612 from immich-app/add/web-ui-tests-setup
Add web UI components tests setup

@alextran1502 I'll get this merged so I can add CI checks for the web as well. Let me know if you have any questions 😃
2022-09-08 11:24:08 +02:00
Jaime Baez
1a39aa4da5 Merge pull request #633 from immich-app/fix/server-lint-errors
Add all server checks to CI - fix lint issues
2022-09-08 11:12:31 +02:00
Jaime Baez
1f4ba73da7 Add all server checks to CI - fix lint issues
CI will now run linter, type-checks and tests for the server.

All the lint issues have been fixed.
2022-09-08 11:07:27 +02:00
Alex Tran
836b174d33 Better styling for count info 2022-09-07 21:19:24 -05:00
Alex Tran
853a65aef1 Up version for release 2022-09-07 15:26:29 -05:00
Alex
566039b93f feat(web): add asset and album count info (#623)
* Get asset and album count

* Generate APIs

* Added asset count for each type

* Added api on the web

* Added info button for asset and album count to trigger getting info on hover

* Remove websocket event from photo page
2022-09-07 15:16:18 -05:00
bo0tzz
18a7ff8726 Remove empty translations (#620) 2022-09-07 14:41:44 -05:00
Thanh Pham
6ffdf167fe fix(web): detail panel overflow-x (#615) 2022-09-07 13:20:44 -05:00
Jaime Baez
6b702b13e4 Rename albums BLoC (.bloc.ts convention)
By convention now it's `album.bloc.ts`
2022-09-07 16:04:50 +02:00
Jaime Baez
f476bd985b Add AlbumCard UI tests
- add libraries for component UI testing
- implement AlbumCard UI tests
2022-09-07 16:00:57 +02:00
Alex
92c4f0598b fix(mobile): search page crashes the app on some Android models (#610) 2022-09-07 06:45:26 -05:00
Alex
a337402124 fix(web): stop showing version announcement on first run of a new web instance (#609) 2022-09-07 06:38:29 -05:00
dependabot[bot]
209e6332b3 Bump actions/checkout from 2 to 3 (#604)
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-07 06:08:44 -05:00
Jaime Baez
645bd8a109 Add web test setup (#597)
* Extract logic from Albums page

- move "albums" page logic to `albums-bloc`
- add types to AlbumCard custom events

* Implement some album-bloc unit-tests

- add libraries for testing
- add album factory
- changes in albums-bloc API

* Add rest of albums-bloc test

Cleanup and remove console logs

* Refactor `isShowContextMenu` writable to derived
2022-09-07 05:20:19 -05:00
Daniel Weaver
9a471d80f7 Update README.md (#599)
Adding a note to the installation section about reverse proxies being a cause for issues when uploading large files.
2022-09-06 16:05:38 -05:00
195 changed files with 16905 additions and 2884 deletions

74
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '20 13 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View File

@@ -13,18 +13,29 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Run Immich Server 2E2 Test
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
unit-tests:
name: Run unit test suites
server-unit-tests:
name: Run server unit test suites and checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Run tests
run: cd server && npm install && npm run test
run: cd server && npm ci && npm run check:all
web-unit-tests:
name: Run web unit test suites and checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run tests
run: cd web && npm ci && npm run check:all

View File

@@ -23,11 +23,29 @@
<br/>
</p>
## Demo
You can access the web demo at https://demo.immich.app
For the mobile app, you can use https://demo.immich.app/api for the `Server Endpoint URL`
```
The credential
email: demo@immich.app
password: demo
```
```
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
## Content
- [Features](#features)
- [Screenshots](#screenshots)
- [Installation](#installation)
- [Mobile App](#-mobile-app)
- [Update](#update)
- [Mobile App](#mobile-app)
- [Development](#development)
- [Support](#support)
- [Known Issues](#known-issues)
@@ -97,6 +115,8 @@ There are several services that compose Immich:
# Installation
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
## Testing One-step installation (not recommended for production)
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
@@ -144,6 +164,7 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
* [Optional] Populate Mapbox value to use reverse geocoding.
* [Optional] Populate `TZ` as your timezone, default is `Etc/UTC`.
### Step 3 - Start the containers
@@ -170,13 +191,21 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
<br/>
## Update
If you have installed, you can update the application by navigate to the directory that contains the `docker-compose.yml` file and run the following command:
```bash
docker-compose pull && docker-compose up -d
```
# Mobile app
| F-Droid | Google Play | iOS |
| - | - | - |
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <img src="design/google-play-qr-code.png" width="200" title="Google Play Store"> <p/> | <p align="left"> <img src="design/ios-qr-code.png" width="200" title="Apple App Store"> <p/> |
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
> *The App version might be lagging behind the latest release due to the review process.*
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
<br/>
@@ -225,7 +254,7 @@ Cheers! 🎉
## TensorFlow Build Issue
*This is a known issue for incorrect Promox setup*
*This is a known issue for incorrect Proxmox setup*
TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
@@ -233,7 +262,7 @@ TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX a
more /proc/cpuinfo | grep flags
```
If you are running virtualization in Promox, the VM doesn't have the flag enabled.
If you are running virtualization in Proxmox, the VM doesn't have the flag enabled.
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `alex.tran1502@gmail.com`

32
dev-setup.md Normal file
View File

@@ -0,0 +1,32 @@
# Development Setup
## Lint / format extensions
Setting these in the IDE give a better developer experience auto-formatting code on save and providing instant feedback on lint issues.
### VSCode
Install Prettier, ESLint and Svelte extensions.
in User `settings.json` (`cmd + shift + p` and search for Open User Settings JSON) add the following:
```json
{
"editor.formatOnSave": true,
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2
},
"svelte.enable-ts-plugin": true,
"eslint.validate": ["javascript", "svelte"]
}
```
## Running tests / checks
In both server and web:
`npm run check:all`

View File

@@ -36,6 +36,11 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Log message level - [simple|verbose]
###################################################################################
LOG_LEVEL=simple
###################################################################################
@@ -63,4 +68,12 @@ MAPBOX_KEY=
# Custom message on the login page, should be written in HTML form.
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
PUBLIC_LOGIN_PAGE_MESSAGE=
PUBLIC_LOGIN_PAGE_MESSAGE=
# For correctly display your local time zone on the web, you can set the time zone here.
# Should work fine by default value, however, in case of incorrect timezone in EXIF, this value
# should be set to the correct timezone.
# Command to get timezone:
# - Linux: curl -s http://ip-api.com/json/ | grep -oP '(?<=timezone":")(.*?)(?=")'
# TZ=Etc/UTC

View File

@@ -19,4 +19,4 @@ ENABLE_MAPBOX=false
# WEB
MAPBOX_KEY=
VITE_SERVER_ENDPOINT=http://localhost:2283/api
VITE_SERVER_ENDPOINT=http://localhost:2283/api

View File

@@ -102,8 +102,7 @@ services:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:80
- 2284:443
- 2283:8080
logging:
driver: none
depends_on:

View File

@@ -72,8 +72,7 @@ services:
container_name: immich_proxy
image: altran1502/immich-proxy:staging
ports:
- 2283:80
- 2284:443
- 2283:8080
logging:
driver: none
depends_on:

View File

@@ -47,6 +47,8 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
environment:
- PUBLIC_TZ=${TZ}
restart: always
redis:
@@ -72,7 +74,7 @@ services:
container_name: immich_proxy
image: altran1502/immich-proxy:release
ports:
- 2283:80
- 2283:8080
logging:
driver: none
depends_on:

View File

@@ -2,12 +2,17 @@ echo "Starting Immich installation..."
ip_address=$(hostname -I | awk '{print $1}')
release_version=$(curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" |
grep '"tag_name":' |
sed -E 's/.*"([^"]+)".*/\1/')
RED='\033[0;31m'
GREEN='\032[0;31m'
NC='\033[0m' # No Color
machine_has() {
type "$1" >/dev/null 2>&1
get_release_version() {
curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" | # Get latest release from GitHub api
grep '"tag_name":' | # Get tag line
sed -E 's/.*"([^"]+)".*/\1/' # Pluck JSON value
}
create_immich_directory() {
@@ -17,12 +22,12 @@ create_immich_directory() {
download_docker_compose_file() {
echo "Downloading docker-compose.yml..."
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
}
download_dot_env_file() {
echo "Downloading .env file..."
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
}
populate_upload_location() {
@@ -45,18 +50,21 @@ populate_upload_location() {
start_docker_compose() {
echo "Starting Immich's docker containers"
if machine_has "docker compose"; then {
docker compose up --remove-orphans -d
show_friendly_message
exit 0
}; fi
if machine_has "docker-compose"; then
docker-compose up --remove-orphans -d
if docker compose &>/dev/null; then
docker_bin="docker compose"
elif docker-compose &>/dev/null; then
docker_bin="docker-compose"
else
echo 'Cannot find `docker compose` or `docker-compose`.'
exit 1
fi
if $docker_bin up --remove-orphans -d; then
show_friendly_message
exit 0
else
echo "Could not start. Check for errors above."
exit 1
fi
}
@@ -65,7 +73,7 @@ show_friendly_message() {
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
echo "The backup (or upload) location is $upload_location"
echo "---------------------------------------------------"
echo "If you want to confgure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,

View File

@@ -51,7 +51,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.alextran.immich"
minSdkVersion 21
minSdkVersion 23
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

View File

@@ -12,6 +12,7 @@
</intent-filter>
</activity>
<service android:name=".AppClearedService" android:stopWithTask="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />

View File

@@ -0,0 +1,25 @@
package app.alextran.immich
import android.app.Service
import android.content.Intent
import android.os.IBinder
/**
* Catches the event when either the system or the user kills the app
* (does not apply on force close!)
*/
class AppClearedService() : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
return START_NOT_STICKY;
}
override fun onTaskRemoved(rootIntent: Intent) {
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
stopSelf();
}
}

View File

@@ -1,11 +1,6 @@
package app.alextran.immich
import android.content.Context
import android.net.Uri
import android.content.Intent
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
@@ -44,30 +39,30 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!!
when(call.method) {
"initialize" -> { // needs to be called prior to any other method
"enable" -> {
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply()
.edit()
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true)
}
"start" -> {
"configure" -> {
val args = call.arguments<ArrayList<*>>()!!
val immediate = args.get(0) as Boolean
val keepExisting = args.get(1) as Boolean
val requireUnmeteredNetwork = args.get(2) as Boolean
val requireCharging = args.get(3) as Boolean
val notificationTitle = args.get(4) as String
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply()
BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
result.success(true)
val requireUnmeteredNetwork = args.get(0) as Boolean
val requireCharging = args.get(1) as Boolean
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
result.success(true)
}
"stop" -> {
"disable" -> {
ContentObserverWorker.disable(ctx)
BackupWorker.stopWork(ctx)
result.success(true)
}
"isEnabled" -> {
result.success(BackupWorker.isEnabled(ctx))
result.success(ContentObserverWorker.isEnabled(ctx))
}
"isIgnoringBatteryOptimizations" -> {
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))

View File

@@ -8,17 +8,12 @@ import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.provider.MediaStore
import android.provider.BaseColumns
import android.provider.MediaStore.MediaColumns
import android.provider.MediaStore.Images.Media
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker
import androidx.work.NetworkType
@@ -26,6 +21,7 @@ import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkInfo
import com.google.common.util.concurrent.ListenableFuture
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
@@ -41,14 +37,7 @@ import java.util.concurrent.TimeUnit
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic.
* Called by Android WorkManager when all constraints for the work are met,
* i.e. a new photo/video is created on the device AND battery is not low.
* Optionally, unmetered network (wifi) and charging can be required.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again with the same settings.
* In case the worker is stopped by the system (e.g. constraints like wifi
* are no longer met, or the system needs memory resources for more other
* more important work), the worker is replaced without the constraint on
* changed contents to run again as soon as deemed possible by the system.
* i.e. battery is not low and optionally Wifi and charging are active.
*/
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
@@ -57,14 +46,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var timeBackupStarted: Long = 0L
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork")
val ctx = applicationContext
// enqueue itself once again to continue to listen on added photos/videos
enqueueMoreWork(ctx,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false))
if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx)
@@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
// Create a Notification channel if necessary
createChannel()
}
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
setForegroundAsync(createForegroundInfo(title))
} else {
showBackgroundInfo(title)
}
engine = FlutterEngine(ctx)
@@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
}
override fun onStopped() {
Log.d(TAG, "onStopped")
// called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
@@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private fun stopEngine(result: Result?) {
if (result != null) {
Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(result)
} else if (engine != null && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// stopped by system and this is the first time (content change constraints active)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
}
engine?.destroy()
engine = null
clearBackgroundNotification()
}
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
when (call.method) {
"initialized" ->
"initialized" -> {
timeBackupStarted = SystemClock.uptimeMillis()
backgroundChannel.invokeMethod(
"onAssetsChanged",
null,
@@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean
stopEngine(if(success) Result.success() else Result.retry())
if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// there was an error (e.g. server not available)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
}
}
}
)
}
"updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String
val content = args.get(1) as String
if (isIgnoringBatteryOptimizations) {
setForegroundAsync(createForegroundInfo(title, content))
} else {
showBackgroundInfo(title, content)
}
}
"showError" -> {
@@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
showError(title, content, individualTag)
}
"clearErrorNotifications" -> clearErrorNotifications()
"hasContentChanged" -> {
val lastChange = applicationContext
.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted)
val hasContentChanged = lastChange > timeBackupStarted;
timeBackupStarted = SystemClock.uptimeMillis()
r.success(hasContentChanged)
}
else -> r.notImplemented()
}
}
@@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
notificationManager.cancel(NOTIFICATION_ERROR_ID)
}
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_ID)
}
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
@@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
companion object {
const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
private const val TASK_NAME = "immich/photoListener"
private const val DATA_KEY_UNMETERED = "unmetered"
private const val DATA_KEY_CHARGING = "charging"
private const val DATA_KEY_RETRIES = "retries"
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2
private const val ONE_MINUTE: Long = 60000
private const val ONE_MINUTE = 60000L
/**
* Enqueues the `BackupWorker` to run when all constraints are met.
*
* @param context Android Context
* @param immediate whether to enqueue(replace) the worker without the content change constraint
* @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE`
* @param requireUnmeteredNetwork if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
* @param retries retry count (should be 0 unless an error occured and this is a retry)
* Enqueues the BackupWorker to run once the constraints are met
*/
fun startWork(context: Context,
immediate: Boolean = false,
keepExisting: Boolean = false,
requireUnmeteredNetwork: Boolean = false,
requireCharging: Boolean = false) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply()
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
fun enqueueBackupWorker(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L) {
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
}
private fun enqueueMoreWork(context: Context,
immediate: Boolean = false,
keepExisting: Boolean = false,
requireUnmeteredNetwork: Boolean = false,
requireCharging: Boolean = false,
initialDelayInMs: Long = 0,
retries: Int = 0) {
if (!isEnabled(context)) {
return
/**
* Updates the constraints of an already enqueued BackupWorker
*/
fun updateBackupWorker(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
try {
val wm = WorkManager.getInstance(context)
val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP)
val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS)
if (workInfoList != null) {
for (workInfo in workInfoList) {
if (workInfo.getState() == WorkInfo.State.ENQUEUED) {
val workRequest = buildWorkRequest(requireWifi, requireCharging)
wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest)
Log.d(TAG, "updateBackupWorker updated BackupWorker constraints")
return
}
}
}
Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued")
} catch (e: Exception) {
Log.d(TAG, "updateBackupWorker failed: ${e}")
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (requireUnmeteredNetwork) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging);
if (!immediate) {
constraints
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
}
val inputData = Data.Builder()
.putBoolean(DATA_KEY_CHARGING, requireCharging)
.putBoolean(DATA_KEY_UNMETERED, requireUnmeteredNetwork)
.putInt(DATA_KEY_RETRIES, retries)
.build()
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints.build())
.setInputData(inputData)
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
ONE_MINUTE,
TimeUnit.MILLISECONDS)
.build()
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck)
val result = op.getResult().get()
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun stopWork(context: Context) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
Log.d(TAG, "stopWork: BackupWorker cancelled")
}
/**
@@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
return true
}
/**
* Return true if the user has enabled the background backup service
*/
fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
private fun buildWorkRequest(requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging)
.build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
.setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS)
.build()
return work
}
private val flutterLoader = FlutterLoader()

View File

@@ -0,0 +1,137 @@
package app.alextran.immich
import android.content.Context
import android.os.SystemClock
import android.provider.MediaStore
import android.util.Log
import androidx.work.Constraints
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Operation
import java.util.concurrent.TimeUnit
/**
* Worker executed by Android WorkManager observing content changes (new photos/videos)
*
* Immediately enqueues the BackupWorker when running.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again after each run.
*/
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result {
if (!isEnabled(applicationContext)) {
return Result.failure()
}
if (getTriggeredContentUris().size > 0) {
startBackupWorker(applicationContext, delayMilliseconds = 0)
}
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
return Result.success()
}
companion object {
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
/**
* Enqueues the `ContentObserverWorker`.
*
* @param context Android Context
*/
fun enable(context: Context, immediate: Boolean = false) {
// migration to remove any old active background task
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
Log.d(TAG, "enabled ContentObserverWorker")
if (immediate) {
startBackupWorker(context, delayMilliseconds = 5000)
}
}
/**
* Configures the `BackupWorker` to run when all constraints are met.
*
* @param context Android Context
* @param requireWifi if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
*/
fun configureWork(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
.apply()
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun disable(context: Context) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
Log.d(TAG, "disabled ContentObserverWorker")
}
/**
* Return true if the user has enabled the background backup service
*/
fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
}
/**
* Enqueue and replace the worker without the content trigger but with a short delay
*/
fun workManagerAppClearedWorkaround(context: Context) {
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setInitialDelay(500, TimeUnit.MILLISECONDS)
.build()
WorkManager
.getInstance(context)
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
.getResult()
.get()
Log.d(TAG, "workManagerAppClearedWorkaround")
}
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(5000, TimeUnit.MILLISECONDS)
.build()
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
}
private fun startBackupWorker(context: Context, delayMilliseconds: Long) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
}
}
}
private const val TAG = "ContentObserverWorker"

View File

@@ -2,6 +2,8 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
class MainActivity: FlutterActivity() {
@@ -10,4 +12,14 @@ class MainActivity: FlutterActivity() {
flutterEngine.getPlugins().add(BackgroundServicePlugin())
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
startService(Intent(getBaseContext(), AppClearedService::class.java));
} catch (e: Exception) {
// startService must not be called when app is in background (crashes app)
// there is nothing we can do
}
}
}

View File

@@ -30,8 +30,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 37,
"android.injected.version.name" => "1.27.0",
"android.injected.version.code" => 44,
"android.injected.version.name" => "1.29.4",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1,2 @@
* Fixed remove empty translations
* Fixed search page crashes the app on some Android models

View File

@@ -0,0 +1 @@
* Improve Android background service reliability

View File

@@ -0,0 +1 @@
* Fix background service cannot run in release build

View File

@@ -0,0 +1,2 @@
* Fixed oversize play button on video
* Fixed app crashing when swipe between assets

View File

@@ -0,0 +1,2 @@
* Fixed Android BackgroundServiceStartNotAllowedException
* Restore old cache mechanism

View File

@@ -0,0 +1 @@
* Update deprecated API that causes notification not dismissing after background upload progress finished.

View File

@@ -0,0 +1 @@
* Fixed app crashes when there is no object detection result.

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00043">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.311329">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="71.114285">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.070842">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.949677">
</testcase>

View File

@@ -12,8 +12,6 @@
"album_viewer_appbar_share_leave": "Album verlassen",
"album_viewer_appbar_share_remove": "Entferne vom Album",
"album_viewer_page_share_add_users": "Nutzer hinzufügen",
"asset_list_settings_subtitle": "",
"asset_list_settings_title": "",
"backup_album_selection_page_albums_device": "Alben auf dem Gerät ({})",
"backup_album_selection_page_albums_tap": "Tippen um einzuschließen, doppelt tippen um zu entfernen",
"backup_album_selection_page_assets_scatter": "Elemente können sich über mehrere Alben verteilen. Daher können diese vor der Sicherung eingeschlossen oder ausgeschlossen werden",
@@ -21,26 +19,7 @@
"backup_album_selection_page_selection_info": "Auswahl",
"backup_album_selection_page_total_assets": "Elemente",
"backup_all": "Alle",
"backup_background_service_backup_failed_message": "",
"backup_background_service_connection_failed_message": "",
"backup_background_service_current_upload_notification": "",
"backup_background_service_default_notification": "",
"backup_background_service_error_title": "",
"backup_background_service_in_progress_notification": "",
"backup_background_service_upload_failure_notification": "",
"backup_controller_page_albums": "Gesicherte Alben",
"backup_controller_page_background_battery_info_link": "",
"backup_controller_page_background_battery_info_message": "",
"backup_controller_page_background_battery_info_ok": "",
"backup_controller_page_background_battery_info_title": "",
"backup_controller_page_background_charging": "",
"backup_controller_page_background_configure_error": "",
"backup_controller_page_background_description": "",
"backup_controller_page_background_is_off": "",
"backup_controller_page_background_is_on": "",
"backup_controller_page_background_turn_off": "",
"backup_controller_page_background_turn_on": "",
"backup_controller_page_background_wifi": "",
"backup_controller_page_backup": "Sicherung",
"backup_controller_page_backup_selected": "Ausgewählt: ",
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
@@ -69,19 +48,6 @@
"backup_controller_page_uploading_file_info": "Informationen",
"backup_err_only_album": "Das einzige Album kann nicht entfernt werden",
"backup_info_card_assets": "Elemente",
"cache_settings_album_thumbnails": "",
"cache_settings_clear_cache_button": "",
"cache_settings_clear_cache_button_title": "",
"cache_settings_image_cache_size": "",
"cache_settings_statistics_album": "",
"cache_settings_statistics_assets": "",
"cache_settings_statistics_full": "",
"cache_settings_statistics_shared": "",
"cache_settings_statistics_thumbnail": "",
"cache_settings_statistics_title": "",
"cache_settings_subtitle": "",
"cache_settings_thumbnail_size": "",
"cache_settings_title": "",
"control_bottom_app_bar_delete": "Löschen",
"control_bottom_app_bar_share": "Teilen",
"create_album_page_untitled": "Unbenannt",
@@ -127,13 +93,6 @@
"select_additional_user_for_sharing_page_suggestions": "Vorschläge",
"select_user_for_sharing_page_err_album": "Album konnte nicht erstellt werden",
"select_user_for_sharing_page_share_suggestions": "Suggestions",
"setting_notifications_notify_failures_grace_period": "",
"setting_notifications_notify_hours": "",
"setting_notifications_notify_immediately": "",
"setting_notifications_notify_minutes": "",
"setting_notifications_notify_never": "",
"setting_notifications_subtitle": "",
"setting_notifications_title": "",
"setting_pages_app_bar_settings": "Einstellungen",
"share_add": "Hinzufügen",
"share_add_photos": "Fotos hinzufügen",
@@ -150,8 +109,6 @@
"tab_controller_nav_photos": "Fotos",
"tab_controller_nav_search": "Suche",
"tab_controller_nav_sharing": "Teilen",
"theme_setting_asset_list_storage_indicator_title": "",
"theme_setting_asset_list_tiles_per_row_title": "",
"theme_setting_dark_mode_switch": "Dunkler Modus",
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",

View File

@@ -21,12 +21,8 @@
"backup_controller_page_backup_selected": "Seleccionado:",
"backup_controller_page_backup_sub": "Copia de seguridad de fotos y vídeos",
"backup_controller_page_cancel": "Cancelar",
"backup_controller_page_created": "",
"backup_controller_page_desc_backup": "Active la copia de seguridad para cargar automáticamente los nuevos activos al servidor.",
"backup_controller_page_excluded": "Excluido:",
"backup_controller_page_failed": "",
"backup_controller_page_filename": "",
"backup_controller_page_id": "",
"backup_controller_page_info": "Información de la Copia de Seguridad",
"backup_controller_page_none_selected": "Ninguno seleccionado",
"backup_controller_page_remainder": "Remanente",
@@ -42,7 +38,6 @@
"backup_controller_page_total_sub": "Todas las fotos y vídeos únicos de los álbumes seleccionados",
"backup_controller_page_turn_off": "Apagar la copia de seguridad",
"backup_controller_page_turn_on": "Activar la copia de seguridad",
"backup_controller_page_uploading_file_info": "",
"backup_err_only_album": "No se puede eliminar el único álbum",
"backup_info_card_assets": "activos",
"control_bottom_app_bar_delete": "Eliminar",
@@ -67,7 +62,6 @@
"login_form_err_invalid_email": "Correo electrónico no válido",
"login_form_err_leading_whitespace": "Espacio en blanco inicial",
"login_form_err_trailing_whitespace": "Espacio en blanco al final",
"login_form_failed_login": "",
"login_form_label_email": "Correo",
"login_form_label_password": "Contraseña",
"login_form_password_hint": "contraseña",
@@ -76,14 +70,12 @@
"profile_drawer_client_server_up_to_date": "El Cliente y el Servidor están actualizados",
"profile_drawer_sign_out": "Cerrar Sesión",
"search_bar_hint": "Busca tus fotos",
"search_page_no_objects": "",
"search_page_no_places": "No hay información de lugares disponibles",
"search_page_places": "Lugares",
"search_page_things": "Cosas",
"search_result_page_new_search_hint": "Nueva Busqueda",
"select_additional_user_for_sharing_page_suggestions": "Sugerencias",
"select_user_for_sharing_page_err_album": "Fallo al crear el álbum",
"select_user_for_sharing_page_share_suggestions": "",
"share_add": "Añadir",
"share_add_photos": "Añadir fotos",
"share_add_title": "Añadir un título",

View File

@@ -49,9 +49,6 @@
"create_shared_album_page_share": "Jaa",
"create_shared_album_page_share_add_assets": "LISÄÄ KOHTEITA",
"create_shared_album_page_share_select_photos": "Valitse kuvat",
"daily_title_text_date": "",
"daily_title_text_date_year": "",
"date_format": "",
"delete_dialog_alert": "Nämä kohteet poistetaan pysyvästi Immich:stä ja laitteeltasi",
"delete_dialog_cancel": "Peruuta",
"delete_dialog_ok": "Poista",
@@ -72,7 +69,6 @@
"login_form_label_password": "Salasana",
"login_form_password_hint": "salasana",
"login_form_save_login": "Pysy kirjautuneena",
"monthly_title_text_date_format": "",
"profile_drawer_client_server_up_to_date": "Asiakassovellus ja palvelin ovat ajan tasalla",
"profile_drawer_sign_out": "Kirjaudu ulos",
"search_bar_hint": "Etsi kuvia",

View File

@@ -12,8 +12,6 @@
"album_viewer_appbar_share_leave": "Quitter l'album",
"album_viewer_appbar_share_remove": "Retirer de l'album",
"album_viewer_page_share_add_users": "Ajouter des utilisateurs",
"asset_list_settings_subtitle": "",
"asset_list_settings_title": "",
"backup_album_selection_page_albums_device": "Albums sur l'appareil ({})",
"backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure",
"backup_album_selection_page_assets_scatter": "Les éléments peuvent être répartis sur plusieurs albums. De ce fait, les albums peuvent être inclus ou exclus pendant le processus de sauvegarde.",
@@ -21,26 +19,7 @@
"backup_album_selection_page_selection_info": "Informations sur la sélection",
"backup_album_selection_page_total_assets": "Total des éléments uniques",
"backup_all": "Tout",
"backup_background_service_backup_failed_message": "",
"backup_background_service_connection_failed_message": "",
"backup_background_service_current_upload_notification": "",
"backup_background_service_default_notification": "",
"backup_background_service_error_title": "",
"backup_background_service_in_progress_notification": "",
"backup_background_service_upload_failure_notification": "",
"backup_controller_page_albums": "Sauvegarder les albums",
"backup_controller_page_background_battery_info_link": "",
"backup_controller_page_background_battery_info_message": "",
"backup_controller_page_background_battery_info_ok": "",
"backup_controller_page_background_battery_info_title": "",
"backup_controller_page_background_charging": "",
"backup_controller_page_background_configure_error": "",
"backup_controller_page_background_description": "",
"backup_controller_page_background_is_off": "",
"backup_controller_page_background_is_on": "",
"backup_controller_page_background_turn_off": "",
"backup_controller_page_background_turn_on": "",
"backup_controller_page_background_wifi": "",
"backup_controller_page_backup": "Sauvegardé",
"backup_controller_page_backup_selected": "Sélectionné : ",
"backup_controller_page_backup_sub": "Photos et vidéos sauvegardées",
@@ -69,19 +48,6 @@
"backup_controller_page_uploading_file_info": "Envoi d'informations sur le fichier",
"backup_err_only_album": "Impossible de retirer le seul album",
"backup_info_card_assets": "éléments",
"cache_settings_album_thumbnails": "",
"cache_settings_clear_cache_button": "",
"cache_settings_clear_cache_button_title": "",
"cache_settings_image_cache_size": "",
"cache_settings_statistics_album": "",
"cache_settings_statistics_assets": "",
"cache_settings_statistics_full": "",
"cache_settings_statistics_shared": "",
"cache_settings_statistics_thumbnail": "",
"cache_settings_statistics_title": "",
"cache_settings_subtitle": "",
"cache_settings_thumbnail_size": "",
"cache_settings_title": "",
"control_bottom_app_bar_delete": "Supprimer",
"control_bottom_app_bar_share": "Partager",
"create_album_page_untitled": "Sans titre",
@@ -127,14 +93,6 @@
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
"select_user_for_sharing_page_err_album": "Échec de la création de l'album",
"select_user_for_sharing_page_share_suggestions": "Suggestions",
"setting_notifications_notify_failures_grace_period": "",
"setting_notifications_notify_hours": "",
"setting_notifications_notify_immediately": "",
"setting_notifications_notify_minutes": "",
"setting_notifications_notify_never": "",
"setting_notifications_subtitle": "",
"setting_notifications_title": "",
"setting_pages_app_bar_settings": "",
"share_add": "Ajouter",
"share_add_photos": "Ajouter des photos",
"share_add_title": "Ajouter un titre",
@@ -150,16 +108,6 @@
"tab_controller_nav_photos": "Photos",
"tab_controller_nav_search": "Recherche",
"tab_controller_nav_sharing": "Partage",
"theme_setting_asset_list_storage_indicator_title": "",
"theme_setting_asset_list_tiles_per_row_title": "",
"theme_setting_dark_mode_switch": "",
"theme_setting_image_viewer_quality_subtitle": "",
"theme_setting_image_viewer_quality_title": "",
"theme_setting_system_theme_switch": "",
"theme_setting_theme_subtitle": "",
"theme_setting_theme_title": "",
"theme_setting_three_stage_loading_subtitle": "",
"theme_setting_three_stage_loading_title": "",
"version_announcement_overlay_ack": "Confirmer",
"version_announcement_overlay_release_notes": "notes de mise à jour",
"version_announcement_overlay_text_1": "Bonjour, une nouvelle version de",

View File

@@ -360,11 +360,11 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -495,11 +495,11 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -522,11 +522,11 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.27.0</string>
<string>1.30.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>52</string>
<string>58</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.27.0"
version_number: "1.29.4"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000173">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.499192">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.412813">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="30.057077">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.69289">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.438506">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.408563">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="91.259106">
<testcase classname="fastlane.lanes" name="4: build_app" time="65.350555">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="102.092139">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.894733">
</testcase>

View File

@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -15,11 +14,9 @@ class AlbumThumbnailCard extends StatelessWidget {
const AlbumThumbnailCard({
Key? key,
required this.album,
required this.cacheService,
}) : super(key: key);
final AlbumResponseDto album;
final CacheService cacheService;
@override
Widget build(BuildContext context) {
@@ -39,7 +36,6 @@ class AlbumThumbnailCard extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
memCacheHeight: max(400, cardSize.toInt() * 3),
width: cardSize,
height: cardSize,

View File

@@ -1,15 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -17,13 +14,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
final AssetResponseDto asset;
final List<AssetResponseDto> assetList;
final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
const AlbumViewerThumbnail({
Key? key,
required this.asset,
required this.assetList,
this.cacheManager,
this.showStorageIndicator = true,
}) : super(key: key);
@@ -126,7 +121,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
return Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage(
cacheManager: cacheManager,
cacheKey: asset.id,
width: 300,
height: 300,

View File

@@ -1,11 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -24,7 +22,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
var newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
final cacheService = ref.watch(cacheServiceProvider);
Widget _buildSelectionIcon(AssetResponseDto asset) {
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
@@ -114,7 +111,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.thumbnail),
cacheKey: asset.id,
width: 150,
height: 150,

View File

@@ -1,10 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -16,7 +14,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final cacheService = ref.watch(cacheServiceProvider);
var box = Hive.box(userInfoBox);
return GestureDetector(
@@ -26,7 +23,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
child: Stack(
children: [
CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.thumbnail),
cacheKey: asset.id,
width: 500,
height: 500,

View File

@@ -16,7 +16,6 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@@ -192,7 +191,6 @@ class AlbumViewerPage extends HookConsumerWidget {
final appSettingService = ref.watch(appSettingsServiceProvider);
final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
final cacheService = ref.watch(cacheServiceProvider);
if (albumInfo.assets.isNotEmpty) {
return SliverPadding(
@@ -207,7 +205,6 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return AlbumViewerThumbnail(
cacheManager: cacheService.getCache(CacheType.thumbnail),
asset: albumInfo.assets[index],
assetList: albumInfo.assets,
showStorageIndicator: showStorageIndicator,

View File

@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
class LibraryPage extends HookConsumerWidget {
const LibraryPage({Key? key}) : super(key: key);
@@ -14,7 +13,6 @@ class LibraryPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider);
final cacheService = ref.watch(cacheServiceProvider);
useEffect(
() {
@@ -104,7 +102,6 @@ class LibraryPage extends HookConsumerWidget {
_buildCreateAlbumButton(),
for (var album in albums)
AlbumThumbnailCard(
cacheService: cacheService,
album: album,
),
],

View File

@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -21,7 +20,6 @@ class SharingPage extends HookConsumerWidget {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
final CacheService cacheService = ref.watch(cacheServiceProvider);
useEffect(
() {
@@ -47,8 +45,6 @@ class SharingPage extends HookConsumerWidget {
height: 60,
memCacheHeight: 200,
fit: BoxFit.cover,
cacheManager:
cacheService.getCache(CacheType.sharedAlbumThumbnail),
imageUrl: getAlbumThumbnailUrl(album),
cacheKey: album.albumThumbnailAssetId,
httpHeaders: {

View File

@@ -1,7 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:photo_view/photo_view.dart';
enum _RemoteImageStatus { empty, thumbnail, preview, full }
@@ -12,6 +10,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
bool _zoomedIn = false;
static const int swipeThreshold = 100;
late CachedNetworkImageProvider fullProvider;
late CachedNetworkImageProvider previewProvider;
late CachedNetworkImageProvider thumbnailProvider;
@override
Widget build(BuildContext context) {
@@ -56,21 +57,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
widget.isZoomedFunction();
}
void _fireStartLoadingEvent() {
widget.onLoadingStart();
}
void _fireFinishedLoadingEvent() {
widget.onLoadingCompleted();
}
CachedNetworkImageProvider _authorizedImageProvider(
String url, String cacheKey, BaseCacheManager? cacheManager) {
String url,
String cacheKey,
) {
return CachedNetworkImageProvider(
url,
headers: {"Authorization": widget.authToken},
cacheKey: cacheKey,
cacheManager: cacheManager,
);
}
@@ -91,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
if (!mounted) return;
if (newStatus != _RemoteImageStatus.full) {
_fireStartLoadingEvent();
} else {
_fireFinishedLoadingEvent();
}
setState(() {
_status = newStatus;
_imageProvider = provider;
@@ -104,10 +92,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
}
void _loadImages() {
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
thumbnailProvider = _authorizedImageProvider(
widget.thumbnailUrl,
widget.cacheKey,
widget.thumbnailCacheManager,
);
_imageProvider = thumbnailProvider;
@@ -121,10 +108,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
);
if (widget.previewUrl != null) {
CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
previewProvider = _authorizedImageProvider(
widget.previewUrl!,
"${widget.cacheKey}_previewStage",
widget.previewCacheManager,
);
previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
@@ -133,10 +119,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
);
}
CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
fullProvider = _authorizedImageProvider(
widget.imageUrl,
"${widget.cacheKey}_fullStage",
widget.fullCacheManager,
);
fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
@@ -147,8 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
@override
void initState() {
_loadImages();
super.initState();
_loadImages();
}
@override
void dispose() async {
super.dispose();
if (_status == _RemoteImageStatus.full) {
await fullProvider.evict();
} else if (_status == _RemoteImageStatus.preview) {
await previewProvider.evict();
} else if (_status == _RemoteImageStatus.thumbnail) {
await thumbnailProvider.evict();
}
await _imageProvider.evict();
}
}
@@ -163,11 +163,6 @@ class RemotePhotoView extends StatefulWidget {
required this.onSwipeDown,
required this.onSwipeUp,
this.previewUrl,
required this.onLoadingCompleted,
required this.onLoadingStart,
this.thumbnailCacheManager,
this.previewCacheManager,
this.fullCacheManager,
required this.cacheKey,
}) : super(key: key);
@@ -175,11 +170,6 @@ class RemotePhotoView extends StatefulWidget {
final String imageUrl;
final String authToken;
final String? previewUrl;
final Function onLoadingCompleted;
final Function onLoadingStart;
final BaseCacheManager? thumbnailCacheManager;
final BaseCacheManager? previewCacheManager;
final BaseCacheManager? fullCacheManager;
final String cacheKey;
final void Function() onSwipeDown;

View File

@@ -24,11 +24,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
double iconSize = 18.0;
return AppBar(
// iconTheme: IconThemeData(color: Colors.grey[100]),
// actionsIconTheme: IconThemeData(color: Colors.grey[100]),
foregroundColor: Colors.grey[100],
toolbarHeight: 60,
backgroundColor: Colors.black,
backgroundColor: Colors.transparent,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();

View File

@@ -121,8 +121,6 @@ class GalleryViewerPage extends HookConsumerWidget {
authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener,
onLoadingCompleted: () => {},
onLoadingStart: () => {},
asset: assetList[index],
heroTag: assetList[index].id,
threeStageLoading: threeStageLoading.value,

View File

@@ -8,7 +8,6 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -19,8 +18,6 @@ class ImageViewerPage extends HookConsumerWidget {
final String authToken;
final ValueNotifier<bool> isZoomedListener;
final void Function() isZoomedFunction;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading;
ImageViewerPage({
@@ -30,8 +27,6 @@ class ImageViewerPage extends HookConsumerWidget {
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading,
}) : super(key: key);
@@ -41,7 +36,6 @@ class ImageViewerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
final cacheService = ref.watch(cacheServiceProvider);
getAssetExif() async {
assetDetail =
@@ -85,14 +79,6 @@ class ImageViewerPage extends HookConsumerWidget {
isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(),
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart,
thumbnailCacheManager:
cacheService.getCache(CacheType.thumbnail),
previewCacheManager:
cacheService.getCache(CacheType.imageViewerPreview),
fullCacheManager:
cacheService.getCache(CacheType.imageViewerFull),
),
),
),

View File

@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
_createChewieController() {
chewieController = ChewieController(
showOptions: true,
showControlsOnInitialize: true,
showControlsOnInitialize: false,
videoPlayerController: videoPlayerController,
autoPlay: true,
autoInitialize: true,

View File

@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -33,7 +32,6 @@ class BackgroundService {
MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel');
bool _isForegroundInitialized = false;
bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken;
bool _canceledBySystem = false;
@@ -43,32 +41,34 @@ class BackgroundService {
ReceivePort? _rp;
bool _errorGracePeriodExceeded = true;
bool get isForegroundInitialized {
return _isForegroundInitialized;
}
bool get isBackgroundInitialized {
return _isBackgroundInitialized;
}
Future<bool> _initialize() async {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
var result = await _foregroundChannel
.invokeMethod('initialize', [callback.toRawHandle()]);
_isForegroundInitialized = true;
return result;
}
/// Ensures that the background service is enqueued if enabled in settings
Future<bool> resumeServiceIfEnabled() async {
return await isBackgroundBackupEnabled() &&
await startService(keepExisting: true);
return await isBackgroundBackupEnabled() && await enableService();
}
/// Enqueues the background service
Future<bool> startService({
bool immediate = false,
bool keepExisting = false,
Future<bool> enableService({bool immediate = false}) async {
if (!Platform.isAndroid) {
return true;
}
try {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
return ok;
} catch (error) {
return false;
}
}
/// Configures the background service
Future<bool> configureService({
bool requireUnmetered = true,
bool requireCharging = false,
}) async {
@@ -76,14 +76,9 @@ class BackgroundService {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel.invokeMethod(
'start',
[immediate, keepExisting, requireUnmetered, requireCharging, title],
'configure',
[requireUnmetered, requireCharging],
);
return ok;
} catch (error) {
@@ -92,15 +87,12 @@ class BackgroundService {
}
/// Cancels the background service (if currently running) and removes it from work queue
Future<bool> stopService() async {
Future<bool> disableService() async {
if (!Platform.isAndroid) {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
final ok = await _foregroundChannel.invokeMethod('stop');
final ok = await _foregroundChannel.invokeMethod('disable');
return ok;
} catch (error) {
return false;
@@ -113,9 +105,6 @@ class BackgroundService {
return false;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) {
return false;
@@ -128,9 +117,6 @@ class BackgroundService {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel
.invokeMethod('isIgnoringBatteryOptimizations');
} catch (error) {
@@ -187,7 +173,8 @@ class BackgroundService {
}
} catch (error) {
debugPrint(
"[_clearErrorNotifications] failed to communicate with plugin");
"[_clearErrorNotifications] failed to communicate with plugin",
);
}
return false;
}
@@ -289,18 +276,11 @@ class BackgroundService {
try {
final bool hasAccess = await acquireLock();
if (!hasAccess) {
debugPrint("[_callHandler] could acquire lock, exiting");
debugPrint("[_callHandler] could not acquire lock, exiting");
return false;
}
await translationsLoaded;
final bool ok = await _onAssetsChanged();
if (ok) {
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
}
return ok;
} catch (error) {
debugPrint(error.toString());
@@ -343,6 +323,31 @@ class BackgroundService {
}
await PhotoManager.setIgnorePermissionCheck(true);
do {
final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
if (backupOk) {
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
return false;
}
// check for new assets added while performing backup
} while (true ==
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
return true;
}
Future<bool> _runBackup(
BackupService backupService,
HiveBackupAlbums backupAlbumInfo,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
if (_canceledBySystem) {
@@ -382,10 +387,6 @@ class BackgroundService {
);
if (ok) {
_clearErrorNotifications();
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
@@ -447,6 +448,7 @@ class BackgroundService {
}
/// entry point called by Kotlin/Java code; needs to be a top-level function
@pragma('vm:entry-point')
void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized();
BackgroundService backgroundService = BackgroundService();

View File

@@ -26,7 +26,7 @@ class AvailableAlbum {
String get name => albumEntity.name;
int get assetCount => albumEntity.assetCount;
Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => albumEntity.id;

View File

@@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo();
}
success &= await _backgroundService.enableService(immediate: true);
}
final bool success = await _backgroundService.stopService() &&
await _backgroundService.startService(
success &= success &&
await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
);
@@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
onError("backup_controller_page_background_configure_error");
}
} else {
final bool success = await _backgroundService.stopService();
final bool success = await _backgroundService.disableService();
if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error");
@@ -181,17 +183,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
var assetList =
await album.getAssetListRange(start: 0, end: album.assetCount);
var assetCountInAlbum = await album.assetCountAsync;
if (assetCountInAlbum > 0) {
var assetList =
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first;
var thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first;
var thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
}
availableAlbums.add(availableAlbum);
}
availableAlbums.add(availableAlbum);
}
state = state.copyWith(availableAlbums: availableAlbums);
@@ -294,14 +300,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) {
var assets = await album.albumEntity
.getAssetListRange(start: 0, end: album.assetCount);
var assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromSelectedAlbums.addAll(assets);
}
for (var album in state.excludedBackupAlbums) {
var assets = await album.albumEntity
.getAssetListRange(start: 0, end: album.assetCount);
var assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets);
}
@@ -351,11 +361,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await Future.wait([
_getBackupAlbumsInfo(),
_updateServerInfo(),
]);
await _getBackupAlbumsInfo();
await _updateServerInfo();
await _updateBackupAssetCount();
}
}

View File

@@ -127,7 +127,9 @@ class BackupService {
for (int i = 0; i < albums.length; i++) {
final AssetPathEntity? a = albums[i];
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount));
result.addAll(
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
);
lastBackup[i] = now;
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -205,15 +203,23 @@ class AlbumInfoCard extends HookConsumerWidget {
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
albumInfo.assetCount.toString() +
(albumInfo.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
child: FutureBuilder(
builder: ((context, snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data.toString() +
(albumInfo.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: albumInfo.assetCount,
),
)
],

View File

@@ -173,19 +173,19 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(),
),
actions: [
TextButton(
OutlinedButton(
onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication),
child: Text(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication,
),
child: const Text(
"backup_controller_page_background_battery_info_link",
style: TextStyle(color: buttonTextColor),
).tr(),
),
TextButton(
child: Text(
ElevatedButton(
child: const Text(
'backup_controller_page_background_battery_info_ok',
style: TextStyle(color: buttonTextColor),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
onPressed: () {
Navigator.of(context).pop();
@@ -636,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Colors.red[300],
onPrimary: Colors.grey[50],
foregroundColor: Colors.grey[50],
backgroundColor: Colors.red[300],
// padding: const EdgeInsets.all(14),
),
onPressed: () {

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
import 'package:openapi/api.dart';
@@ -10,13 +9,11 @@ class ImageGrid extends ConsumerWidget {
final List<AssetResponseDto> sortedAssetGroup;
final int tilesPerRow;
final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
ImageGrid({
Key? key,
required this.assetGroup,
required this.sortedAssetGroup,
this.cacheManager,
this.tilesPerRow = 4,
this.showStorageIndicator = true,
}) : super(key: key);
@@ -39,7 +36,6 @@ class ImageGrid extends ConsumerWidget {
child: Stack(
children: [
ThumbnailImage(
cacheManager: cacheManager,
asset: assetGroup[index],
assetList: sortedAssetGroup,
showStorageIndicator: showStorageIndicator,

View File

@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
bool isEnableAutoBackup =
bool isEnableAutoBackup = backupState.backgroundBackup ||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);

View File

@@ -2,8 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -17,13 +15,11 @@ class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset;
final List<AssetResponseDto> assetList;
final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
const ThumbnailImage({
Key? key,
required this.asset,
required this.assetList,
this.cacheManager,
this.showStorageIndicator = true,
}) : super(key: key);
@@ -36,7 +32,7 @@ class ThumbnailImage extends HookConsumerWidget {
ref.watch(homePageStateProvider).isMultiSelectEnable;
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget _buildSelectionIcon(AssetResponseDto asset) {
Widget buildSelectionIcon(AssetResponseDto asset) {
if (selectedAsset.contains(asset)) {
return Icon(
Icons.check_circle,
@@ -52,7 +48,6 @@ class ThumbnailImage extends HookConsumerWidget {
return GestureDetector(
onTap: () {
debugPrint("View ${asset.id}");
if (isMultiSelectEnable &&
selectedAsset.contains(asset) &&
selectedAsset.length == 1) {
@@ -95,11 +90,12 @@ class ThumbnailImage extends HookConsumerWidget {
: const Border(),
),
child: CachedNetworkImage(
cacheKey: asset.id,
cacheManager: cacheManager,
cacheKey: 'thumbnail-image-${asset.id}',
width: 300,
height: 300,
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
memCacheHeight: 200,
maxWidthDiskCache: 200,
maxHeightDiskCache: 200,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {
@@ -115,6 +111,8 @@ class ThumbnailImage extends HookConsumerWidget {
),
errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
@@ -127,7 +125,7 @@ class ThumbnailImage extends HookConsumerWidget {
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: _buildSelectionIcon(asset),
child: buildSelectionIcon(asset),
),
),
if (showStorageIndicator)

View File

@@ -16,7 +16,6 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:openapi/api.dart';
class HomePage extends HookConsumerWidget {
@@ -25,7 +24,6 @@ class HomePage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final cacheService = ref.watch(cacheServiceProvider);
ScrollController scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
@@ -91,7 +89,6 @@ class HomePage extends HookConsumerWidget {
imageGridGroup.add(
ImageGrid(
cacheManager: cacheService.getCache(CacheType.thumbnail),
assetGroup: immichAssetList,
sortedAssetGroup: sortedAssetList,
tilesPerRow:

View File

@@ -99,9 +99,9 @@ class SearchPage extends HookConsumerWidget {
_buildThings() {
return curatedObjects.when(
loading: () => const SizedBox(
height: 200,
child: Center(child: ImmichLoadingIndicator()),
loading: () => SizedBox(
height: imageSize,
child: const Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => Text('Error: $err'),
data: (objects) {
@@ -133,8 +133,7 @@ class SearchPage extends HookConsumerWidget {
),
)
: SizedBox(
// height: imageSize,
width: imageSize,
height: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,

View File

@@ -15,7 +15,6 @@ class CacheSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final CacheService cacheService = ref.watch(cacheServiceProvider);
final clearCacheState = useState(false);
Future<void> clearCache() async {

View File

@@ -4,7 +4,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
@@ -42,7 +41,6 @@ class SettingsPage extends HookConsumerWidget {
const ImageViewerQualitySetting(),
const ThemeSetting(),
const AssetListSettings(),
const CacheSettings(),
if (Platform.isAndroid) const NotificationSetting(),
],
).toList(),

View File

@@ -59,8 +59,6 @@ class _$AppRouter extends RootStackRouter {
authToken: args.authToken,
isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener,
onLoadingCompleted: args.onLoadingCompleted,
onLoadingStart: args.onLoadingStart,
threeStageLoading: args.threeStageLoading));
},
VideoViewerRoute.name: (routeData) {
@@ -297,8 +295,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
required String authToken,
required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener,
required void Function() onLoadingCompleted,
required void Function() onLoadingStart,
required bool threeStageLoading})
: super(ImageViewerRoute.name,
path: '/image-viewer-page',
@@ -309,8 +305,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
authToken: authToken,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart,
threeStageLoading: threeStageLoading));
static const String name = 'ImageViewerRoute';
@@ -324,8 +318,6 @@ class ImageViewerRouteArgs {
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading});
final Key? key;
@@ -340,15 +332,11 @@ class ImageViewerRouteArgs {
final ValueNotifier<bool> isZoomedListener;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading;
@override
String toString() {
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, onLoadingCompleted: $onLoadingCompleted, onLoadingStart: $onLoadingStart, threeStageLoading: $threeStageLoading}';
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, threeStageLoading: $threeStageLoading}';
}
}

View File

@@ -7,7 +7,6 @@ import 'package:immich_mobile/utils/immich_cache_info_repository.dart';
enum CacheType {
// Shared cache for asset thumbnails in various modules
thumbnail,
imageViewerPreview,
imageViewerFull,
albumThumbnail,
@@ -67,7 +66,10 @@ class CacheService {
}
BaseCacheManager _getDefaultCache(
String cacheName, int size, CacheInfoRepository repo) {
String cacheName,
int size,
CacheInfoRepository repo,
) {
return CacheManager(
Config(
cacheName,

View File

@@ -6,10 +6,12 @@ doc/AddAssetsDto.md
doc/AddUsersDto.md
doc/AdminSignupResponseDto.md
doc/AlbumApi.md
doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md
doc/AssetApi.md
doc/AssetCountByTimeBucket.md
doc/AssetCountByTimeBucketResponseDto.md
doc/AssetCountByUserIdResponseDto.md
doc/AssetFileUploadResponseDto.md
doc/AssetResponseDto.md
doc/AssetTypeEnum.md
@@ -70,9 +72,11 @@ lib/auth/oauth.dart
lib/model/add_assets_dto.dart
lib/model/add_users_dto.dart
lib/model/admin_signup_response_dto.dart
lib/model/album_count_response_dto.dart
lib/model/album_response_dto.dart
lib/model/asset_count_by_time_bucket.dart
lib/model/asset_count_by_time_bucket_response_dto.dart
lib/model/asset_count_by_user_id_response_dto.dart
lib/model/asset_file_upload_response_dto.dart
lib/model/asset_response_dto.dart
lib/model/asset_type_enum.dart

View File

@@ -69,6 +69,7 @@ Class | Method | HTTP request | Description
*AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users |
*AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album |
*AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} |
*AlbumApi* | [**getAlbumCountByUserId**](doc//AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id |
*AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} |
*AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album |
*AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets |
@@ -81,6 +82,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} |
*AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
*AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
*AssetApi* | [**getAssetCountByUserId**](doc//AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
*AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
*AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} |
*AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
@@ -113,9 +115,11 @@ Class | Method | HTTP request | Description
- [AddAssetsDto](doc//AddAssetsDto.md)
- [AddUsersDto](doc//AddUsersDto.md)
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
- [AlbumResponseDto](doc//AlbumResponseDto.md)
- [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
- [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
- [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
- [AssetResponseDto](doc//AssetResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md)

View File

@@ -13,6 +13,7 @@ Method | HTTP request | Description
[**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users |
[**createAlbum**](AlbumApi.md#createalbum) | **POST** /album |
[**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} |
[**getAlbumCountByUserId**](AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id |
[**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} |
[**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album |
[**removeAssetFromAlbum**](AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets |
@@ -211,6 +212,49 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAlbumCountByUserId**
> AlbumCountResponseDto getAlbumCountByUserId()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AlbumApi();
try {
final result = api_instance.getAlbumCountByUserId();
print(result);
} catch (e) {
print('Exception when calling AlbumApi->getAlbumCountByUserId: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**AlbumCountResponseDto**](AlbumCountResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAlbumInfo**
> AlbumResponseDto getAlbumInfo(albumId)

View File

@@ -0,0 +1,17 @@
# openapi.model.AlbumCountResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**owned** | **int** | |
**shared** | **int** | |
**sharing** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -16,6 +16,7 @@ Method | HTTP request | Description
[**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} |
[**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
[**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
[**getAssetCountByUserId**](AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
[**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
[**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} |
[**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
@@ -363,6 +364,49 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAssetCountByUserId**
> AssetCountByUserIdResponseDto getAssetCountByUserId()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
try {
final result = api_instance.getAssetCountByUserId();
print(result);
} catch (e) {
print('Exception when calling AssetApi->getAssetCountByUserId: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAssetSearchTerms**
> List<String> getAssetSearchTerms()

View File

@@ -0,0 +1,16 @@
# openapi.model.AssetCountByUserIdResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**photos** | **int** | |
**videos** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -37,9 +37,11 @@ part 'api/user_api.dart';
part 'model/add_assets_dto.dart';
part 'model/add_users_dto.dart';
part 'model/admin_signup_response_dto.dart';
part 'model/album_count_response_dto.dart';
part 'model/album_response_dto.dart';
part 'model/asset_count_by_time_bucket.dart';
part 'model/asset_count_by_time_bucket_response_dto.dart';
part 'model/asset_count_by_user_id_response_dto.dart';
part 'model/asset_file_upload_response_dto.dart';
part 'model/asset_response_dto.dart';
part 'model/asset_type_enum.dart';

View File

@@ -207,6 +207,47 @@ class AlbumApi {
}
}
/// Performs an HTTP 'GET /album/count-by-user-id' operation and returns the [Response].
Future<Response> getAlbumCountByUserIdWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/album/count-by-user-id';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<AlbumCountResponseDto?> getAlbumCountByUserId() async {
final response = await getAlbumCountByUserIdWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumCountResponseDto',) as AlbumCountResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /album/{albumId}' operation and returns the [Response].
/// Parameters:
///

View File

@@ -395,6 +395,47 @@ class AssetApi {
return null;
}
/// Performs an HTTP 'GET /asset/count-by-user-id' operation and returns the [Response].
Future<Response> getAssetCountByUserIdWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/asset/count-by-user-id';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<AssetCountByUserIdResponseDto?> getAssetCountByUserId() async {
final response = await getAssetCountByUserIdWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /asset/search-terms' operation and returns the [Response].
Future<Response> getAssetSearchTermsWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@@ -198,12 +198,16 @@ class ApiClient {
return AddUsersDto.fromJson(value);
case 'AdminSignupResponseDto':
return AdminSignupResponseDto.fromJson(value);
case 'AlbumCountResponseDto':
return AlbumCountResponseDto.fromJson(value);
case 'AlbumResponseDto':
return AlbumResponseDto.fromJson(value);
case 'AssetCountByTimeBucket':
return AssetCountByTimeBucket.fromJson(value);
case 'AssetCountByTimeBucketResponseDto':
return AssetCountByTimeBucketResponseDto.fromJson(value);
case 'AssetCountByUserIdResponseDto':
return AssetCountByUserIdResponseDto.fromJson(value);
case 'AssetFileUploadResponseDto':
return AssetFileUploadResponseDto.fromJson(value);
case 'AssetResponseDto':

View File

@@ -0,0 +1,127 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AlbumCountResponseDto {
/// Returns a new [AlbumCountResponseDto] instance.
AlbumCountResponseDto({
required this.owned,
required this.shared,
required this.sharing,
});
int owned;
int shared;
int sharing;
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumCountResponseDto &&
other.owned == owned &&
other.shared == shared &&
other.sharing == sharing;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(owned.hashCode) +
(shared.hashCode) +
(sharing.hashCode);
@override
String toString() => 'AlbumCountResponseDto[owned=$owned, shared=$shared, sharing=$sharing]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'owned'] = owned;
_json[r'shared'] = shared;
_json[r'sharing'] = sharing;
return _json;
}
/// Returns a new [AlbumCountResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumCountResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AlbumCountResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AlbumCountResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AlbumCountResponseDto(
owned: mapValueOfType<int>(json, r'owned')!,
shared: mapValueOfType<int>(json, r'shared')!,
sharing: mapValueOfType<int>(json, r'sharing')!,
);
}
return null;
}
static List<AlbumCountResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumCountResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AlbumCountResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AlbumCountResponseDto> mapFromJson(dynamic json) {
final map = <String, AlbumCountResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AlbumCountResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AlbumCountResponseDto-objects as value to a dart map
static Map<String, List<AlbumCountResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AlbumCountResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AlbumCountResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'owned',
'shared',
'sharing',
};
}

View File

@@ -0,0 +1,119 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetCountByUserIdResponseDto {
/// Returns a new [AssetCountByUserIdResponseDto] instance.
AssetCountByUserIdResponseDto({
required this.photos,
required this.videos,
});
int photos;
int videos;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetCountByUserIdResponseDto &&
other.photos == photos &&
other.videos == videos;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(photos.hashCode) +
(videos.hashCode);
@override
String toString() => 'AssetCountByUserIdResponseDto[photos=$photos, videos=$videos]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'photos'] = photos;
_json[r'videos'] = videos;
return _json;
}
/// Returns a new [AssetCountByUserIdResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetCountByUserIdResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AssetCountByUserIdResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetCountByUserIdResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AssetCountByUserIdResponseDto(
photos: mapValueOfType<int>(json, r'photos')!,
videos: mapValueOfType<int>(json, r'videos')!,
);
}
return null;
}
static List<AssetCountByUserIdResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetCountByUserIdResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetCountByUserIdResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetCountByUserIdResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetCountByUserIdResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetCountByUserIdResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetCountByUserIdResponseDto-objects as value to a dart map
static Map<String, List<AssetCountByUserIdResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetCountByUserIdResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetCountByUserIdResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'photos',
'videos',
};
}

View File

@@ -76,69 +76,72 @@ class AssetResponseDto {
SmartInfoResponseDto? smartInfo;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
bool operator ==(Object other) =>
identical(this, other) ||
other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode);
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode);
@override
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
String toString() =>
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'type'] = type;
_json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath;
_json[r'type'] = type;
_json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath;
if (resizePath != null) {
_json[r'resizePath'] = resizePath;
} else {
_json[r'resizePath'] = null;
}
_json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite;
_json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite;
if (mimeType != null) {
_json[r'mimeType'] = mimeType;
} else {
_json[r'mimeType'] = null;
}
_json[r'duration'] = duration;
_json[r'duration'] = duration;
if (webpPath != null) {
_json[r'webpPath'] = webpPath;
} else {
@@ -172,13 +175,13 @@ class AssetResponseDto {
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
// assert(() {
// requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// });
// return true;
// }());
return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -202,7 +205,10 @@ class AssetResponseDto {
return null;
}
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
static List<AssetResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -230,12 +236,18 @@ class AssetResponseDto {
}
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
static Map<String, List<AssetResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
final value = AssetResponseDto.listFromJson(
entry.value,
growable: growable,
);
if (value != null) {
map[entry.key] = value;
}
@@ -262,4 +274,3 @@ class AssetResponseDto {
'encodedVideoPath',
};
}

View File

@@ -0,0 +1,37 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AlbumCountResponseDto
void main() {
// final instance = AlbumCountResponseDto();
group('test AlbumCountResponseDto', () {
// int owned
test('to test the property `owned`', () async {
// TODO
});
// int shared
test('to test the property `shared`', () async {
// TODO
});
// int sharing
test('to test the property `sharing`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,32 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AssetCountByUserIdResponseDto
void main() {
// final instance = AssetCountByUserIdResponseDto();
group('test AssetCountByUserIdResponseDto', () {
// int photos
test('to test the property `photos`', () async {
// TODO
});
// int videos
test('to test the property `videos`', () async {
// TODO
});
});
}

View File

@@ -7,14 +7,14 @@ packages:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "38.0.0"
version: "47.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "3.4.1"
version: "4.7.0"
archive:
dependency: transitive
description:
@@ -42,14 +42,14 @@ packages:
name: auto_route
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
version: "5.0.1"
auto_route_generator:
dependency: "direct dev"
description:
name: auto_route_generator
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
version: "5.0.2"
badges:
dependency: "direct main"
description:
@@ -77,7 +77,7 @@ packages:
name: build_config
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
version: "1.1.0"
build_daemon:
dependency: transitive
description:
@@ -98,7 +98,7 @@ packages:
name: build_runner
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.10"
version: "2.2.1"
build_runner_core:
dependency: transitive
description:
@@ -126,21 +126,21 @@ packages:
name: cached_network_image
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.1"
version: "3.2.2"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
version: "2.0.0"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
cancellation_token:
dependency: transitive
description:
@@ -162,13 +162,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
checked_yaml:
dependency: transitive
description:
@@ -182,7 +175,7 @@ packages:
name: chewie
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.2"
version: "1.3.5"
clock:
dependency: transitive
description:
@@ -238,7 +231,7 @@ packages:
name: cupertino_icons
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
version: "1.0.5"
dart_style:
dependency: transitive
description:
@@ -322,7 +315,7 @@ packages:
source: hosted
version: "0.6.8"
flutter_cache_manager:
dependency: "direct main"
dependency: transitive
description:
name: flutter_cache_manager
url: "https://pub.dartlang.org"
@@ -461,7 +454,7 @@ packages:
name: hive_generator
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.2"
version: "1.1.3"
hooks_riverpod:
dependency: "direct main"
description:
@@ -503,7 +496,7 @@ packages:
name: image
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.3"
version: "3.2.0"
image_picker:
dependency: "direct main"
description:
@@ -783,7 +776,7 @@ packages:
name: photo_manager
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0+2"
version: "2.2.1"
photo_view:
dependency: "direct main"
description:
@@ -839,7 +832,7 @@ packages:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.2"
version: "6.0.3"
pub_semver:
dependency: transitive
description:
@@ -1139,20 +1132,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.1"
universal_html:
dependency: transitive
description:
name: universal_html
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
universal_io:
dependency: transitive
description:
name: universal_io
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
url_launcher:
dependency: "direct main"
description:
@@ -1223,27 +1202,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
very_good_analysis:
dependency: transitive
description:
name: very_good_analysis
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
video_player:
dependency: "direct main"
description:
name: video_player
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.2"
version: "2.4.7"
video_player_android:
dependency: transitive
description:
name: video_player_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.3"
version: "2.3.9"
video_player_avfoundation:
dependency: transitive
description:
@@ -1271,7 +1243,7 @@ packages:
name: wakelock
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.1+2"
version: "0.6.2"
wakelock_macos:
dependency: transitive
description:
@@ -1341,7 +1313,7 @@ packages:
name: xml
url: "https://pub.dartlang.org"
source: hosted
version: "5.4.1"
version: "6.1.0"
yaml:
dependency: transitive
description:
@@ -1350,5 +1322,5 @@ packages:
source: hosted
version: "3.1.1"
sdks:
dart: ">=2.17.0 <3.0.0"
flutter: ">=3.0.0"
dart: ">=2.18.0 <3.0.0"
flutter: ">=3.3.0"

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.27.0+37
version: 1.29.4+44
environment:
sdk: ">=2.17.0 <3.0.0"
@@ -11,22 +11,22 @@ dependencies:
flutter:
sdk: flutter
photo_manager: ^2.0.6
photo_manager: ^2.2.1
flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0
hive: ^2.2.1
hive_flutter: ^1.1.0
dio: ^4.0.4
cached_network_image: ^3.2.1
cached_network_image: ^3.2.2
percent_indicator: ^4.2.2
intl: ^0.17.0
auto_route: ^4.0.1
auto_route: ^5.0.1
exif: ^3.1.1
transparent_image: ^2.0.0
flutter_launcher_icons: "^0.9.2"
fluttertoast: ^8.0.8
video_player: ^2.2.18
chewie: ^1.2.2
chewie: ^1.3.5
badges: ^2.0.2
photo_view: ^0.14.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0
@@ -43,7 +43,6 @@ dependencies:
easy_localization: ^3.0.1
share_plus: ^4.0.10
flutter_displaymode: ^0.4.0
flutter_cache_manager: 3.3.0
path: ^1.8.1
path_provider: ^2.0.11
@@ -59,8 +58,8 @@ dev_dependencies:
sdk: flutter
flutter_lints: ^2.0.1
hive_generator: ^1.1.2
build_runner: ^2.1.7
auto_route_generator: ^4.0.0
build_runner: ^2.2.1
auto_route_generator: ^5.0.2
flutter:
uses-material-design: true

View File

@@ -1,6 +1,5 @@
FROM nginx:latest
FROM docker.io/nginxinc/nginx-unprivileged:latest
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY nginx.conf "/etc/nginx/nginx.conf"
EXPOSE 80
EXPOSE 443
CMD nginx -g "daemon off;"

View File

@@ -1,73 +1,93 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /tmp/nginx.pid;
# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
}
# events {
# worker_connections 1000;
# }
server {
gzip on;
gzip_min_length 1000;
gunzip on;
client_max_body_size 50000M;
listen 80;
access_log off;
location /api {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
rewrite /api/(.*) /$1 break;
proxy_pass http://immich-server:3001;
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location / {
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# Compression
gzip_static on;
# events {
# worker_connections 1000;
# }
server {
gzip on;
gzip_min_length 1000;
gzip_comp_level 2;
gunzip on;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
client_max_body_size 50000M;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
listen 8080;
access_log off;
proxy_pass http://immich-web:3000;
location /api {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
rewrite /api/(.*) /$1 break;
proxy_pass http://immich-server:3001;
}
location / {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich-web:3000;
}
}
}
}

View File

@@ -10,7 +10,7 @@ import { CreateAlbumDto } from './dto/create-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
export interface IAlbumRepository {
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
@@ -23,6 +23,7 @@ export interface IAlbumRepository {
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
}
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
@@ -42,6 +43,23 @@ export class AlbumRepository implements IAlbumRepository {
private dataSource: DataSource,
) {}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
const sharedAlbums = await this.userAlbumRepository.count({
where: { sharedUserId: userId },
});
let sharedAlbumCount = 0;
ownedAlbums.map((album) => {
if (album.sharedUsers?.length) {
sharedAlbumCount += 1;
}
});
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount);
}
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
return this.dataSource.transaction(async (transactionalEntityManager) => {
// Create album entity
@@ -151,32 +169,32 @@ export class AlbumRepository implements IAlbumRepository {
}
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
let query = this.albumRepository.createQueryBuilder('album');
const query = this.albumRepository.createQueryBuilder('album');
const albums = await query
.where('album.ownerId = :ownerId', { ownerId: userId })
.andWhere((qb) => {
// shared with userId
const subQuery = qb
.subQuery()
.select('assetAlbum.albumId')
.from(AssetAlbumEntity, 'assetAlbum')
.where('assetAlbum.assetId = :assetId', {assetId: assetId})
.getQuery();
return `album.id IN ${subQuery}`;
})
.leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
.getMany();
.where('album.ownerId = :ownerId', { ownerId: userId })
.andWhere((qb) => {
// shared with userId
const subQuery = qb
.subQuery()
.select('assetAlbum.albumId')
.from(AssetAlbumEntity, 'assetAlbum')
.where('assetAlbum.assetId = :assetId', { assetId: assetId })
.getQuery();
return `album.id IN ${subQuery}`;
})
.leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
.getMany();
return albums;
}
async get(albumId: string): Promise<AlbumEntity | undefined> {
let query = this.albumRepository.createQueryBuilder('album');
const query = this.albumRepository.createQueryBuilder('album');
const album = await query
.where('album.id = :albumId', { albumId })
@@ -228,9 +246,10 @@ export class AlbumRepository implements IAlbumRepository {
// TODO: No need to return boolean if using a singe delete query
if (deleteAssetCount == removeAssetsDto.assetIds.length) {
const retAlbum = await this.get(album.id) as AlbumEntity;
const retAlbum = (await this.get(album.id)) as AlbumEntity;
if (retAlbum?.assets?.length === 0) { // is empty album
if (retAlbum?.assets?.length === 0) {
// is empty album
await this.albumRepository.update(album.id, { albumThumbnailAssetId: null });
retAlbum.albumThumbnailAssetId = null;
}

View File

@@ -24,6 +24,7 @@ import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
@UseGuards(JwtAuthGuard)
@@ -33,6 +34,11 @@ import { AlbumResponseDto } from './response-dto/album-response.dto';
export class AlbumController {
constructor(private readonly albumService: AlbumService) {}
@Get('count-by-user-id')
async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.albumService.getAlbumCountByUserId(authUser);
}
@Post()
async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
return this.albumService.create(authUser, createAlbumDto);

View File

@@ -8,6 +8,7 @@ import { AlbumEntity } from '../../../../../libs/database/src/entities/album.ent
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
@Module({
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
@@ -18,6 +19,10 @@ import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
provide: ALBUM_REPOSITORY,
useClass: AlbumRepository,
},
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
],
})
export class AlbumModule {}

View File

@@ -4,11 +4,13 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
import { IAssetRepository } from '../asset/asset-repository';
describe('Album service', () => {
let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
const authUser: AuthUserDto = Object.freeze({
id: '1111',
email: 'auth@test.com',
@@ -116,9 +118,25 @@ describe('Album service', () => {
removeAssets: jest.fn(),
removeUser: jest.fn(),
updateAlbum: jest.fn(),
getListByAssetId: jest.fn()
getListByAssetId: jest.fn(),
getCountByUserId: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock);
assetRepositoryMock = {
create: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
});
it('creates album', async () => {

View File

@@ -9,10 +9,15 @@ import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
@Injectable()
export class AlbumService {
constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {}
constructor(
@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository,
@Inject(ASSET_REPOSITORY) private _assetRepository: IAssetRepository,
) {}
private async _getAlbum({
authUser,
@@ -53,6 +58,11 @@ export class AlbumService {
return albums.map(mapAlbumExcludeAssetInfo);
}
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
for (const album of albums) {
await this._checkValidThumbnail(album);
}
return albums.map((album) => mapAlbumExcludeAssetInfo(album));
}
@@ -118,4 +128,22 @@ export class AlbumService {
const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
return mapAlbum(updatedAlbum);
}
async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this._albumRepository.getCountByUserId(authUser.id);
}
async _checkValidThumbnail(album: AlbumEntity): Promise<AlbumEntity> {
const assetId = album.albumThumbnailAssetId;
if (assetId) {
try {
await this._assetRepository.getById(assetId);
} catch (e) {
album.albumThumbnailAssetId = null;
return await this._albumRepository.updateAlbum(album, {});
}
}
return album;
}
}

View File

@@ -1,4 +1,4 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { IsOptional } from 'class-validator';
export class UpdateAlbumDto {
@IsOptional()

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
export class AlbumCountResponseDto {
@ApiProperty({ type: 'integer' })
owned!: number;
@ApiProperty({ type: 'integer' })
shared!: number;
@ApiProperty({ type: 'integer' })
sharing!: number;
constructor(owned: number, shared: number, sharing: number) {
this.owned = owned;
this.shared = shared;
this.sharing = sharing;
}
}

View File

@@ -9,6 +9,7 @@ import { CuratedObjectsResponseDto } from './response-dto/curated-objects-respon
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
export interface IAssetRepository {
create(
@@ -25,6 +26,7 @@ export interface IAssetRepository {
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
}
@@ -38,6 +40,28 @@ export class AssetRepository implements IAssetRepository {
private assetRepository: Repository<AssetEntity>,
) {}
async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
// Get asset count by AssetType
const res = await this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.where('"userId" = :userId', { userId: userId })
.groupBy('asset.type')
.getRawMany();
const assetCountByUserId = new AssetCountByUserIdResponseDto(0, 0);
res.map((item) => {
if (item.type === 'IMAGE') {
assetCountByUserId.photos = item.count;
} else if (item.type === 'VIDEO') {
assetCountByUserId.videos = item.count;
}
});
return assetCountByUserId;
}
async getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
// Get asset entity from a list of time buckets
return await this.assetRepository

View File

@@ -15,7 +15,6 @@ import {
HttpCode,
BadRequestException,
UploadedFile,
Header,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service';
@@ -34,7 +33,7 @@ import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto';
@@ -48,6 +47,7 @@ import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { QueryFailedError } from 'typeorm';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -78,7 +78,13 @@ export class AssetController {
const checksum = await this.assetService.calculateChecksum(file.path);
try {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype, checksum);
const savedAsset = await this.assetService.createUserAsset(
authUser,
assetInfo,
file.path,
file.mimetype,
checksum,
);
if (!savedAsset) {
await this.backgroundTaskService.deleteFileOnDisk([
@@ -104,7 +110,7 @@ export class AssetController {
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum)
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum);
return new AssetFileUploadResponseDto(existedAsset.id);
}
@@ -172,6 +178,10 @@ export class AssetController {
return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto);
}
@Get('/count-by-user-id')
async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getAssetCountByUserId(authUser);
}
/**
* Get all AssetEntity belong to the user
*/

View File

@@ -1,9 +1,12 @@
import { AssetRepository, IAssetRepository } from './asset-repository';
import { IAssetRepository } from './asset-repository';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetService } from './asset.service';
import { Repository } from 'typeorm';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
describe('AssetService', () => {
let sui: AssetService;
@@ -11,7 +14,7 @@ describe('AssetService', () => {
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
const authUser: AuthUserDto = Object.freeze({
id: '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd',
id: 'user_id_1',
email: 'auth@test.com',
});
@@ -27,26 +30,68 @@ describe('AssetService', () => {
return createAssetDto;
};
const _getAsset = () => {
const assetEntity = new AssetEntity();
assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
assetEntity.deviceAssetId = '4967046344801';
assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
assetEntity.type = AssetType.VIDEO;
assetEntity.originalPath =
'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
assetEntity.resizePath = '';
assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
assetEntity.isFavorite = false;
assetEntity.mimeType = 'image/jpeg';
assetEntity.webpPath = '';
assetEntity.encodedVideoPath = '';
assetEntity.duration = '0:00:00.000000';
const _getAsset_1 = () => {
const asset_1 = new AssetEntity();
return assetEntity;
asset_1.id = 'id_1';
asset_1.userId = 'user_id_1';
asset_1.deviceAssetId = 'device_asset_id_1';
asset_1.deviceId = 'device_id_1';
asset_1.type = AssetType.VIDEO;
asset_1.originalPath = 'fake_path/asset_1.jpeg';
asset_1.resizePath = '';
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
asset_1.isFavorite = false;
asset_1.mimeType = 'image/jpeg';
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000';
return asset_1;
};
const _getAsset_2 = () => {
const asset_2 = new AssetEntity();
asset_2.id = 'id_2';
asset_2.userId = 'user_id_1';
asset_2.deviceAssetId = 'device_asset_id_2';
asset_2.deviceId = 'device_id_1';
asset_2.type = AssetType.VIDEO;
asset_2.originalPath = 'fake_path/asset_2.jpeg';
asset_2.resizePath = '';
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
asset_2.isFavorite = false;
asset_2.mimeType = 'image/jpeg';
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
asset_2.duration = '0:00:00.000000';
return asset_2;
};
const _getAssets = () => {
return [_getAsset_1(), _getAsset_2()];
};
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
const result1 = new AssetCountByTimeBucket();
result1.count = 2;
result1.timeBucket = '2022-06-01T00:00:00.000Z';
const result2 = new AssetCountByTimeBucket();
result1.count = 5;
result1.timeBucket = '2022-07-01T00:00:00.000Z';
return [result1, result2];
};
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
const result = new AssetCountByUserIdResponseDto(2, 2);
return result;
};
beforeAll(() => {
@@ -61,35 +106,72 @@ describe('AssetService', () => {
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
};
sui = new AssetService(assetRepositoryMock, a);
});
// Currently failing due to calculate checksum from a file
// it('create an asset', async () => {
// const assetEntity = _getAsset();
it('create an asset', async () => {
const assetEntity = _getAsset_1();
// assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
// const originalPath =
// 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
// const mimeType = 'image/jpeg';
// const createAssetDto = _getCreateAssetDto();
// const result = await sui.createUserAsset(authUser, createAssetDto, originalPath, mimeType);
const originalPath = 'fake_path/asset_1.jpeg';
const mimeType = 'image/jpeg';
const createAssetDto = _getCreateAssetDto();
const result = await sui.createUserAsset(
authUser,
createAssetDto,
originalPath,
mimeType,
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
);
// expect(result.userId).toEqual(authUser.id);
// expect(result.resizePath).toEqual('');
// expect(result.webpPath).toEqual('');
// });
expect(result.userId).toEqual(authUser.id);
expect(result.resizePath).toEqual('');
expect(result.webpPath).toEqual('');
});
it('get assets by device id', async () => {
assetRepositoryMock.getAllByDeviceId.mockImplementation(() => Promise.resolve<string[]>(['4967046344801']));
const assets = _getAssets();
const deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
assetRepositoryMock.getAllByDeviceId.mockImplementation(() =>
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
);
const deviceId = 'device_id_1';
const result = await sui.getUserAssetsByDeviceId(authUser, deviceId);
expect(result.length).toEqual(1);
expect(result[0]).toEqual('4967046344801');
expect(result.length).toEqual(2);
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
});
it('get assets count by time bucket', async () => {
const assetCountByTimeBucket = _getAssetCountByTimeBucket();
assetRepositoryMock.getAssetCountByTimeBucket.mockImplementation(() =>
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
);
const result = await sui.getAssetCountByTimeBucket(authUser, {
timeGroup: TimeGroupEnum.Month,
});
expect(result.totalCount).toEqual(assetCountByTimeBucket.reduce((a, b) => a + b.count, 0));
expect(result.buckets.length).toEqual(2);
});
it('get asset count by user id', async () => {
const assetCount = _getAssetCountByUserId();
assetRepositoryMock.getAssetCountByUserId.mockImplementation(() =>
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
);
const result = await sui.getAssetCountByUserId(authUser);
expect(result).toEqual(assetCount);
});
});

View File

@@ -35,6 +35,8 @@ import {
} from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { timeUtils } from '@app/common/utils';
const fileInfo = promisify(stat);
@@ -55,6 +57,18 @@ export class AssetService {
mimeType: string,
checksum: Buffer,
): Promise<AssetEntity> {
// Check valid time.
const createdAt = createAssetDto.createdAt;
const modifiedAt = createAssetDto.modifiedAt;
if (!timeUtils.checkValidTimestamp(createdAt)) {
createAssetDto.createdAt = await timeUtils.getTimestampFromExif(originalPath);
}
if (!timeUtils.checkValidTimestamp(modifiedAt)) {
createAssetDto.modifiedAt = await timeUtils.getTimestampFromExif(originalPath);
}
const assetEntity = await this._assetRepository.create(
createAssetDto,
authUser.id,
@@ -479,4 +493,8 @@ export class AssetService {
fileReadStream.pipe(sha1Hash);
return deferred;
}
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getAssetCountByUserId(authUser.id);
}
}

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional } from 'class-validator';
export enum GetAssetThumbnailFormatEnum {
JPEG = 'JPEG',

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
export class ServeFileDto {
@IsNotEmpty()

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
export class AssetCountByUserIdResponseDto {
@ApiProperty({ type: 'integer' })
photos!: number;
@ApiProperty({ type: 'integer' })
videos!: number;
constructor(photos: number, videos: number) {
this.photos = photos;
this.videos = videos;
}
}

View File

@@ -1,12 +1,5 @@
import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiCreatedResponse,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthService } from './auth.service';

View File

@@ -1,5 +1,5 @@
import { UserEntity } from '@app/database/entities/user.entity';
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
import { ApiResponseProperty } from '@nestjs/swagger';
export class LoginResponseDto {
@ApiResponseProperty()

View File

@@ -1,8 +1,6 @@
import { DeviceType } from '@app/database/entities/device-info.entity';
import { PartialType } from '@nestjs/mapped-types';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional } from 'class-validator';
import { CreateDeviceInfoDto } from './create-device-info.dto';
export class UpdateDeviceInfoDto {
@IsNotEmpty()

View File

@@ -1,13 +1,10 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { Controller, Get } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { serverVersion } from '../../constants/server_version.constant';
import { ApiTags } from '@nestjs/swagger';
import { ServerPingResponse } from './response-dto/server-ping-response.dto';
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import { DataSource } from 'typeorm';
@ApiTags('Server Info')
@Controller('server-info')

View File

@@ -12,7 +12,6 @@ import {
UploadedFile,
Response,
Request,
StreamableFile,
ParseBoolPipe,
} from '@nestjs/common';
import { UserService } from './user.service';
@@ -75,8 +74,11 @@ export class UserController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Put()
async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
return await this.userService.updateUser(updateUserDto);
async updateUser(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
return await this.userService.updateUser(authUser, updateUserDto);
}
@UseInterceptors(FileInterceptor('file', profileImageUploadOption))

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