Compare commits

...

34 Commits

Author SHA1 Message Date
Alex Tran
25848b78f9 Up version for release 2022-10-26 11:24:04 -05:00
Alex
f94176a910 feat(web) dark mode (#867) 2022-10-26 11:10:48 -05:00
Alex Tran
ae96508e15 Fixed unit test 2022-10-25 22:15:17 -05:00
Alex
95ebf815eb feat(web) styling server stats page (#866) 2022-10-25 21:41:46 -05:00
Jonas Janz
b713fb5650 feat(docker) revert ubuntu base image (#863)
* feat(docker) revert ubuntu base image

This PR reverts the base image for immich-server back to alpine

Adds LICENSE to all Images
Quiets apt-get commands when building
ensures write-permission for root group on app folders

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

* Test build old Docker content

* Revert and retry

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-10-25 14:18:37 -05:00
Alex
6159c83fd2 feat(mobile) duplicated asset upload handling mechanism (#853) 2022-10-25 09:51:03 -05:00
Jonas Janz
f1af17bf4d feat(immich-server) use ubuntu base-image (#851)
this changes the base-image for immich-server from
`node:16-alpine3.14`
to
`node:16-slim`

There is an open issue with alpine DNS resolving which
breaks immich-microservice when deployed on
kubernetes.

This fixes https://github.com/immich-app/immich-charts/issues/4

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

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-10-24 14:59:07 -05:00
Alex
a87c1c1210 fix(mobile) not possible to sign out when option is enable (#860) 2022-10-24 14:45:58 -05:00
Alex
e63d165b65 chore(server) add workflow dispatcher to sdk repository (#859) 2022-10-24 12:55:16 -05:00
Alex Tran
9411770253 update readme 2022-10-23 22:21:48 -05:00
Alex Tran
dc80ac1c88 Remove openapi generator - move to TeamCity CI/CD for this job 2022-10-23 18:19:21 -05:00
Alex Tran
bb055628cc Fixed api generation action 2022-10-23 17:56:16 -05:00
Alex Tran
390bcdb8c6 Fixed api generation action 2022-10-23 17:53:11 -05:00
Alex Tran
d95bcb46ad Fixed api generation action 2022-10-23 17:52:52 -05:00
Alex
7b954e21e7 fix(server): add permission for server stats api (#854) 2022-10-23 17:01:41 -05:00
Zeeshan Khan
a6eea4d096 feat(web) add asset count stats on admin page (#843) 2022-10-23 16:54:54 -05:00
Alex
2c189d5c78 fix(server): force best effort to decode thumbnail image (#847) 2022-10-22 11:40:25 -05:00
Alex
85a80fd032 Added changlog 2022-10-21 13:19:04 -05:00
Zeeshan Khan
0309b47515 fixes(mobile) back navigation issue on android (#841) 2022-10-21 13:05:44 -05:00
bo0tzz
95d8f60389 feat(server)Log username and IP address on failed login attempt 2022-10-21 11:04:01 -05:00
Alex Tran
1ec7122381 Up version for release 2022-10-19 20:07:53 -05:00
Alex
061b229e12 feat(mobile): Cache assets and albums for faster loading speed
feat(mobile): Cache assets and albums for faster loading speed
2022-10-19 15:53:15 -05:00
Matthias Rupp
3617433858 Refactor abstract class to separate file 2022-10-19 22:03:54 +02:00
Alex
d6d525cc1b fix(mobile) back button navigation Android
fixes #310 back button navigation
2022-10-19 14:51:48 -05:00
Alex
e752290458 Merge pull request #839 from immich-app/dependabot/github_actions/docker/setup-buildx-action-2.2.1
chore(deps): bump docker/setup-buildx-action from 2.1.0 to 2.2.1
2022-10-18 09:27:47 -05:00
Matthias Rupp
d77e25425e Add cache for shared albums 2022-10-18 14:06:35 +02:00
dependabot[bot]
028c0249a3 chore(deps): bump docker/setup-buildx-action from 2.1.0 to 2.2.1
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.1.0 to 2.2.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.1.0...v2.2.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-18 10:45:12 +00:00
Zeeshan Khan
a3ca5307a5 fixes #310 back button navigation 2022-10-17 13:04:17 -05:00
Matthias Rupp
6796462b13 Switch to plain fs based caching mechanism 2022-10-17 18:02:43 +02:00
Matthias Rupp
d08475d5af Switch to lazyBox 2022-10-17 16:40:51 +02:00
Matthias Rupp
d310c77fc8 Add album list response caching 2022-10-17 14:53:27 +02:00
Matthias Rupp
75d8ca1306 Invalidation on logout and timing measurements 2022-10-16 09:50:31 +02:00
Matthias Rupp
894eea739e JSON based caching 2022-10-15 23:20:15 +02:00
Matthias Rupp
1156290377 Add asset response cache 2022-10-14 23:57:55 +02:00
135 changed files with 2836 additions and 442 deletions

View File

@@ -20,7 +20,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
@@ -48,7 +48,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
@@ -75,7 +75,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
@@ -103,7 +103,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
with:

View File

@@ -20,7 +20,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -50,7 +50,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -79,7 +79,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -109,7 +109,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2

View File

@@ -26,7 +26,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -61,7 +61,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
@@ -98,7 +98,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -138,7 +138,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.1.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2

View File

@@ -0,0 +1,21 @@
name: Update Immich SDK
on:
workflow_dispatch:
push:
branches: ["main"]
jobs:
update-sdk-repos:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'immich-app',
repo: 'immich-sdk-typescript-axios',
workflow_id: 'build.yml',
ref: 'main'
})

View File

@@ -1,83 +0,0 @@
name: Generate OpenAPI SDK
on:
workflow_dispatch:
push:
branches: [main]
jobs:
generate-typescript-axios:
runs-on: ubuntu-latest
name: OpenAPI Generator
steps:
# Checkout your code
- name: Checkout
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_TOKEN }}
# Use the action to generate a client package
# This uses the default path for the openapi document and thus assumes there is an openapi.json in the current workspace.
- name: Generate Typescript Axios Client
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: typescript-axios
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
# Do something with the generated client (likely publishing it somewhere)
- name: Push to typescript repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd typescript-axios-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-typescript-axios.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'
- name: Generate Dart SDK
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: dart
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
- name: Push to Dart repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd dart-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-dart.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'
- name: Generate Rust SDK
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: rust
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
- name: Push to Rust repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd rust-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-rust.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'

View File

@@ -116,8 +116,7 @@ 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).
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 further customization to meet the users' needs.*
@@ -197,6 +196,10 @@ If you have installed, you can update the application by navigate to the directo
```bash
docker-compose pull && docker-compose up -d
```
# Unraid Installation
Please follow this [article](https://mfaz.dev/posts/immich-unraid/) for a tutorial on how to install Immich on Unraid
# Mobile app

View File

@@ -7,8 +7,9 @@ WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN apt-get update > /dev/null \
&& apt-get install --no-install-recommends -y gcc g++ make cmake python3 python3-pip ffmpeg > /dev/null \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN npm ci
RUN npm rebuild @tensorflow/tfjs-node --build-from-source
@@ -23,6 +24,9 @@ FROM node:16-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
@@ -30,13 +34,18 @@ COPY entrypoint.sh ./
RUN mkdir -p /usr/src/app/dist \
&& mkdir -p /usr/src/app/node_modules \
&& apt-get update \
&& apt-get install -y ffmpeg \
&& rm -rf /var/cache/apt/lists
&& mkdir -p /usr/src/app/.reverse-geocoding-dump \
&& apt-get update > /dev/null \
&& apt-get install --no-install-recommends -y ffmpeg > /dev/null \
&& apt-get clean \
&& rm -rf /var/cache/apt/lists/*
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production
# CMD [ "node", "dist/main" ]
RUN chown -R node:0 /usr/src/app \
&& chmod -R g=u /usr/src/app
RUN addgroup node root

21
machine-learning/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 50,
"android.injected.version.name" => "1.32.0",
"android.injected.version.code" => 52,
"android.injected.version.name" => "1.33.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1 @@
* Improved slow initial loading for large amount of asset.

View File

@@ -0,0 +1,3 @@
* Fixed back button navigation
* Added duplicated asset upload handling mechanism
* Fixed cannot signout completely when "Save Logged In" is checked

View File

@@ -24,6 +24,7 @@
"backup_controller_page_backup_selected": "Ausgewählt: ",
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
"backup_controller_page_cancel": "Abbrechen",
"backup_background_service_default_notification": "Suche nach neuen assets…",
"backup_controller_page_created": "Erstellt: {}",
"backup_controller_page_desc_backup": "Aktiviere die Sicherung um Elemente automatisch auf den Server zu laden.",
"backup_controller_page_excluded": "Ausgeschlossen: ",
@@ -123,4 +124,4 @@
"version_announcement_overlay_text_2": "Bitte nehm dir die Zeit und lese das ",
"version_announcement_overlay_text_3": " und achte darauf, dass deine docker-compose und .env Dateien aktuell sind, vor allem wenn du ein System für automatische Updates benutzt (z.B. Watchtower).",
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89"
}
}

View File

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

View File

@@ -25,3 +25,7 @@ const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
// Duplicate asset
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1

View File

@@ -10,6 +10,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -30,12 +31,14 @@ void main() async {
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
await Hive.openBox(hiveGithubReleaseInfoBox);
await Hive.openBox(userSettingInfoBox);
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(

View File

@@ -1,22 +1,35 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:openapi/api.dart';
class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
AlbumNotifier(this._albumService) : super([]);
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
final AlbumService _albumService;
final AlbumCacheService _albumCacheService;
_cacheState() {
_albumCacheService.put(state);
}
getAllAlbums() async {
if (await _albumCacheService.isValid() && state.isEmpty) {
state = await _albumCacheService.get();
}
List<AlbumResponseDto>? albums =
await _albumService.getAlbums(isShared: false);
if (albums != null) {
state = albums;
_cacheState();
}
}
deleteAlbum(String albumId) {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
}
Future<AlbumResponseDto?> createAlbum(
@@ -28,6 +41,8 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
if (album != null) {
state = [...state, album];
_cacheState();
return album;
}
return null;
@@ -36,5 +51,8 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
final albumProvider =
StateNotifierProvider<AlbumNotifier, List<AlbumResponseDto>>((ref) {
return AlbumNotifier(ref.watch(albumServiceProvider));
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(albumCacheServiceProvider),
);
});

View File

@@ -1,12 +1,18 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:openapi/api.dart';
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
SharedAlbumNotifier(this._sharedAlbumService) : super([]);
SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]);
final AlbumService _sharedAlbumService;
final SharedAlbumCacheService _sharedAlbumCacheService;
_cacheState() {
_sharedAlbumCacheService.put(state);
}
Future<AlbumResponseDto?> createSharedAlbum(
String albumName,
@@ -22,6 +28,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
if (newAlbum != null) {
state = [...state, newAlbum];
_cacheState();
}
return newAlbum;
@@ -33,16 +40,22 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}
getAllSharedAlbums() async {
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
state = await _sharedAlbumCacheService.get();
}
List<AlbumResponseDto>? sharedAlbums =
await _sharedAlbumService.getAlbums(isShared: true);
if (sharedAlbums != null) {
state = sharedAlbums;
_cacheState();
}
}
deleteAlbum(String albumId) async {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
}
Future<bool> leaveAlbum(String albumId) async {
@@ -50,6 +63,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
if (res) {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
return true;
} else {
return false;
@@ -72,7 +86,10 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<AlbumResponseDto>>((ref) {
return SharedAlbumNotifier(ref.watch(albumServiceProvider));
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});
final sharedAlbumDetailProvider = FutureProvider.autoDispose

View File

@@ -0,0 +1,49 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';
class BaseAlbumCacheService extends JsonCache<List<AlbumResponseDto>> {
BaseAlbumCacheService(super.cacheFileName);
@override
void put(List<AlbumResponseDto> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
@override
Future<List<AlbumResponseDto>> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = mapList
.map((e) => AlbumResponseDto.fromJson(e))
.whereNotNull()
.toList();
return responseData;
} catch (e) {
debugPrint(e.toString());
return [];
}
}
}
class AlbumCacheService extends BaseAlbumCacheService {
AlbumCacheService() : super("album_cache");
}
class SharedAlbumCacheService extends BaseAlbumCacheService {
SharedAlbumCacheService() : super("shared_album_cache");
}
final albumCacheServiceProvider = Provider(
(ref) => AlbumCacheService(),
);
final sharedAlbumCacheServiceProvider = Provider(
(ref) => SharedAlbumCacheService(),
);

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/backup/background_service/localization.dar
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -316,10 +317,13 @@ class BackgroundService {
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox(userSettingInfoBox);
await Hive.openBox(backgroundBackupInfoBox);
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
@@ -410,7 +414,7 @@ class BackgroundService {
final bool ok = await backupService.backupAsset(
toUpload,
_cancellationToken!,
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
notifySingleProgress ? _onProgress : (sent, total) {},
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
_onBackupError,
@@ -429,7 +433,7 @@ class BackgroundService {
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
debugPrint("Uploaded $deviceAssetId from $deviceId");
_uploadedAssetsCount++;
_updateNotification(

View File

@@ -45,8 +45,10 @@ class ErrorUploadAsset extends Equatable {
List<Object> get props {
return [
id,
createdAt,
fileName,
fileType,
asset,
errorMessage,
];
}

View File

@@ -0,0 +1,57 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hive/hive.dart';
part 'hive_duplicated_assets.model.g.dart';
@HiveType(typeId: 2)
class HiveDuplicatedAssets {
@HiveField(0, defaultValue: [])
List<String> duplicatedAssetIds;
HiveDuplicatedAssets({
required this.duplicatedAssetIds,
});
HiveDuplicatedAssets copyWith({
List<String>? duplicatedAssetIds,
}) {
return HiveDuplicatedAssets(
duplicatedAssetIds: duplicatedAssetIds ?? this.duplicatedAssetIds,
);
}
Map<String, dynamic> toMap() {
return {
'duplicatedAssetIds': duplicatedAssetIds,
};
}
factory HiveDuplicatedAssets.fromMap(Map<String, dynamic> map) {
return HiveDuplicatedAssets(
duplicatedAssetIds: List<String>.from(map['duplicatedAssetIds']),
);
}
String toJson() => json.encode(toMap());
factory HiveDuplicatedAssets.fromJson(String source) =>
HiveDuplicatedAssets.fromMap(json.decode(source));
@override
String toString() =>
'HiveDuplicatedAssets(duplicatedAssetIds: $duplicatedAssetIds)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is HiveDuplicatedAssets &&
listEquals(other.duplicatedAssetIds, duplicatedAssetIds);
}
@override
int get hashCode => duplicatedAssetIds.hashCode;
}

View File

@@ -0,0 +1,42 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'hive_duplicated_assets.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class HiveDuplicatedAssetsAdapter extends TypeAdapter<HiveDuplicatedAssets> {
@override
final int typeId = 2;
@override
HiveDuplicatedAssets read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return HiveDuplicatedAssets(
duplicatedAssetIds:
fields[0] == null ? [] : (fields[0] as List).cast<String>(),
);
}
@override
void write(BinaryWriter writer, HiveDuplicatedAssets obj) {
writer
..writeByte(1)
..writeByte(0)
..write(obj.duplicatedAssetIds);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HiveDuplicatedAssetsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
@@ -296,6 +297,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Those assets are unique and are used as the total assets
///
Future<void> _updateBackupAssetCount() async {
Set<String> duplicatedAssetIds = _backupService.getDuplicatedAssetIds();
Set<AssetEntity> assetsFromSelectedAlbums = {};
Set<AssetEntity> assetsFromExcludedAlbums = {};
@@ -326,9 +328,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums
Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
);
if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device");
state = state.copyWith(
@@ -455,14 +463,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId
},
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
);
void _onAssetUploaded(
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
if (isDuplicated) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId
},
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
);
}
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
@@ -564,6 +584,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
albums.lastExcludedBackupTime,
);
}
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
state = state.copyWith(
backupProgress: previous,
@@ -608,6 +629,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
}
try {
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
}
try {
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
await Hive.box(backgroundBackupInfoBox).close();

View File

@@ -19,6 +19,8 @@ import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http;
import '../models/hive_duplicated_assets.model.dart';
final backupServiceProvider = Provider(
(ref) => BackupService(
ref.watch(apiServiceProvider),
@@ -41,6 +43,29 @@ class BackupService {
}
}
void _saveDuplicatedAssetIdToLocalStorage(List<String> deviceAssetIds) {
HiveDuplicatedAssets duplicatedAssets =
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.get(duplicatedAssetsKey) ??
HiveDuplicatedAssets(duplicatedAssetIds: []);
duplicatedAssets.duplicatedAssetIds =
{...duplicatedAssets.duplicatedAssetIds, ...deviceAssetIds}.toList();
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.put(duplicatedAssetsKey, duplicatedAssets);
}
/// Get duplicated asset id from Hive storage
Set<String> getDuplicatedAssetIds() {
HiveDuplicatedAssets duplicatedAssets =
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
.get(duplicatedAssetsKey) ??
HiveDuplicatedAssets(duplicatedAssetIds: []);
return duplicatedAssets.duplicatedAssetIds.toSet();
}
/// Returns all assets newer than the last successful backup per album
Future<List<AssetEntity>> buildUploadCandidates(
HiveBackupAlbums backupAlbums,
@@ -140,34 +165,47 @@ class BackupService {
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
List<AssetEntity> candidates,
) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
if (candidates.length < 10) {
final List<CheckDuplicateAssetResponseDto?> duplicateResponse =
await Future.wait(
candidates.map(
(e) => _apiService.assetApi.checkDuplicateAsset(
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId),
),
if (candidates.isEmpty) {
return candidates;
}
final Set<String> duplicatedAssetIds = getDuplicatedAssetIds();
candidates = duplicatedAssetIds.isEmpty
? candidates
: candidates
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
.toList();
if (candidates.isEmpty) {
return candidates;
}
final Set<String> existing = {};
try {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetApi.checkExistingAssets(
CheckExistingAssetsDto(
deviceAssetIds: candidates.map((e) => e.id).toList(),
deviceId: deviceId,
),
);
return candidates
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false)
.toList();
} else {
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return candidates;
if (duplicates != null) {
existing.addAll(duplicates.existingIds);
}
} on ApiException {
// workaround for older server versions or when checking for too many assets at once
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase != null) {
existing.addAll(allAssetsInDatabase);
}
final Set<String> inDb = allAssetsInDatabase.toSet();
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
}
return existing.isEmpty
? candidates
: candidates.whereNot((e) => existing.contains(e.id)).toList();
}
Future<bool> backupAsset(
Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(String, String, bool) uploadSuccessCb,
Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb,
@@ -176,6 +214,7 @@ class BackupService {
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file;
bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
for (var entity in assetList) {
try {
@@ -235,8 +274,13 @@ class BackupService {
var response = await req.send(cancellationToken: cancelToken);
if (response.statusCode == 201) {
singleAssetDoneCb(entity.id, deviceId);
if (response.statusCode == 200) {
// asset is a duplicate (already exists on the server)
duplicatedAssetIds.add(entity.id);
uploadSuccessCb(entity.id, deviceId, true);
} else if (response.statusCode == 201) {
// stored a new asset on the server
uploadSuccessCb(entity.id, deviceId, false);
} else {
var data = await response.stream.bytesToString();
var error = jsonDecode(data);
@@ -260,7 +304,8 @@ class BackupService {
}
} on http.CancelledException {
debugPrint("Backup was cancelled by the user");
return false;
anyErrors = true;
break;
} catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}");
anyErrors = true;
@@ -271,6 +316,9 @@ class BackupService {
}
}
}
if (duplicatedAssetIds.isNotEmpty) {
_saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds);
}
return !anyErrors;
}

View File

@@ -419,7 +419,6 @@ class BackupControllerPage extends HookConsumerWidget {
ActionChip(
avatar: Icon(
Icons.info,
size: 24,
color: Colors.red[400],
),
elevation: 1,

View File

@@ -0,0 +1,37 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';
class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
AssetCacheService() : super("asset_cache");
@override
void put(List<AssetResponseDto> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
@override
Future<List<AssetResponseDto>> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = mapList
.map((e) => AssetResponseDto.fromJson(e))
.whereNotNull()
.toList();
return responseData;
} catch (e) {
debugPrint(e.toString());
return [];
}
}
}
final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(),
);

View File

@@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
@@ -16,6 +18,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
this._deviceInfoService,
this._backupService,
this._apiService,
this._assetCacheService,
this._albumCacheService,
this._sharedAlbumCacheService,
) : super(
AuthenticationState(
deviceId: "",
@@ -42,6 +47,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final DeviceInfoService _deviceInfoService;
final BackupService _backupService;
final ApiService _apiService;
final AssetCacheService _assetCacheService;
final AlbumCacheService _albumCacheService;
final SharedAlbumCacheService _sharedAlbumCacheService;
Future<bool> login(
String email,
@@ -153,7 +161,23 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> logout() async {
Hive.box(userInfoBox).delete(accessTokenKey);
state = state.copyWith(isAuthenticated: false);
_assetCacheService.invalidate();
_albumCacheService.invalidate();
_sharedAlbumCacheService.invalidate();
// Remove login info from local storage
var loginInfo =
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
if (loginInfo != null) {
loginInfo.email = "";
loginInfo.password = "";
loginInfo.isSaveLogin = false;
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
loginInfo,
);
}
return true;
}
@@ -199,5 +223,8 @@ final authenticationProvider =
ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(apiServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(albumCacheServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});

View File

@@ -228,7 +228,7 @@ class LoginButton extends ConsumerWidget {
AutoRouter.of(context).push(const ChangePasswordRoute());
} else {
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/tab-controller-page");
AutoRouter.of(context).replace(const TabControllerRoute());
}
} else {
ImmichToast.show(

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
@@ -9,24 +10,50 @@ import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final DeviceInfoService _deviceInfoService = DeviceInfoService();
AssetNotifier(this._assetService) : super([]);
AssetNotifier(this._assetService, this._assetCacheService) : super([]);
_cacheState() {
_assetCacheService.put(state);
}
getAllAsset() async {
final stopwatch = Stopwatch();
if (await _assetCacheService.isValid() && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
stopwatch.start();
var allAssets = await _assetService.getAllAsset();
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (allAssets != null) {
state = allAssets;
stopwatch.start();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
}
clearAllAsset() {
state = [];
_cacheState();
}
onNewAssetUploaded(AssetResponseDto newAsset) {
state = [...state, newAsset];
_cacheState();
}
deleteAssets(Set<AssetResponseDto> deleteAssets) async {
@@ -65,12 +92,15 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
state.where((immichAsset) => immichAsset.id != asset.id).toList();
}
}
_cacheState();
}
}
final assetProvider =
StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) {
return AssetNotifier(ref.watch(assetServiceProvider));
return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
});
final assetGroupByDateTimeProvider = StateProvider((ref) {

View File

@@ -0,0 +1,49 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
abstract class JsonCache<T> {
final String cacheFileName;
JsonCache(this.cacheFileName);
Future<File> _getCacheFile() async {
final basePath = await getTemporaryDirectory();
final basePathName = basePath.path;
final file = File("$basePathName/$cacheFileName.bin");
return file;
}
Future<bool> isValid() async {
final file = await _getCacheFile();
return await file.exists();
}
Future<void> invalidate() async {
final file = await _getCacheFile();
await file.delete();
}
Future<void> putRawData(dynamic data) async {
final jsonString = json.encode(data);
final file = await _getCacheFile();
if (!await file.exists()) {
await file.create();
}
await file.writeAsString(jsonString);
}
dynamic readRawData() async {
final file = await _getCacheFile();
final data = await file.readAsString();
return json.decode(data);
}
void put(T data);
Future<T> get();
}

View File

@@ -29,9 +29,9 @@ class SplashScreenPage extends HookConsumerWidget {
if (isAuthenticated) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/tab-controller-page");
AutoRouter.of(context).replace(const TabControllerRoute());
} else {
AutoRouter.of(context).push(const LoginRoute());
AutoRouter.of(context).replace(const LoginRoute());
}
}
@@ -40,7 +40,7 @@ class SplashScreenPage extends HookConsumerWidget {
if (loginInfo?.isSaveLogin == true) {
performLoggingIn();
} else {
AutoRouter.of(context).push(const LoginRoute());
AutoRouter.of(context).replace(const LoginRoute());
}
return null;
},

View File

@@ -12,7 +12,6 @@ class TabControllerPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter(
routes: [
const HomeRoute(),
@@ -22,10 +21,16 @@ class TabControllerPage extends ConsumerWidget {
],
builder: (context, child, animation) {
final tabsRouter = AutoTabsRouter.of(context);
final appRouter = AutoRouter.of(context);
return WillPopScope(
onWillPop: () async {
tabsRouter.setActiveIndex(0);
return false;
bool atHomeTab = tabsRouter.activeIndex == 0;
if (!atHomeTab) {
tabsRouter.setActiveIndex(0);
} else {
appRouter.navigateBack();
}
return atHomeTab;
},
child: Scaffold(
body: FadeTransition(

View File

@@ -19,6 +19,8 @@ doc/AssetTypeEnum.md
doc/AuthenticationApi.md
doc/CheckDuplicateAssetDto.md
doc/CheckDuplicateAssetResponseDto.md
doc/CheckExistingAssetsDto.md
doc/CheckExistingAssetsResponseDto.md
doc/CreateAlbumDto.md
doc/CreateDeviceInfoDto.md
doc/CreateProfileImageResponseDto.md
@@ -48,6 +50,7 @@ doc/SearchAssetDto.md
doc/ServerInfoApi.md
doc/ServerInfoResponseDto.md
doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md
doc/ServerVersionReponseDto.md
doc/SignUpDto.md
doc/SmartInfoResponseDto.md
@@ -56,6 +59,7 @@ doc/TimeGroupEnum.md
doc/UpdateAlbumDto.md
doc/UpdateDeviceInfoDto.md
doc/UpdateUserDto.md
doc/UsageByUserDto.md
doc/UserApi.md
doc/UserCountResponseDto.md
doc/UserResponseDto.md
@@ -91,6 +95,8 @@ lib/model/asset_response_dto.dart
lib/model/asset_type_enum.dart
lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_dto.dart
lib/model/check_existing_assets_dto.dart
lib/model/check_existing_assets_response_dto.dart
lib/model/create_album_dto.dart
lib/model/create_device_info_dto.dart
lib/model/create_profile_image_response_dto.dart
@@ -117,6 +123,7 @@ lib/model/remove_assets_dto.dart
lib/model/search_asset_dto.dart
lib/model/server_info_response_dto.dart
lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart
lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart
@@ -125,7 +132,10 @@ lib/model/time_group_enum.dart
lib/model/update_album_dto.dart
lib/model/update_device_info_dto.dart
lib/model/update_user_dto.dart
lib/model/usage_by_user_dto.dart
lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart
pubspec.yaml
test/check_existing_assets_dto_test.dart
test/check_existing_assets_response_dto_test.dart

View File

@@ -76,6 +76,7 @@ Class | Method | HTTP request | Description
*AlbumApi* | [**removeUserFromAlbum**](doc//AlbumApi.md#removeuserfromalbum) | **DELETE** /album/{albumId}/user/{userId} |
*AlbumApi* | [**updateAlbumInfo**](doc//AlbumApi.md#updatealbuminfo) | **PATCH** /album/{albumId} |
*AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check |
*AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist |
*AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset |
*AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **GET** /asset/download |
*AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset |
@@ -102,6 +103,7 @@ Class | Method | HTTP request | Description
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image |
*UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user |
@@ -129,6 +131,8 @@ Class | Method | HTTP request | Description
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
- [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
- [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
- [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateDeviceInfoDto](doc//CreateDeviceInfoDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
@@ -155,6 +159,7 @@ Class | Method | HTTP request | Description
- [SearchAssetDto](doc//SearchAssetDto.md)
- [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
- [ServerPingResponse](doc//ServerPingResponse.md)
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
- [SignUpDto](doc//SignUpDto.md)
- [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
@@ -163,6 +168,7 @@ Class | Method | HTTP request | Description
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserCountResponseDto](doc//UserCountResponseDto.md)
- [UserResponseDto](doc//UserResponseDto.md)
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)

View File

@@ -10,6 +10,7 @@ All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check |
[**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist |
[**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset |
[**downloadFile**](AssetApi.md#downloadfile) | **GET** /asset/download |
[**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset |
@@ -76,6 +77,55 @@ 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)
# **checkExistingAssets**
> CheckExistingAssetsResponseDto checkExistingAssets(checkExistingAssetsDto)
Checks if multiple assets exist on the server and returns all existing - used by background backup
### 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();
final checkExistingAssetsDto = CheckExistingAssetsDto(); // CheckExistingAssetsDto |
try {
final result = api_instance.checkExistingAssets(checkExistingAssetsDto);
print(result);
} catch (e) {
print('Exception when calling AssetApi->checkExistingAssets: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**checkExistingAssetsDto** | [**CheckExistingAssetsDto**](CheckExistingAssetsDto.md)| |
### Return type
[**CheckExistingAssetsResponseDto**](CheckExistingAssetsResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **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)
# **deleteAsset**
> List<DeleteAssetResponseDto> deleteAsset(deleteAssetDto)

View File

@@ -0,0 +1,16 @@
# openapi.model.AssetCountResponseDto
## 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

@@ -0,0 +1,16 @@
# openapi.model.CheckExistingAssetsDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**deviceAssetIds** | **List<String>** | | [default to const []]
**deviceId** | **String** | |
[[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

@@ -0,0 +1,15 @@
# openapi.model.CheckExistingAssetsResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**existingIds** | **List<String>** | | [default to const []]
[[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

@@ -11,6 +11,7 @@ Method | HTTP request | Description
------------- | ------------- | -------------
[**getServerInfo**](ServerInfoApi.md#getserverinfo) | **GET** /server-info |
[**getServerVersion**](ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
[**getStats**](ServerInfoApi.md#getstats) | **GET** /server-info/stats |
[**pingServer**](ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
@@ -88,6 +89,43 @@ No authorization required
[[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)
# **getStats**
> ServerStatsResponseDto getStats()
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = ServerInfoApi();
try {
final result = api_instance.getStats();
print(result);
} catch (e) {
print('Exception when calling ServerInfoApi->getStats: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**ServerStatsResponseDto**](ServerStatsResponseDto.md)
### Authorization
No authorization required
### 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)
# **pingServer**
> ServerPingResponse pingServer()

View File

@@ -0,0 +1,20 @@
# openapi.model.ServerStatsResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**photos** | **int** | |
**videos** | **int** | |
**objects** | **int** | |
**usageRaw** | **int** | |
**usage** | **String** | |
**usageByUser** | [**List<UsageByUserDto>**](UsageByUserDto.md) | | [default to const []]
[[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

@@ -0,0 +1,20 @@
# openapi.model.UsageByUserDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**userId** | **String** | |
**objects** | **int** | |
**videos** | **int** | |
**photos** | **int** | |
**usageRaw** | **int** | |
**usage** | **String** | |
[[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

@@ -49,6 +49,8 @@ part 'model/asset_response_dto.dart';
part 'model/asset_type_enum.dart';
part 'model/check_duplicate_asset_dto.dart';
part 'model/check_duplicate_asset_response_dto.dart';
part 'model/check_existing_assets_dto.dart';
part 'model/check_existing_assets_response_dto.dart';
part 'model/create_album_dto.dart';
part 'model/create_device_info_dto.dart';
part 'model/create_profile_image_response_dto.dart';
@@ -75,6 +77,7 @@ part 'model/remove_assets_dto.dart';
part 'model/search_asset_dto.dart';
part 'model/server_info_response_dto.dart';
part 'model/server_ping_response.dart';
part 'model/server_stats_response_dto.dart';
part 'model/server_version_reponse_dto.dart';
part 'model/sign_up_dto.dart';
part 'model/smart_info_response_dto.dart';
@@ -83,6 +86,7 @@ part 'model/time_group_enum.dart';
part 'model/update_album_dto.dart';
part 'model/update_device_info_dto.dart';
part 'model/update_user_dto.dart';
part 'model/usage_by_user_dto.dart';
part 'model/user_count_response_dto.dart';
part 'model/user_response_dto.dart';
part 'model/validate_access_token_response_dto.dart';

View File

@@ -72,6 +72,62 @@ class AssetApi {
return null;
}
///
///
/// Checks if multiple assets exist on the server and returns all existing - used by background backup
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
Future<Response> checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/exist';
// ignore: prefer_final_locals
Object? postBody = checkExistingAssetsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
///
///
/// Checks if multiple assets exist on the server and returns all existing - used by background backup
///
/// Parameters:
///
/// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
Future<CheckExistingAssetsResponseDto?> checkExistingAssets(CheckExistingAssetsDto checkExistingAssetsDto,) async {
final response = await checkExistingAssetsWithHttpInfo(checkExistingAssetsDto,);
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), 'CheckExistingAssetsResponseDto',) as CheckExistingAssetsResponseDto;
}
return null;
}
/// Performs an HTTP 'DELETE /asset' operation and returns the [Response].
/// Parameters:
///

View File

@@ -98,6 +98,47 @@ class ServerInfoApi {
return null;
}
/// Performs an HTTP 'GET /server-info/stats' operation and returns the [Response].
Future<Response> getStatsWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/server-info/stats';
// 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<ServerStatsResponseDto?> getStats() async {
final response = await getStatsWithHttpInfo();
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), 'ServerStatsResponseDto',) as ServerStatsResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /server-info/ping' operation and returns the [Response].
Future<Response> pingServerWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@@ -220,6 +220,10 @@ class ApiClient {
return CheckDuplicateAssetDto.fromJson(value);
case 'CheckDuplicateAssetResponseDto':
return CheckDuplicateAssetResponseDto.fromJson(value);
case 'CheckExistingAssetsDto':
return CheckExistingAssetsDto.fromJson(value);
case 'CheckExistingAssetsResponseDto':
return CheckExistingAssetsResponseDto.fromJson(value);
case 'CreateAlbumDto':
return CreateAlbumDto.fromJson(value);
case 'CreateDeviceInfoDto':
@@ -272,6 +276,8 @@ class ApiClient {
return ServerInfoResponseDto.fromJson(value);
case 'ServerPingResponse':
return ServerPingResponse.fromJson(value);
case 'ServerStatsResponseDto':
return ServerStatsResponseDto.fromJson(value);
case 'ServerVersionReponseDto':
return ServerVersionReponseDto.fromJson(value);
case 'SignUpDto':
@@ -288,6 +294,8 @@ class ApiClient {
return UpdateDeviceInfoDto.fromJson(value);
case 'UpdateUserDto':
return UpdateUserDto.fromJson(value);
case 'UsageByUserDto':
return UsageByUserDto.fromJson(value);
case 'UserCountResponseDto':
return UserCountResponseDto.fromJson(value);
case 'UserResponseDto':

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 AssetCountResponseDto {
/// Returns a new [AssetCountResponseDto] instance.
AssetCountResponseDto({
required this.photos,
required this.videos,
});
int photos;
int videos;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetCountResponseDto &&
other.photos == photos &&
other.videos == videos;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(photos.hashCode) +
(videos.hashCode);
@override
String toString() => 'AssetCountResponseDto[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 [AssetCountResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetCountResponseDto? 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 "AssetCountResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetCountResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AssetCountResponseDto(
photos: mapValueOfType<int>(json, r'photos')!,
videos: mapValueOfType<int>(json, r'videos')!,
);
}
return null;
}
static List<AssetCountResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetCountResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetCountResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetCountResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetCountResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetCountResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetCountResponseDto-objects as value to a dart map
static Map<String, List<AssetCountResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetCountResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetCountResponseDto.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

@@ -0,0 +1,121 @@
//
// 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 CheckExistingAssetsDto {
/// Returns a new [CheckExistingAssetsDto] instance.
CheckExistingAssetsDto({
this.deviceAssetIds = const [],
required this.deviceId,
});
List<String> deviceAssetIds;
String deviceId;
@override
bool operator ==(Object other) => identical(this, other) || other is CheckExistingAssetsDto &&
other.deviceAssetIds == deviceAssetIds &&
other.deviceId == deviceId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(deviceAssetIds.hashCode) +
(deviceId.hashCode);
@override
String toString() => 'CheckExistingAssetsDto[deviceAssetIds=$deviceAssetIds, deviceId=$deviceId]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'deviceAssetIds'] = deviceAssetIds;
_json[r'deviceId'] = deviceId;
return _json;
}
/// Returns a new [CheckExistingAssetsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CheckExistingAssetsDto? 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 "CheckExistingAssetsDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "CheckExistingAssetsDto[$key]" has a null value in JSON.');
});
return true;
}());
return CheckExistingAssetsDto(
deviceAssetIds: json[r'deviceAssetIds'] is List
? (json[r'deviceAssetIds'] as List).cast<String>()
: const [],
deviceId: mapValueOfType<String>(json, r'deviceId')!,
);
}
return null;
}
static List<CheckExistingAssetsDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <CheckExistingAssetsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CheckExistingAssetsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CheckExistingAssetsDto> mapFromJson(dynamic json) {
final map = <String, CheckExistingAssetsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CheckExistingAssetsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CheckExistingAssetsDto-objects as value to a dart map
static Map<String, List<CheckExistingAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CheckExistingAssetsDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CheckExistingAssetsDto.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>{
'deviceAssetIds',
'deviceId',
};
}

View File

@@ -0,0 +1,113 @@
//
// 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 CheckExistingAssetsResponseDto {
/// Returns a new [CheckExistingAssetsResponseDto] instance.
CheckExistingAssetsResponseDto({
this.existingIds = const [],
});
List<String> existingIds;
@override
bool operator ==(Object other) => identical(this, other) || other is CheckExistingAssetsResponseDto &&
other.existingIds == existingIds;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(existingIds.hashCode);
@override
String toString() => 'CheckExistingAssetsResponseDto[existingIds=$existingIds]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'existingIds'] = existingIds;
return _json;
}
/// Returns a new [CheckExistingAssetsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CheckExistingAssetsResponseDto? 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 "CheckExistingAssetsResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "CheckExistingAssetsResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return CheckExistingAssetsResponseDto(
existingIds: json[r'existingIds'] is List
? (json[r'existingIds'] as List).cast<String>()
: const [],
);
}
return null;
}
static List<CheckExistingAssetsResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <CheckExistingAssetsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CheckExistingAssetsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CheckExistingAssetsResponseDto> mapFromJson(dynamic json) {
final map = <String, CheckExistingAssetsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CheckExistingAssetsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CheckExistingAssetsResponseDto-objects as value to a dart map
static Map<String, List<CheckExistingAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CheckExistingAssetsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CheckExistingAssetsResponseDto.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>{
'existingIds',
};
}

View File

@@ -0,0 +1,151 @@
//
// 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 ServerStatsResponseDto {
/// Returns a new [ServerStatsResponseDto] instance.
ServerStatsResponseDto({
required this.photos,
required this.videos,
required this.objects,
required this.usageRaw,
required this.usage,
this.usageByUser = const [],
});
int photos;
int videos;
int objects;
int usageRaw;
String usage;
List<UsageByUserDto> usageByUser;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerStatsResponseDto &&
other.photos == photos &&
other.videos == videos &&
other.objects == objects &&
other.usageRaw == usageRaw &&
other.usage == usage &&
other.usageByUser == usageByUser;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(photos.hashCode) +
(videos.hashCode) +
(objects.hashCode) +
(usageRaw.hashCode) +
(usage.hashCode) +
(usageByUser.hashCode);
@override
String toString() => 'ServerStatsResponseDto[photos=$photos, videos=$videos, objects=$objects, usageRaw=$usageRaw, usage=$usage, usageByUser=$usageByUser]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'photos'] = photos;
_json[r'videos'] = videos;
_json[r'objects'] = objects;
_json[r'usageRaw'] = usageRaw;
_json[r'usage'] = usage;
_json[r'usageByUser'] = usageByUser;
return _json;
}
/// Returns a new [ServerStatsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ServerStatsResponseDto? 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 "ServerStatsResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "ServerStatsResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return ServerStatsResponseDto(
photos: mapValueOfType<int>(json, r'photos')!,
videos: mapValueOfType<int>(json, r'videos')!,
objects: mapValueOfType<int>(json, r'objects')!,
usageRaw: mapValueOfType<int>(json, r'usageRaw')!,
usage: mapValueOfType<String>(json, r'usage')!,
usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser'])!,
);
}
return null;
}
static List<ServerStatsResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <ServerStatsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ServerStatsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ServerStatsResponseDto> mapFromJson(dynamic json) {
final map = <String, ServerStatsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ServerStatsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ServerStatsResponseDto-objects as value to a dart map
static Map<String, List<ServerStatsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ServerStatsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ServerStatsResponseDto.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',
'objects',
'usageRaw',
'usage',
'usageByUser',
};
}

View File

@@ -0,0 +1,151 @@
//
// 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 UsageByUserDto {
/// Returns a new [UsageByUserDto] instance.
UsageByUserDto({
required this.userId,
required this.objects,
required this.videos,
required this.photos,
required this.usageRaw,
required this.usage,
});
String userId;
int objects;
int videos;
int photos;
int usageRaw;
String usage;
@override
bool operator ==(Object other) => identical(this, other) || other is UsageByUserDto &&
other.userId == userId &&
other.objects == objects &&
other.videos == videos &&
other.photos == photos &&
other.usageRaw == usageRaw &&
other.usage == usage;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(userId.hashCode) +
(objects.hashCode) +
(videos.hashCode) +
(photos.hashCode) +
(usageRaw.hashCode) +
(usage.hashCode);
@override
String toString() => 'UsageByUserDto[userId=$userId, objects=$objects, videos=$videos, photos=$photos, usageRaw=$usageRaw, usage=$usage]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'userId'] = userId;
_json[r'objects'] = objects;
_json[r'videos'] = videos;
_json[r'photos'] = photos;
_json[r'usageRaw'] = usageRaw;
_json[r'usage'] = usage;
return _json;
}
/// Returns a new [UsageByUserDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UsageByUserDto? 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 "UsageByUserDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "UsageByUserDto[$key]" has a null value in JSON.');
});
return true;
}());
return UsageByUserDto(
userId: mapValueOfType<String>(json, r'userId')!,
objects: mapValueOfType<int>(json, r'objects')!,
videos: mapValueOfType<int>(json, r'videos')!,
photos: mapValueOfType<int>(json, r'photos')!,
usageRaw: mapValueOfType<int>(json, r'usageRaw')!,
usage: mapValueOfType<String>(json, r'usage')!,
);
}
return null;
}
static List<UsageByUserDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <UsageByUserDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UsageByUserDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UsageByUserDto> mapFromJson(dynamic json) {
final map = <String, UsageByUserDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UsageByUserDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UsageByUserDto-objects as value to a dart map
static Map<String, List<UsageByUserDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UsageByUserDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UsageByUserDto.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>{
'userId',
'objects',
'videos',
'photos',
'usageRaw',
'usage',
};
}

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 AssetCountResponseDto
void main() {
// final instance = AssetCountResponseDto();
group('test AssetCountResponseDto', () {
// int photos
test('to test the property `photos`', () async {
// TODO
});
// int videos
test('to test the property `videos`', () 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 CheckExistingAssetsDto
void main() {
// final instance = CheckExistingAssetsDto();
group('test CheckExistingAssetsDto', () {
// List<String> deviceAssetIds (default value: const [])
test('to test the property `deviceAssetIds`', () async {
// TODO
});
// String deviceId
test('to test the property `deviceId`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,27 @@
//
// 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 CheckExistingAssetsResponseDto
void main() {
// final instance = CheckExistingAssetsResponseDto();
group('test CheckExistingAssetsResponseDto', () {
// List<String> existingIds (default value: const [])
test('to test the property `existingIds`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,42 @@
//
// 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 ServerStatsResponseDto
void main() {
// final instance = ServerStatsResponseDto();
group('test ServerStatsResponseDto', () {
// int photos
test('to test the property `photos`', () async {
// TODO
});
// int videos
test('to test the property `videos`', () async {
// TODO
});
// int objects
test('to test the property `objects`', () async {
// TODO
});
// UsagePerUser diskUsagesByUser
test('to test the property `diskUsagesByUser`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,42 @@
//
// 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 UsageByUserDto
void main() {
// final instance = UsageByUserDto();
group('test UsageByUserDto', () {
// int usageRaw
test('to test the property `usageRaw`', () async {
// TODO
});
// num objects
test('to test the property `objects`', () async {
// TODO
});
// num videos
test('to test the property `videos`', () async {
// TODO
});
// num images
test('to test the property `images`', () async {
// TODO
});
});
}

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.32.0+50
version: 1.33.0+52
environment:
sdk: ">=2.17.0 <3.0.0"

View File

@@ -1,5 +1,8 @@
FROM docker.io/nginxinc/nginx-unprivileged:latest
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
COPY nginx.conf "/etc/nginx/nginx.conf"
CMD nginx -g "daemon off;"

21
nginx/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,4 +1,5 @@
node_modules/
upload/
dist/
coverage/
.reverse-geocoding-dump

View File

@@ -1,4 +1,3 @@
# Build stage
FROM node:16-alpine3.14 as builder
WORKDIR /usr/src/app
@@ -12,16 +11,18 @@ COPY . .
RUN npm run build
# Prod stage
FROM node:16-alpine3.14
WORKDIR /usr/src/app
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
COPY package.json package-lock.json ./
COPY start-server.sh start-microservices.sh ./
RUN mkdir -p /usr/src/app/dist \
&& mkdir /usr/src/app/.reverse-geocoding-dump \
&& apk add --no-cache libheif vips ffmpeg
COPY --from=builder /usr/src/app/node_modules ./node_modules
@@ -29,6 +30,11 @@ COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production
RUN chown -R node:0 /usr/src/app \
&& chmod -R g=u /usr/src/app
RUN addgroup node root
VOLUME /usr/src/app/upload
EXPOSE 3001

21
server/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -137,6 +137,7 @@ describe('Album service', () => {
getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);

View File

@@ -10,6 +10,9 @@ import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group
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';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { In } from 'typeorm/find-options/operator/In';
export interface IAssetRepository {
create(
@@ -32,6 +35,7 @@ export interface IAssetRepository {
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<CheckExistingAssetsResponseDto>;
}
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@@ -279,4 +283,17 @@ export class AssetRepository implements IAssetRepository {
relations: ['exifInfo'],
});
}
async getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<CheckExistingAssetsResponseDto> {
const existingAssets = await this.assetRepository.find({
select: {deviceAssetId: true},
where: {
deviceAssetId: In(checkDuplicateAssetDto.deviceAssetIds),
deviceId: checkDuplicateAssetDto.deviceId,
userId,
},
});
return new CheckExistingAssetsResponseDto(existingAssets.map(a => a.deviceAssetId));
}
}

View File

@@ -48,6 +48,8 @@ import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-buck
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';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -74,6 +76,7 @@ export class AssetController {
@GetAuthUser() authUser: AuthUserDto,
@UploadedFile() file: Express.Multer.File,
@Body(ValidationPipe) assetInfo: CreateAssetDto,
@Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> {
const checksum = await this.assetService.calculateChecksum(file.path);
@@ -111,6 +114,7 @@ export class AssetController {
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum);
res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
return new AssetFileUploadResponseDto(existedAsset.id);
}
@@ -182,6 +186,7 @@ export class AssetController {
async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getAssetCountByUserId(authUser);
}
/**
* Get all AssetEntity belong to the user
*/
@@ -253,4 +258,16 @@ export class AssetController {
): Promise<CheckDuplicateAssetResponseDto> {
return await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
}
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('/exist')
@HttpCode(200)
async checkExistingAssets(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return await this.assetService.checkExistingAssets(authUser, checkExistingAssetsDto);
}
}

View File

@@ -110,6 +110,7 @@ describe('AssetService', () => {
getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
};
sui = new AssetService(assetRepositoryMock, a);

View File

@@ -37,6 +37,8 @@ import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-buck
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';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
const fileInfo = promisify(stat);
@@ -466,6 +468,13 @@ export class AssetService {
return new CheckDuplicateAssetResponseDto(isDuplicated, res?.id);
}
async checkExistingAssets(
authUser: AuthUserDto,
checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this._assetRepository.getExistingAssets(authUser.id, checkExistingAssetsDto);
}
async getAssetCountByTimeBucket(
authUser: AuthUserDto,
getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto,

View File

@@ -0,0 +1,9 @@
import { IsNotEmpty } from 'class-validator';
export class CheckExistingAssetsDto {
@IsNotEmpty()
deviceAssetIds!: string[];
@IsNotEmpty()
deviceId!: string;
}

View File

@@ -0,0 +1,7 @@
export class CheckExistingAssetsResponseDto {
constructor(existingIds: string[]) {
this.existingIds = existingIds;
}
existingIds: string[];
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common';
import { Body, Controller, Post, Res, UseGuards, ValidationPipe, Ip } from '@nestjs/common';
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';
@@ -19,9 +19,10 @@ export class AuthController {
@Post('/login')
async login(
@Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
@Ip() clientIp: string,
@Res() response: Response,
): Promise<LoginResponseDto> {
const loginResponse = await this.authService.login(loginCredential);
const loginResponse = await this.authService.login(loginCredential, clientIp);
// Set Cookies
const accessTokenCookie = this.authService.getCookieWithJwtToken(loginResponse);

View File

@@ -50,10 +50,11 @@ export class AuthService {
return null;
}
public async login(loginCredential: LoginCredentialDto): Promise<LoginResponseDto> {
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
const validatedUser = await this.validateUser(loginCredential);
if (!validatedUser) {
Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
throw new BadRequestException('Incorrect email or password');
}

View File

@@ -1,10 +1,10 @@
import { ApiResponseProperty } from '@nestjs/swagger';
export class LogoutResponseDto {
constructor (successful: boolean) {
this.successful = successful;
}
constructor(successful: boolean) {
this.successful = successful;
}
@ApiResponseProperty()
successful!: boolean;
};
@ApiResponseProperty()
successful!: boolean;
}

View File

@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { UsageByUserDto } from './usage-by-user-response.dto';
export class ServerStatsResponseDto {
constructor() {
this.photos = 0;
this.videos = 0;
this.objects = 0;
this.usageByUser = [];
this.usageRaw = 0;
this.usage = '';
}
@ApiProperty({ type: 'integer' })
photos!: number;
@ApiProperty({ type: 'integer' })
videos!: number;
@ApiProperty({ type: 'integer' })
objects!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
usageRaw!: number;
@ApiProperty({ type: 'string' })
usage!: string;
@ApiProperty({
isArray: true,
type: UsageByUserDto,
title: 'Array of usage for each user',
example: [
{
photos: 1,
videos: 1,
objects: 1,
diskUsageRaw: 1,
},
],
})
usageByUser!: UsageByUserDto[];
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
export class UsageByUserDto {
constructor(userId: string) {
this.userId = userId;
this.objects = 0;
this.videos = 0;
this.photos = 0;
}
@ApiProperty({ type: 'string' })
userId: string;
@ApiProperty({ type: 'integer' })
objects: number;
@ApiProperty({ type: 'integer' })
videos: number;
@ApiProperty({ type: 'integer' })
photos: number;
@ApiProperty({ type: 'integer', format: 'int64' })
usageRaw!: number;
@ApiProperty({ type: 'string' })
usage!: string;
}

View File

@@ -1,10 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseGuards } 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 { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
@ApiTags('Server Info')
@Controller('server-info')
@@ -25,4 +28,11 @@ export class ServerInfoController {
async getServerVersion(): Promise<ServerVersionReponseDto> {
return serverVersion;
}
@UseGuards(JwtAuthGuard)
@UseGuards(AdminRolesGuard)
@Get('/stats')
async getStats(): Promise<ServerStatsResponseDto> {
return await this.serverInfoService.getStats();
}
}

View File

@@ -1,8 +1,13 @@
import { Module } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { ServerInfoController } from './server-info.controller';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { UserEntity } from '@app/database/entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity]), ImmichJwtModule],
controllers: [ServerInfoController],
providers: [ServerInfoService],
})

View File

@@ -2,9 +2,21 @@ import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { Injectable } from '@nestjs/common';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import diskusage from 'diskusage';
import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
import { UsageByUserDto } from './response-dto/usage-by-user-response.dto';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import path from 'path';
import { readdirSync, statSync } from 'fs';
@Injectable()
export class ServerInfoService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
async getServerInfo(): Promise<ServerInfoResponseDto> {
const diskInfo = await diskusage.check(APP_UPLOAD_LOCATION);
@@ -18,7 +30,6 @@ export class ServerInfoService {
serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
return serverInfo;
}
@@ -48,4 +59,61 @@ export class ServerInfoService {
return `${sizeInByte}B`;
}
}
async getStats(): Promise<ServerStatsResponseDto> {
const res = await this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.addSelect(`asset.userId`, 'userId')
.groupBy('asset.type, asset.userId')
.addGroupBy('asset.type')
.getRawMany();
const serverStats = new ServerStatsResponseDto();
const tmpMap = new Map<string, UsageByUserDto>();
const getUsageByUser = (id: string) => tmpMap.get(id) || new UsageByUserDto(id);
res.map((item) => {
const usage: UsageByUserDto = getUsageByUser(item.userId);
if (item.type === 'IMAGE') {
usage.photos = parseInt(item.count);
serverStats.photos += usage.photos;
} else if (item.type === 'VIDEO') {
usage.videos = parseInt(item.count);
serverStats.videos += usage.videos;
}
tmpMap.set(item.userId, usage);
});
for (const userId of tmpMap.keys()) {
const usage = getUsageByUser(userId);
const userDiskUsage = await ServerInfoService.getDirectoryStats(path.join(APP_UPLOAD_LOCATION, userId));
usage.usageRaw = userDiskUsage.size;
usage.objects = userDiskUsage.fileCount;
usage.usage = ServerInfoService.getHumanReadableString(usage.usageRaw);
serverStats.usageRaw += usage.usageRaw;
serverStats.objects += usage.objects;
}
serverStats.usage = ServerInfoService.getHumanReadableString(serverStats.usageRaw);
serverStats.usageByUser = Array.from(tmpMap.values());
return serverStats;
}
private static async getDirectoryStats(dirPath: string) {
let size = 0;
let fileCount = 0;
for (const filename of readdirSync(dirPath)) {
const absFilename = path.join(dirPath, filename);
const fileStat = statSync(absFilename);
if (fileStat.isFile()) {
size += fileStat.size;
fileCount += 1;
} else if (fileStat.isDirectory()) {
const subDirStat = await ServerInfoService.getDirectoryStats(absFilename);
size += subDirStat.size;
fileCount += subDirStat.fileCount;
}
}
return { size, fileCount };
}
}

View File

@@ -3,13 +3,13 @@ import { validate } from 'class-validator';
import { CreateUserDto } from './create-user.dto';
describe('create user DTO', () => {
it('validates the email', async() => {
it('validates the email', async () => {
const params: Partial<CreateUserDto> = {
email: undefined,
password: 'password',
firstName: 'first name',
lastName: 'last name',
}
};
let dto: CreateUserDto = plainToInstance(CreateUserDto, params);
let errors = await validate(dto);
expect(errors).toHaveLength(1);

View File

@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';
import { UpdateUserDto } from './dto/update-user.dto'
import { UpdateUserDto } from './dto/update-user.dto';
export interface IUserRepository {
get(userId: string): Promise<UserEntity | null>;
@@ -92,4 +92,4 @@ export class UserRepository implements IUserRepository {
user.profileImagePath = fileInfo.path;
return this.userRepository.save(user);
}
}
}

View File

@@ -17,8 +17,8 @@ import { UserRepository, USER_REPOSITORY } from './user-repository';
ImmichJwtService,
{
provide: USER_REPOSITORY,
useClass: UserRepository
}
useClass: UserRepository,
},
],
})
export class UserModule {}

View File

@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = {
major: 1,
minor: 32,
minor: 33,
patch: 0,
build: 50,
build: 52,
};

View File

@@ -65,7 +65,11 @@ export class ThumbnailGeneratorProcessor {
if (asset.type == AssetType.IMAGE) {
try {
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
await sharp(asset.originalPath, { failOnError: false })
.resize(1440, 2560, { fit: 'inside' })
.jpeg()
.rotate()
.toFile(jpegThumbnailPath);
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
} catch (error) {
Logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id);
@@ -135,7 +139,7 @@ export class ThumbnailGeneratorProcessor {
const webpPath = asset.resizePath.replace('jpeg', 'webp');
try {
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
await sharp(asset.resizePath, { failOnError: false }).resize(250).webp().rotate().toFile(webpPath);
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
} catch (error) {
Logger.error('Failed to generate webp thumbnail for asset: ' + asset.id);

File diff suppressed because one or more lines are too long

View File

@@ -1,20 +1,20 @@
import { Logger } from '@nestjs/common';
import { ConfigModuleOptions } from '@nestjs/config';
import Joi from 'joi';
import { createSecretKey, generateKeySync } from 'node:crypto'
import { createSecretKey, generateKeySync } from 'node:crypto';
const jwtSecretValidator: Joi.CustomValidator<string> = (value, ) => {
const key = createSecretKey(value, "base64")
const keySizeBits = (key.symmetricKeySize ?? 0) * 8
const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
const key = createSecretKey(value, 'base64');
const keySizeBits = (key.symmetricKeySize ?? 0) * 8;
if (keySizeBits < 128) {
const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64')
Logger.warn("The current JWT_SECRET key is insecure. It should be at least 128 bits long!")
Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`)
const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64');
Logger.warn('The current JWT_SECRET key is insecure. It should be at least 128 bits long!');
Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`);
}
return value;
}
};
export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env',
@@ -26,7 +26,7 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_DATABASE_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().required().custom(jwtSecretValidator),
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
}),
};

View File

@@ -16,7 +16,7 @@ export class AlbumEntity {
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: string;
@Column({ comment: 'Asset ID to be used as thumbnail', type: 'varchar', nullable: true})
@Column({ comment: 'Asset ID to be used as thumbnail', type: 'varchar', nullable: true })
albumThumbnailAssetId!: string | null;
@OneToMany(() => UserAlbumEntity, (userAlbums) => userAlbums.albumInfo)

View File

@@ -1,4 +1,4 @@
import {Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique} from 'typeorm';
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { AlbumEntity } from './album.entity';
import { AssetEntity } from './asset.entity';

View File

@@ -1,15 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm"
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameAssetAlbumIdSequence1656888591977 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter sequence asset_shared_album_id_seq rename to asset_album_id_seq;`);
await queryRunner.query(
`alter table asset_album alter column id set default nextval('asset_album_id_seq'::regclass);`,
);
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter sequence asset_shared_album_id_seq rename to asset_album_id_seq;`);
await queryRunner.query(`alter table asset_album alter column id set default nextval('asset_album_id_seq'::regclass);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter sequence asset_album_id_seq rename to asset_shared_album_id_seq;`);
await queryRunner.query(`alter table asset_album alter column id set default nextval('asset_shared_album_id_seq'::regclass);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter sequence asset_album_id_seq rename to asset_shared_album_id_seq;`);
await queryRunner.query(
`alter table asset_album alter column id set default nextval('asset_shared_album_id_seq'::regclass);`,
);
}
}

View File

@@ -1,13 +1,12 @@
import { MigrationInterface, QueryRunner } from "typeorm";
import { MigrationInterface, QueryRunner } from 'typeorm';
export class DropExifTextSearchableColumns1656888918620 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exif_text_searchable_column"`);
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exif_text_searchable_column"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
@@ -29,6 +28,5 @@ export class DropExifTextSearchableColumns1656888918620 implements MigrationInte
ON exif
USING GIN (exif_text_searchable_column);
`);
}
}
}

View File

@@ -1,9 +1,8 @@
import { MigrationInterface, QueryRunner } from "typeorm";
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MatchMigrationsWithTypeORMEntities1656889061566 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
@@ -11,36 +10,63 @@ export class MatchMigrationsWithTypeORMEntities1656889061566 implements Migratio
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", ''))) STORED`);
await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","exifTextSearchableColumn","postgres","public","exif"]);
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["postgres","public","exif","GENERATED_COLUMN","exifTextSearchableColumn","TO_TSVECTOR('english',\n COALESCE(make, '') || ' ' ||\n COALESCE(model, '') || ' ' ||\n COALESCE(orientation, '') || ' ' ||\n COALESCE(\"lensModel\", '') || ' ' ||\n COALESCE(\"city\", '') || ' ' ||\n COALESCE(\"state\", '') || ' ' ||\n COALESCE(\"country\", ''))"]);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288"`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_7ae4e03729895bf87e056d7b598" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
await queryRunner.query(
`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`,
['GENERATED_COLUMN', 'exifTextSearchableColumn', 'postgres', 'public', 'exif'],
);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`,
[
'postgres',
'public',
'exif',
'GENERATED_COLUMN',
'exifTextSearchableColumn',
"TO_TSVECTOR('english',\n COALESCE(make, '') || ' ' ||\n COALESCE(model, '') || ' ' ||\n COALESCE(orientation, '') || ' ' ||\n COALESCE(\"lensModel\", '') || ' ' ||\n COALESCE(\"city\", '') || ' ' ||\n COALESCE(\"state\", '') || ' ' ||\n COALESCE(\"country\", ''))",
],
);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288"`);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`,
);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_7ae4e03729895bf87e056d7b598" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" DROP NOT NULL`);
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","exifTextSearchableColumn","immich","public","exif"]);
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_unique_asset_in_album"`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" DROP NOT NULL`);
await queryRunner.query(
`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`,
['GENERATED_COLUMN', 'exifTextSearchableColumn', 'immich', 'public', 'exif'],
);
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_unique_asset_in_album"`);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`,
);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
}

View File

@@ -1,16 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAssetChecksum1661881837496 implements MigrationInterface {
name = 'AddAssetChecksum1661881837496'
name = 'AddAssetChecksum1661881837496';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "checksum" bytea`);
await queryRunner.query(`CREATE INDEX "IDX_64c507300988dd1764f9a6530c" ON "assets" ("checksum") WHERE 'checksum' IS NOT NULL`);
await queryRunner.query(
`CREATE INDEX "IDX_64c507300988dd1764f9a6530c" ON "assets" ("checksum") WHERE 'checksum' IS NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`);
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "checksum"`);
}
}

View File

@@ -1,7 +1,7 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements MigrationInterface {
name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662'
name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e"`);
@@ -10,7 +10,8 @@ export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements Mig
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_userid_checksum"`);
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e" UNIQUE ("deviceAssetId", "userId", "deviceId")`);
await queryRunner.query(
`ALTER TABLE "assets" ADD CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e" UNIQUE ("deviceAssetId", "userId", "deviceId")`,
);
}
}

View File

@@ -1,11 +1,13 @@
# Our Node base image
FROM node:16-alpine3.14 as base
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
WORKDIR /usr/src/app
RUN chown node:node /usr/src/app
RUN apk add --no-cache setpriv
RUN chown node:node /usr/src/app && \
apk add --no-cache setpriv
COPY --chown=node:node package*.json ./
@@ -13,7 +15,11 @@ RUN npm ci
COPY --chown=node:node . .
RUN npm run build
RUN npm run build \
&& chown -R node:0 /usr/src/app \
&& chmod -R g=u /usr/src/app
RUN addgroup node root
EXPOSE 3000

21
web/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -452,6 +452,38 @@ export interface CheckDuplicateAssetResponseDto {
*/
'id'?: string;
}
/**
*
* @export
* @interface CheckExistingAssetsDto
*/
export interface CheckExistingAssetsDto {
/**
*
* @type {Array<string>}
* @memberof CheckExistingAssetsDto
*/
'deviceAssetIds': Array<string>;
/**
*
* @type {string}
* @memberof CheckExistingAssetsDto
*/
'deviceId': string;
}
/**
*
* @export
* @interface CheckExistingAssetsResponseDto
*/
export interface CheckExistingAssetsResponseDto {
/**
*
* @type {Array<string>}
* @memberof CheckExistingAssetsResponseDto
*/
'existingIds': Array<string>;
}
/**
*
* @export
@@ -1157,6 +1189,49 @@ export interface ServerPingResponse {
*/
'res': string;
}
/**
*
* @export
* @interface ServerStatsResponseDto
*/
export interface ServerStatsResponseDto {
/**
*
* @type {number}
* @memberof ServerStatsResponseDto
*/
'photos': number;
/**
*
* @type {number}
* @memberof ServerStatsResponseDto
*/
'videos': number;
/**
*
* @type {number}
* @memberof ServerStatsResponseDto
*/
'objects': number;
/**
*
* @type {number}
* @memberof ServerStatsResponseDto
*/
'usageRaw': number;
/**
*
* @type {string}
* @memberof ServerStatsResponseDto
*/
'usage': string;
/**
*
* @type {Array<UsageByUserDto>}
* @memberof ServerStatsResponseDto
*/
'usageByUser': Array<UsageByUserDto>;
}
/**
*
* @export
@@ -1365,6 +1440,49 @@ export interface UpdateUserDto {
*/
'profileImagePath'?: string;
}
/**
*
* @export
* @interface UsageByUserDto
*/
export interface UsageByUserDto {
/**
*
* @type {string}
* @memberof UsageByUserDto
*/
'userId': string;
/**
*
* @type {number}
* @memberof UsageByUserDto
*/
'objects': number;
/**
*
* @type {number}
* @memberof UsageByUserDto
*/
'videos': number;
/**
*
* @type {number}
* @memberof UsageByUserDto
*/
'photos': number;
/**
*
* @type {number}
* @memberof UsageByUserDto
*/
'usageRaw': number;
/**
*
* @type {string}
* @memberof UsageByUserDto
*/
'usage': string;
}
/**
*
* @export
@@ -2248,6 +2366,46 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
* @summary
* @param {CheckExistingAssetsDto} checkExistingAssetsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
checkExistingAssets: async (checkExistingAssetsDto: CheckExistingAssetsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'checkExistingAssetsDto' is not null or undefined
assertParamExists('checkExistingAssets', 'checkExistingAssetsDto', checkExistingAssetsDto)
const localVarPath = `/asset/exist`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(checkExistingAssetsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {DeleteAssetDto} deleteAssetDto
@@ -2867,6 +3025,17 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.checkDuplicateAsset(checkDuplicateAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
* @summary
* @param {CheckExistingAssetsDto} checkExistingAssetsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async checkExistingAssets(checkExistingAssetsDto: CheckExistingAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<CheckExistingAssetsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.checkExistingAssets(checkExistingAssetsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {DeleteAssetDto} deleteAssetDto
@@ -3042,6 +3211,16 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
checkDuplicateAsset(checkDuplicateAssetDto: CheckDuplicateAssetDto, options?: any): AxiosPromise<CheckDuplicateAssetResponseDto> {
return localVarFp.checkDuplicateAsset(checkDuplicateAssetDto, options).then((request) => request(axios, basePath));
},
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
* @summary
* @param {CheckExistingAssetsDto} checkExistingAssetsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
checkExistingAssets(checkExistingAssetsDto: CheckExistingAssetsDto, options?: any): AxiosPromise<CheckExistingAssetsResponseDto> {
return localVarFp.checkExistingAssets(checkExistingAssetsDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {DeleteAssetDto} deleteAssetDto
@@ -3204,6 +3383,18 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).checkDuplicateAsset(checkDuplicateAssetDto, options).then((request) => request(this.axios, this.basePath));
}
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
* @summary
* @param {CheckExistingAssetsDto} checkExistingAssetsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public checkExistingAssets(checkExistingAssetsDto: CheckExistingAssetsDto, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).checkExistingAssets(checkExistingAssetsDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {DeleteAssetDto} deleteAssetDto
@@ -4132,6 +4323,35 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getStats: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/server-info/stats`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -4198,6 +4418,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getServerVersion(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getStats(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerStatsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getStats(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@@ -4233,6 +4462,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
getServerVersion(options?: any): AxiosPromise<ServerVersionReponseDto> {
return localVarFp.getServerVersion(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getStats(options?: any): AxiosPromise<ServerStatsResponseDto> {
return localVarFp.getStats(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@@ -4271,6 +4508,16 @@ export class ServerInfoApi extends BaseAPI {
return ServerInfoApiFp(this.configuration).getServerVersion(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ServerInfoApi
*/
public getStats(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).getStats(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.

View File

@@ -48,7 +48,7 @@ html::-webkit-scrollbar-thumb:hover {
body {
/* min-height: 100vh; */
margin: 0;
background-color: #f6f8fe;
/* background-color: #f6f8fe; */
color: #5f6368;
}
@@ -59,15 +59,15 @@ input:focus-visible {
@layer utilities {
.immich-form-input {
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm;
@apply bg-slate-100 p-2 rounded-md dark:text-immich-dark-bg focus:border-immich-primary text-sm;
}
.immich-form-label {
@apply font-medium text-sm text-gray-500;
@apply font-medium text-sm text-gray-500 dark:text-gray-300;
}
.immich-btn-primary {
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium;
@apply bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray text-gray-100 border dark:border-immich-dark-gray rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary dark:hover:bg-immich-dark-primary/90 hover:shadow-lg text-sm font-medium;
}
.immich-text-button {

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
@@ -7,7 +7,7 @@
%sveltekit.head%
</head>
<body>
<body class="bg-immich-bg dark:bg-immich-dark-bg">
<div>%sveltekit.body%</div>
</body>
</html>

View File

@@ -11,24 +11,28 @@
const dispatch = createEventDispatcher();
</script>
<div class="flex border p-6 rounded-2xl bg-white">
<div class="flex border-b pb-5 dark:border-b-immich-dark-gray">
<div class="w-[70%]">
<h1 class="font-medium text-immich-primary">{title}</h1>
<p class="text-sm mt-1 font-medium">{subtitle}</p>
<p class="text-sm">
<h1 class="text-immich-primary dark:text-immich-dark-primary text-sm">{title.toUpperCase()}</h1>
<p class="text-sm mt-1 dark:text-immich-dark-fg">{subtitle}</p>
<p class="text-sm dark:text-immich-dark-fg">
<slot />
</p>
<table class="text-left w-full mt-4">
<table class="text-left w-full mt-5">
<!-- table header -->
<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12">
<thead
class="border rounded-md mb-2 dark:bg-immich-dark-gray dark:border-immich-dark-gray bg-immich-primary/10 flex text-immich-primary dark:text-immich-dark-primary w-full h-12"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/3 font-medium text-sm">Status</th>
<th class="text-center w-1/3 font-medium text-sm">Active</th>
<th class="text-center w-1/3 font-medium text-sm">Waiting</th>
</tr>
</thead>
<tbody class="overflow-y-auto rounded-md w-full max-h-[320px] block border">
<tr class="text-center flex place-items-center w-full h-[40px]">
<tbody
class="overflow-y-auto rounded-md w-full max-h-[320px] block border bg-white dark:border-immich-dark-gray dark:bg-[#e5e5e5] dark:text-immich-dark-bg"
>
<tr class="text-center flex place-items-center w-full h-[60px]">
<td class="text-sm px-2 w-1/3 text-ellipsis">{jobStatus ? 'Active' : 'Idle'}</td>
<td class="text-sm px-2 w-1/3 text-ellipsis">{activeJobCount}</td>
<td class="text-sm px-2 w-1/3 text-ellipsis">{waitingJobCount}</td>
@@ -39,7 +43,7 @@
<div class="w-[30%] flex place-items-center place-content-end">
<button
on:click={() => dispatch('click')}
class="border px-6 py-3 text-sm bg-gray-50 font-medium rounded-2xl hover:bg-immich-primary/10 transition-all hover:cursor-pointer disabled:cursor-not-allowed"
class="px-6 py-3 text-sm bg-immich-primary dark:bg-immich-dark-primary font-medium rounded-2xl hover:bg-immich-primary/50 transition-all hover:cursor-pointer disabled:cursor-not-allowed shadow-sm text-immich-bg dark:text-immich-dark-gray"
disabled={jobStatus}
>
{#if jobStatus}

View File

@@ -106,7 +106,7 @@
};
</script>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-10">
<JobTile
title={'Generate thumbnails'}
subtitle={'Regenerate missing thumbnail (JPEG, WEBP)'}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { ServerStatsResponseDto, UserResponseDto } from '@api';
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
import PlayCircle from 'svelte-material-icons/PlayCircle.svelte';
import FileImageOutline from 'svelte-material-icons/FileImageOutline.svelte';
import Memory from 'svelte-material-icons/Memory.svelte';
import StatsCard from './stats-card.svelte';
export let stats: ServerStatsResponseDto;
export let allUsers: Array<UserResponseDto>;
const getFullName = (userId: string) => {
let name = 'Admin'; // since we do not have admin user in allUsers
allUsers.forEach((user) => {
if (user.id === userId) name = `${user.firstName} ${user.lastName}`;
});
return name;
};
$: spaceUnit = stats.usage.slice(stats.usage.length - 2, stats.usage.length);
$: spaceUsage = stats.usage.slice(0, stats.usage.length - 2);
</script>
<div class="flex flex-col gap-5">
<div>
<p class="text-sm dark:text-immich-dark-fg">TOTAL USAGE</p>
<div class="flex mt-5 justify-between">
<StatsCard logo={CameraIris} title={'PHOTOS'} value={stats.photos.toString()} />
<StatsCard logo={PlayCircle} title={'VIDEOS'} value={stats.videos.toString()} />
<StatsCard logo={FileImageOutline} title={'OBJECTS'} value={stats.objects.toString()} />
<StatsCard logo={Memory} title={'STORAGE'} value={spaceUsage} unit={spaceUnit} />
</div>
</div>
<div>
<p class="text-sm dark:text-immich-dark-fg">USER USAGE DETAIL</p>
<table class="text-left w-full mt-5">
<thead
class="border rounded-md mb-4 bg-gray-50 dark:bg-immich-dark-gray dark:border-immich-dark-gray flex text-immich-primary dark:text-immich-dark-primary w-full h-12"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/5 font-medium text-sm">User</th>
<th class="text-center w-1/5 font-medium text-sm">Photos</th>
<th class="text-center w-1/5 font-medium text-sm">Videos</th>
<th class="text-center w-1/5 font-medium text-sm">Objects</th>
<th class="text-center w-1/5 font-medium text-sm">Size</th>
</tr>
</thead>
<tbody
class="overflow-y-auto rounded-md w-full max-h-[320px] block border dark:border-immich-dark-gray dark:text-immich-dark-bg"
>
{#each stats.usageByUser as user, i}
<tr
class={`text-center flex place-items-center w-full h-[50px] ${
i % 2 == 0 ? 'bg-immich-gray dark:bg-[#e5e5e5]' : 'bg-immich-bg dark:bg-[#eeeeee]'
}`}
>
<td class="text-sm px-2 w-1/5 text-ellipsis">{getFullName(user.userId)}</td>
<td class="text-sm px-2 w-1/5 text-ellipsis">{user.photos}</td>
<td class="text-sm px-2 w-1/5 text-ellipsis">{user.videos}</td>
<td class="text-sm px-2 w-1/5 text-ellipsis">{user.objects}</td>
<td class="text-sm px-2 w-1/5 text-ellipsis">{user.usage}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let logo: any;
export let title: string;
export let value: string;
export let unit: string | undefined = undefined;
$: zeros = () => {
let result = '';
const maxLength = 9;
const valueLength = parseInt(value).toString().length;
const zeroLength = maxLength - valueLength;
for (let i = 0; i < zeroLength; i++) {
result += '0';
}
return result;
};
</script>
<div
class="w-[180px] h-[140px] bg-immich-gray dark:bg-immich-dark-gray rounded-3xl p-5 flex flex-col justify-between"
>
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<svelte:component this={logo} size="40" />
<p>{title}</p>
</div>
<div class="relative text-center font-mono font-semibold text-2xl">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{parseInt(value)}</span
>
{#if unit}
<span class="absolute -top-5 right-2 text-base font-light text-gray-400">{unit}</span>
{/if}
</div>
</div>

View File

@@ -8,8 +8,10 @@
const dispatch = createEventDispatcher();
</script>
<table class="text-left w-full my-4">
<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12 ">
<table class="text-left w-full my-5">
<thead
class="border rounded-md mb-4 bg-gray-50 flex text-immich-primary w-full h-12 dark:bg-immich-dark-gray dark:text-immich-dark-primary dark:border-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/4 font-medium text-sm">Email</th>
<th class="text-center w-1/4 font-medium text-sm">First name</th>
@@ -17,11 +19,13 @@
<th class="text-center w-1/4 font-medium text-sm">Edit</th>
</tr>
</thead>
<tbody class="overflow-y-auto rounded-md w-full max-h-[320px] block border">
<tbody
class="overflow-y-auto rounded-md w-full max-h-[320px] block border dark:border-immich-dark-gray"
>
{#each allUsers as user, i}
<tr
class={`text-center flex place-items-center w-full border-b h-[80px] ${
i % 2 == 0 ? 'bg-gray-100' : 'bg-immich-bg'
class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-bg ${
i % 2 == 0 ? 'bg-immich-gray dark:bg-[#e5e5e5]' : 'bg-immich-bg dark:bg-[#eeeeee]'
}`}
>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td>
@@ -32,7 +36,7 @@
on:click={() => {
dispatch('edit-user', { user });
}}
class="bg-immich-primary text-gray-100 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><PencilOutline size="20" /></button
></td
>

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