Compare commits

...

70 Commits

Author SHA1 Message Date
Alex The Bot
e9a8daa924 Version v1.91.0 2023-12-15 15:22:37 +00:00
Alex
c2cda5f3b0 fix(web): map thumbnail stretch (#5721) 2023-12-15 09:17:28 -06:00
bo0tzz
e29b80845b chore: Update docs url config (#5716) 2023-12-15 08:57:36 -06:00
dependabot[bot]
6c3bfc6f0f chore(deps): bump actions/upload-artifact from 3 to 4 (#5719)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-15 08:56:47 -05:00
dependabot[bot]
9cc904fb2a chore(deps): bump actions/download-artifact from 3 to 4 (#5718)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-15 08:56:11 -05:00
Mert
3e54ee5052 feat(ml): add new models (#5710) 2023-12-14 23:34:41 -06:00
Mert
458257847e fix(server): disable classification by default (#5708)
* disable classification by default

* fixed tests
2023-12-14 23:34:18 -06:00
renovate[bot]
16f385626e chore(deps): update dependency sql-formatter to v15 (#5709)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-15 00:28:05 -05:00
renovate[bot]
e05c7f5b76 chore(deps): update mambaorg/micromamba:bookworm-slim docker digest to 5ea70d2 (#5707)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-14 23:09:48 -05:00
renovate[bot]
5c8eaa6859 chore(deps): update base-image to v20231214 (#5705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-14 21:22:06 -06:00
martin
4c5397d7e6 feat(web): add types to dispatcher (#5700)
* feat: add types to dispatcher

* fix: create album name

* pr feedback

* pr feedback

* pr feedback

* fix: api key name

* remove newSharedAlbum

* pr feedback

* fix: api key creation

* on:close

* fix: owner

* fix: onclose

* remove unused code

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-14 20:54:21 -06:00
martin
502495883d fix(web): log out (#5706)
* fix: logging out

* fix: websocket

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-15 02:29:05 +00:00
bo0tzz
2836b8cda9 fix: Catchall category for release notes (#5701) 2023-12-14 14:01:17 -06:00
bo0tzz
e4ee224e16 feat(ci): Automatic categories in generated release notes (#5684)
* feat(ci): Automatic categories in generated release notes

* ci: Enforce PR labels

* chore: Job name

* fix: Label names
2023-12-14 13:54:37 -06:00
Mert
d729c863c8 chore(ml): improve shutdown (#5689) 2023-12-14 13:51:24 -06:00
Jason Rasmussen
9768931275 feat(web,server)!: runtime log level (#5672)
* feat: change log level at runtime

* chore: open api

* chore: prefer env over runtime

* chore: remove default env value
2023-12-14 16:55:40 +00:00
martin
f2270ad757 fix(web): user management responsive design (#5698)
* fix: user management tailwind

* use top instead of inset-y-0

* add types to createEventDispatcher
2023-12-14 10:55:15 -06:00
Po-Ru, Lin
8e39d389b5 feat: lazy loading on album/sharing/search (#5696)
* feat(frontend): Lazy loading on album

* feat(frontend): Lazy loading on search & sharing

Issue #5418
2023-12-14 10:48:29 -06:00
Jason Rasmussen
9bb6befc92 docs: clean-up old references (#5697)
* docs: clean-up old references

* chore: fix ref
2023-12-14 09:53:08 -05:00
waclaw66
3a2e9b6298 feat(web): increase map max zoom (#5693)
* increate max zoom

* increase max zoom on mobile
2023-12-14 08:40:56 -05:00
dependabot[bot]
a4c057ba63 chore(deps): bump github/codeql-action from 2 to 3 (#5694)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-14 08:08:22 -05:00
Slavik
679b22fada docs: remove citiesFileOverride from config-file.md; fixes #5682 (#5683)
as a result of PR #5301, the citiesFileOverride  variable is not used anymore.
2023-12-13 14:54:51 -05:00
Jason Rasmussen
b34abf25f0 feat(server): server-side events (#5669) 2023-12-13 12:23:51 -05:00
Jason Rasmussen
36196f2a5d fix(immich-admin): in dev mode (#5670) 2023-12-13 11:10:00 -06:00
martin
f13dce7d0d fix: warning when building web (#5680) 2023-12-13 12:02:26 -05:00
Jan
e5e6fcc46d #5519 shared page redirect to host (#5678)
* #5519 shared page redirect to host

* #5519 file formatting
2023-12-13 16:15:39 +00:00
martin
523d01068f fix(web): no icon with firefox (#5679)
* fix: no icon with firefox

* remove FaviconHeader.svelte
2023-12-13 11:04:06 -05:00
Alex
885eba2b7c fix(mobile): simplify state management in backup selection page (#5655)
* fix(mobile): simplify album selection backup state management

* remove search bar'

* log available albums
2023-12-12 21:06:04 -06:00
Alex
f7429c3615 docs: recap 2023 (#5665)
* docs: recap 2023

* fix: format
2023-12-13 03:03:15 +00:00
shenlong
ec0526dbcb chore(mobile): move mocktail to dev dep (#5666)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-12 20:49:14 -06:00
martin
7a9a9473d1 fix: prevent loop on search (#5664) 2023-12-12 16:55:18 -06:00
Jason Rasmussen
a0b2cbe123 feat: update arch diagram (#5662)
* feat: update arch diagram

* chore: alt test
2023-12-12 16:16:39 -06:00
bo0tzz
05550647eb docs: FAQ for thumbnail jobs (#5661) 2023-12-12 22:39:56 +01:00
Alex
c7df800d27 fix(mobile): Fix upload hang on iOS when deleting stale files (#5658)
* fix(mobile): Fix upload hang on iOS when deleting stale files

* Cleaner fix
2023-12-12 11:36:37 -06:00
martin
c602eaea4a feat(web): automatically update user info (#5647)
* use svelte store

* fix: websocket error when not authenticated

* more routes
2023-12-12 10:35:28 -06:00
Jason Rasmussen
cbca69841a refactor(server): immich file responses (#5641)
* refactor(server): immich file response

* chore: open api

* chore: tests

* chore: fix logger import

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-12 08:58:25 -06:00
Alex
af7c4ae090 fix(mobile): better error message (#5653) 2023-12-12 08:58:13 -06:00
martin
fba9e784fb feat: use <a> tag for albums in list view (#5645)
* fix: multiple improvements

* pr feedback

* optimize
2023-12-11 20:35:57 -06:00
shenlong
fb4b4e5895 fix: handle livePhotos using originFileWithSubType (#5602)
* fix: handle livePhotos using originFileWithSubType

* remove livePhoto asset cache

* fetch live photo video name from entity

* fix: video file not detected

* chore: pull main

* fix: set correct header

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-12-11 20:20:36 -06:00
Alex Tran
c8da1c07dc chore: add _redirects to docs for CloudFlare 2023-12-11 16:13:39 -06:00
Alex Tran
ed05785005 chore: remove orphaned package-lock.json 2023-12-11 16:01:33 -06:00
Jason Rasmussen
81603fddc8 chore: fix ssr in dev (#5637) 2023-12-11 14:19:27 -06:00
Jason Rasmussen
ed4358741e feat(web): re-add open graph tags for public share links (#5635)
* feat: re-add open graph tags for public share links

* fix: undefined in html

* chore: tests
2023-12-11 13:37:47 -06:00
renovate[bot]
ac2a36bd53 chore(deps): pin tensorchord/pgvecto-rs docker tag to 0335a1a (#5632)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-11 14:00:46 -05:00
Łukasz Wawrzyk
f6ef226b64 chore(mobile): put delete button before metadata editing (#5633) 2023-12-11 12:53:11 -06:00
pjsxw
f798e9beed docs: update backup-and-restore.md (#5616)
Removes `BACKUP_KEEP_NUM` option from docker-compose example for database dumping, since it no longer exists in the linked image. 

The image has sensible defaults for backups to keep (7 daily, 4 weekly, 6 monthly), so I haven't replaced the argument with an alternative.
2023-12-11 10:57:30 -06:00
renovate[bot]
08570875eb chore(deps): update redis:6.2-alpine docker digest to b6124ab (#5599)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-11 10:38:26 -06:00
Alex
64e985d600 fix(mobile): Revert - upload motion and live part of LivePhotos together (#5601) 2023-12-11 10:38:02 -06:00
Sushain Cherivirala
e3e4fb40fd fix(server): don't associate assets with Null Island (#5623)
* Don't associate assets with Null Island

* Fix lint
2023-12-11 09:00:23 -06:00
shenlong
960b68b02f fix(mobile): live / motion photo download (#5607)
* reverts: 5566

* fix: stitch livePhoto only in iOS

* fix: PMProgressHandler only on iOS

* ios: fallback to saving image if livephoto fails

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-10 09:56:39 -06:00
Jason Rasmussen
33529d1d9b refactor(server): auth dto (#5593)
* refactor: AuthUserDto => AuthDto

* refactor: reorganize auth-dto

* refactor: AuthUser() => Auth()
2023-12-09 23:34:12 -05:00
Mert
8057c375ba docs: remove typesense from faq (#5600) 2023-12-10 03:23:40 +00:00
renovate[bot]
d2ad01cd2f chore(deps): update python:3.11-slim-bookworm docker digest to cfd7ed5 (#5572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-09 21:15:15 -06:00
Jason Rasmussen
8e07b35786 docs: add sponsor link (#5597) 2023-12-09 20:59:00 -06:00
Jason Rasmussen
b7b4483a33 fix(server): connection aborted logging (#5595) 2023-12-09 20:46:56 -06:00
shenlong
3a794d7a2b fix: use avatarColor as the text background when no avatar available (#5566)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-09 20:32:39 -06:00
shenlong
68c0112aaa fix(mobile): memory lane not displayed in mobile app (#5587)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-09 20:32:20 -06:00
shenlong
8847ebeef2 fix(mobile): mobile album sort not persisting (#5584)
* chore(deps): use mocktail instead of mockito

* refactor: move stubs to fixtures/

* fix: fetch assetsortmode based on storeindex

* test: validate AlbumSortByOptions provider

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-09 20:31:23 -06:00
Jon Howell
188cdf9367 docs: Add tip showing common error messages (#5592)
I encountered two errors trying to use Ubuntu-distributed docker. These tips make the error messages searchable, with a pointer to the solution (use docker's packages).
2023-12-10 01:05:58 +00:00
Jason Rasmussen
6acd8eb4ba chore(server): faster shutdown (#5577)
* chore(server): faster shutdown

* fix: e2e test entrypoint
2023-12-08 21:58:07 -05:00
Jason Rasmussen
92b4284b5a feat(server): use postgres-adapter for websockets (#5569)
* feat(server): use postgres-adapter for websockets

* refactor: create attachment table via migration
2023-12-08 20:38:25 -05:00
Alex
a426ea8fbc fix(web): remove always on delay badge (#5574) 2023-12-08 18:19:42 -06:00
Alex
f206cb9403 chore(server): simplify search face query and better clustering (#5573)
* chore(server): simplify search face query and better clustering

* update sql

* Use correct syntax for utilizing the index

* Update sql
2023-12-08 16:26:17 -06:00
renovate[bot]
2234394aa6 chore(deps): pin tensorchord/pgvecto-rs docker tag to 0335a1a (#5570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-08 14:38:45 -05:00
renovate[bot]
8a6284569a chore(deps): pin dependencies (#5567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-08 14:24:17 -05:00
renovate[bot]
80591ea609 fix(deps): update server (#5506)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-08 13:57:05 -05:00
Mert
2553c54b26 fix(server): select asset face columns explicitly (#5564)
* select columns explicitly

* updated sql

* formatting
2023-12-08 12:43:35 -06:00
Jason Rasmussen
2f4ee622ab chore(deps): remove unused cron types (#5563) 2023-12-08 12:08:27 -05:00
Jason Rasmussen
02d55644e5 fix(deps): bump sharp (#5509)
* fix(deps): bump sharp

* fixed sharp dependencies

* chore: use date tag

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2023-12-08 11:52:43 -05:00
Jason Rasmussen
1e99ba8167 feat: use pgvecto.rs (#3605) 2023-12-08 11:15:46 -05:00
396 changed files with 6915 additions and 5484 deletions

29
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
changelog:
categories:
- title: Breaking Changes 🛠
labels:
- breaking-change
- title: Server
labels:
- 🗄server
- title: Mobile
labels:
- 📱mobile
- title: Web
labels:
- 🖥web
- title: Machine Learning
labels:
- 🧠machine-learning
- title: CLI
labels:
- cli
- title: Documentation
labels:
- documentation
- title: Dependency updates
labels:
- renovate
- title: Other changes
labels:
- "*"

View File

@@ -69,7 +69,7 @@ jobs:
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
- name: Publish Android Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk

View File

@@ -46,7 +46,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -60,7 +60,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -73,6 +73,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

13
.github/workflows/pr-require-label.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Enforce PR labels
on:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
jobs:
enforce-label:
name: Enforce label
runs-on: ubuntu-latest
steps:
- if: toJson(github.event.pull_request.labels) == '[]'
run: exit 1

View File

@@ -69,7 +69,7 @@ jobs:
token: ${{ secrets.ORG_RELEASE_TOKEN }}
- name: Download APK
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: release-apk-signed

View File

@@ -213,7 +213,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres@sha256:6dfee32131933ab4ca25a00360c3f427fdc134de56f9a90c6c9a4956b48aea85
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
@@ -261,6 +261,8 @@ jobs:
- name: Run SQL generation
run: npm run sql:generate
env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes
uses: tj-actions/verify-changed-files@v13.1

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ coverage
mobile/gradle.properties
mobile/openapi/pubspec.lock
mobile/*.jks
mobile/libisar.dylib

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.90.2
* The version of the OpenAPI document: 1.91.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -2175,6 +2175,24 @@ export const LibraryType = {
export type LibraryType = typeof LibraryType[keyof typeof LibraryType];
/**
*
* @export
* @enum {string}
*/
export const LogLevel = {
Verbose: 'verbose',
Debug: 'debug',
Log: 'log',
Warn: 'warn',
Error: 'error',
Fatal: 'fatal'
} as const;
export type LogLevel = typeof LogLevel[keyof typeof LogLevel];
/**
*
* @export
@@ -3577,6 +3595,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'library': SystemConfigLibraryDto;
/**
*
* @type {SystemConfigLoggingDto}
* @memberof SystemConfigDto
*/
'logging': SystemConfigLoggingDto;
/**
*
* @type {SystemConfigMachineLearningDto}
@@ -3860,6 +3884,27 @@ export interface SystemConfigLibraryScanDto {
*/
'enabled': boolean;
}
/**
*
* @export
* @interface SystemConfigLoggingDto
*/
export interface SystemConfigLoggingDto {
/**
*
* @type {boolean}
* @memberof SystemConfigLoggingDto
*/
'enabled': boolean;
/**
*
* @type {LogLevel}
* @memberof SystemConfigLoggingDto
*/
'level': LogLevel;
}
/**
*
* @export
@@ -14565,22 +14610,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
* @param {string} [query]
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {string} [exifInfoProjectionType]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, exifInfoProjectionType?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/search`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -14618,46 +14653,6 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
localVarQueryParameter['type'] = type;
}
if (isFavorite !== undefined) {
localVarQueryParameter['isFavorite'] = isFavorite;
}
if (isArchived !== undefined) {
localVarQueryParameter['isArchived'] = isArchived;
}
if (exifInfoCity !== undefined) {
localVarQueryParameter['exifInfo.city'] = exifInfoCity;
}
if (exifInfoState !== undefined) {
localVarQueryParameter['exifInfo.state'] = exifInfoState;
}
if (exifInfoCountry !== undefined) {
localVarQueryParameter['exifInfo.country'] = exifInfoCountry;
}
if (exifInfoMake !== undefined) {
localVarQueryParameter['exifInfo.make'] = exifInfoMake;
}
if (exifInfoModel !== undefined) {
localVarQueryParameter['exifInfo.model'] = exifInfoModel;
}
if (exifInfoProjectionType !== undefined) {
localVarQueryParameter['exifInfo.projectionType'] = exifInfoProjectionType;
}
if (smartInfoObjects) {
localVarQueryParameter['smartInfo.objects'] = smartInfoObjects;
}
if (smartInfoTags) {
localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
}
if (recent !== undefined) {
localVarQueryParameter['recent'] = recent;
}
@@ -14752,23 +14747,13 @@ export const SearchApiFp = function(configuration?: Configuration) {
* @param {string} [query]
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {string} [exifInfoProjectionType]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, exifInfoProjectionType?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, recent, motion, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -14807,7 +14792,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
* @throws {RequiredError}
*/
search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
},
/**
*
@@ -14855,76 +14840,6 @@ export interface SearchApiSearchRequest {
*/
readonly type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'
/**
*
* @type {boolean}
* @memberof SearchApiSearch
*/
readonly isFavorite?: boolean
/**
*
* @type {boolean}
* @memberof SearchApiSearch
*/
readonly isArchived?: boolean
/**
*
* @type {string}
* @memberof SearchApiSearch
*/
readonly exifInfoCity?: string
/**
*
* @type {string}
* @memberof SearchApiSearch
*/
readonly exifInfoState?: string
/**
*
* @type {string}
* @memberof SearchApiSearch
*/
readonly exifInfoCountry?: string
/**
*
* @type {string}
* @memberof SearchApiSearch
*/
readonly exifInfoMake?: string
/**
*
* @type {string}
* @memberof SearchApiSearch
*/
readonly exifInfoModel?: string
/**
*
* @type {string}
* @memberof SearchApiSearch
*/
readonly exifInfoProjectionType?: string
/**
*
* @type {Array<string>}
* @memberof SearchApiSearch
*/
readonly smartInfoObjects?: Array<string>
/**
*
* @type {Array<string>}
* @memberof SearchApiSearch
*/
readonly smartInfoTags?: Array<string>
/**
*
* @type {boolean}
@@ -14986,7 +14901,7 @@ export class SearchApi extends BaseAPI {
* @memberof SearchApi
*/
public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
}
/**
@@ -17973,7 +17888,7 @@ export const UserApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getProfileImage(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
@@ -18075,7 +17990,7 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise<object> {
getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
return localVarFp.getProfileImage(requestParameters.id, options).then((request) => request(axios, basePath));
},
/**

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.90.2
* The version of the OpenAPI document: 1.91.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.90.2
* The version of the OpenAPI document: 1.91.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.90.2
* The version of the OpenAPI document: 1.91.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.90.2
* The version of the OpenAPI document: 1.91.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -29,7 +29,7 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich_server
command: npm run start:debug immich
command: [ "/usr/src/app/bin/immich-dev", "immich" ]
<<: *server-common
ports:
- 3001:3001
@@ -37,11 +37,10 @@ services:
depends_on:
- redis
- database
- typesense
immich-microservices:
container_name: immich_microservices
command: npm run start:debug microservices
command: [ "/usr/src/app/bin/immich-dev", "microservices" ]
<<: *server-common
# extends:
# file: hwaccel.yml
@@ -51,7 +50,6 @@ services:
depends_on:
- database
- immich-server
- typesense
immich-web:
container_name: immich_web
@@ -95,24 +93,13 @@ services:
- database
restart: unless-stopped
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- ${UPLOAD_LOCATION}/typesense:/data
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
database:
container_name: immich_postgres
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
env_file:
- .env
environment:

View File

@@ -24,7 +24,6 @@ services:
depends_on:
- redis
- database
- typesense
immich-microservices:
container_name: immich_microservices
@@ -36,7 +35,6 @@ services:
depends_on:
- redis
- database
- typesense
- immich-server
immich-machine-learning:
@@ -51,26 +49,14 @@ services:
- .env
restart: always
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- ${UPLOAD_LOCATION}/typesense:/data
restart: always
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
restart: always
database:
container_name: immich_postgres
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
env_file:
- .env
environment:

View File

@@ -9,7 +9,8 @@ services:
context: ../
dockerfile: server/Dockerfile
target: dev
command: npm run test:e2e
entrypoint: [ "/usr/local/bin/npm", "run" ]
command: test:e2e
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
@@ -23,8 +24,7 @@ services:
- database
database:
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
command: -c fsync=off
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres

View File

@@ -25,7 +25,6 @@ services:
depends_on:
- redis
- database
- typesense
restart: always
immich-microservices:
@@ -43,7 +42,6 @@ services:
depends_on:
- redis
- database
- typesense
restart: always
immich-machine-learning:
@@ -55,26 +53,14 @@ services:
- .env
restart: always
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- tsdata:/data
restart: always
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
restart: always
database:
container_name: immich_postgres
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
env_file:
- .env
environment:
@@ -88,4 +74,3 @@ services:
volumes:
pgdata:
model-cache:
tsdata:

View File

@@ -6,8 +6,7 @@ UPLOAD_LOCATION=./library
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
IMMICH_VERSION=release
# Connection secrets for postgres and typesense. You should change these to random passwords
TYPESENSE_API_KEY=some-random-text
# Connection secret for postgres. You should change it to a random password
DB_PASSWORD=postgres
# The values below this line do not need to be changed

26
docs/_redirects Normal file
View File

@@ -0,0 +1,26 @@
/docs /docs/overview/introduction 301
/docs/mobile-app-beta-program /docs/features/mobile-app 301
/docs/contribution-guidelines /docs/overview/support-the-project#contributing 301
/docs/install /docs/install/docker-compose 301
/docs/installation/one-step-installation /docs/install/script 301
/docs/installation/portainer-installation /docs/install/portainer 301
/docs/installation/recommended-installation /docs/install/docker-compose 301
/docs/installation/unraid /docs/install/unraid 301
/docs/installation/requirements /docs/install/requirements 301
/docs/overview/logo-meaning /docs/overview/logo 301
/docs/overview/technology-stack /docs/developer/architecture 301
/docs/usage/automatic-backup /docs/features/automatic-backup 301
/docs/usage/bulk-upload /docs/features/command-line-interface 301
/docs/features/bulk-upload /docs/features/command-line-interface 301
/docs/usage/oauth /docs/administration/oauth 301
/docs/usage/post-installation /docs/install/post-install 301
/docs/usage/update /docs/install/docker-compose#step-4---upgrading 301
/docs/usage/server-commands /docs/administration/server-commands 301
/docs/features/jobs /docs/administration/jobs 301
/docs/features/oauth /docs/administration/oauth 301
/docs/features/password-login /docs/administration/password-login 301
/docs/features/server-commands /docs/administration/server-commands 301
/docs/features/storage-template /docs/administration/storage-template 301
/docs/features/user-management /docs/administration/user-management 301
/docs/developer/contributing /docs/developer/pr-checklist 301

View File

@@ -0,0 +1,70 @@
---
title: Immich Recap 2023
authors: [alextran]
tags: [update, recap-2023]
---
Hi everyone,
Alex from Immich here.
We are entering the last few weeks of 2023, and it has been quite a year for Immich. The project has grown so much in terms of users, developers, features, maturity, and the community around it. When I started working on Immich, it was simply a challenge for myself and an opportunity to learn new technologies, crafting something fun and useful for my wife during my free time to satisfy my urge to build and create things. I never thought it would become so popular and help so many people. At the end of the day, all we have is memory. I am proud that the team and I have created something to make storing and viewing those precious memories easier without restrictions and without sacrificing our privacy. As the year closes, heres a recap of everything the project accomplished in 2023.
# Milestones
- Public shared links
- Favorites page
- Immich turned 1
- Material Design 3 on the mobile app
- Auto-link LivePhotos server-side
- iOS background backup
- Explore page
- CLIP search
- Search by metadata
- Responsive web app
- Archive page
- Asset descriptions
- 10,000 stars on GitHub
- Manage auth devices
- Map view
- Facial recognition, clustering, searching, renaming, and person management
- Partner sharing and unifying timeline between partners' users
- Custom storage label
- XMP sidecar reading
- RAW file formats
- Justified layout on the web
- Memories
- Multi-select via SHIFT
- Android Motion Photos
- 360° Photos
- Album description
- Album performance improvements (time buckets)
- Video hardware transcoding
- Slideshow mode on the web
- Configuration file
- External libraries
- Trash page
- Custom theme
- Asset Stacking
- 20,000 stars on GitHub
- Shared album activity and comments
- CLI v2
- Down to 5 containers (from 8)
# Fun Statistics
- We have gone from the release version `1.41.0` to `1.90.0` at the time of writing. On average, we see a release every 7 days.
- According to GitHub's metrics, the `immich-server` container image has been pulled almost _4 million_ times.
- According to mobile app store metrics, we have 22,000 installations on Android and 6700 installation units on iOS (opt-in only).
- Immich is making around $1200/month on average from donations. (Thank you all so much!)
- We were guests on two podcasts:
- [Self-hosted](https://selfhosted.show/110)
- [The Vergecast](https://www.theverge.com/23938533/self-hosting-local-first-software-vergecast)
- There are over 4,500 members on the Discord server.
- We have over 22,000 stars on the main GitHub repository, gaining 15,000 stars since January 2023.
Diving into the next year, the team will continue to build on the foundation we have laid out over the past year, implementing more advanced features for searching, organizing, and sharing between users. Bugs will continue to be squashed and conquered. “Shit Alex wrote'' code will continue to be replaced by beautiful, clean code from Jason, Zack, Boet, Daniel, Osorin, Mert, Fynn, Marty, Martin, and Jonathan. The team has my eternal gratitude for creating a welcoming environment for new contributors, helping, teaching, and learning from each other. Ive realized that hardly a day has gone by where the team hasnt been in communication about Immich related topics over the past year.
My long-term goal is to help hone Immich into a diamond in the FOSS space, where the UI, UX, development experiences, documentation, and quality are at a high standard while remaining free for everybody to use.
I hope you enjoy Immich and have a happy and peaceful holiday.

View File

@@ -30,7 +30,6 @@ The initial backup is the most intensive due to the number of jobs running. The
- Lower the job concurrency for these jobs to 1.
- Under Settings > Transcoding Settings > Threads, set the number of threads to a low number like 1 or 2.
- Set the `TYPESENSE_THREAD_POOL_SIZE` environmental variable and restart the Typesense container. For instance, `TYPESENSE_THREAD_POOL_SIZE=8` will limit it to 8 threads.
- Under Settings > Machine Learning Settings > Facial Recognition > Model Name, you can change the facial recognition model to `buffalo_s` instead of `buffalo_l`. The former is a smaller and faster model, albeit not as good.
- You _must_ re-run the Recognize Faces job for all images after this for facial recognition on new images to work properly.
- If these changes are not enough, see [below](/docs/FAQ.md#how-can-i-disable-machine-learning) for how you can disable machine learning.
@@ -45,14 +44,6 @@ Machine learning can be disabled under Settings > Machine Learning Settings, eit
However, disabling all jobs will not disable the machine learning service itself. To prevent it from starting up at all in this case, you can comment out the `immich-machine-learning` section of the docker-compose.yml.
### How can I disable TypeSense?
:::info
Disabling Typesense will result in a poor search experience since searching is reliant on it.
:::
You can disable Typesense by commenting out the `immich-typesense` section of the docker-compose.yml and setting `TYPESENSE_ENABLED=false` in your .env file.
### I'm getting errors about models being corrupt or failing to download. What do I do?
You can delete the model cache volume, which is where models are downloaded. This will give the service a clean environment to download the model again.
@@ -69,6 +60,10 @@ This is fixed by running the storage migration job.
The default image tagging model is relatively small. You can change this for a larger model like `google/vit-base-patch16-224` by setting the model name under Settings > Machine Learning Settings > Image Tagging. You can then re-run the Image Tagging job to get improved tags.
### Why are there so many thumbnail generation jobs?
Immich generates three thumbnails for each asset (blurred, small, and large), as well as a thumbnail for each recognized face.
### How can I see Immich logs?
Most Immich components are typically deployed using docker. To see logs for deployed docker containers, you can use the [Docker CLI](https://docs.docker.com/engine/reference/commandline/cli/), specifically the `docker logs` command. For examples, see [Docker Help](/docs/guides/docker-help.md)

View File

@@ -44,7 +44,6 @@ services:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
SCHEDULE: "@daily"
BACKUP_NUM_KEEP: 7
BACKUP_DIR: /db_dumps
volumes:
- ./db_dumps:/db_dumps

View File

@@ -2,15 +2,17 @@
sidebar_position: 1
---
import AppArchitecture from './img/app-architecture.png';
# Architecture
Immich uses a traditional client-server design, with a dedicated database for data persistence. The frontend clients communicate with backend services over HTTP using REST APIs.
Immich uses a traditional client-server design, with a dedicated database for data persistence. The frontend clients communicate with backend services over HTTP using REST APIs. Below is a high level diagram of the architecture.
## High Level Diagram
![Immich Architecture](./img/app-architecture.png)
<img alt="Immich Architecture" src={AppArchitecture} className="p-4 dark:bg-immich-dark-primary my-4" />
The diagram shows clients communicating with the server via REST, as well as the flow of database between backend services.
The diagram shows clients communicating with the server's API via REST. The server communicates with downstream systems (i.e. Redis, Postgres, Machine Learning, file system) through repository interfaces. Not shown in the diagram, is that the server is split into two separate containers `immich-server` and `immich-microservices`. The microservices container does not handle API requests or schedule cron jobs, but primarily handles incoming job requests from Redis.
## Clients
@@ -45,7 +47,6 @@ The Immich backend is divided into several services, which are run as individual
1. `immich-machine-learning` - Execute machine learning models
1. `postgres` - Persistent data storage
1. `redis`- Queue management for `immich-microservices`
1. `typesense`- Specialized database for search, specifically with vector comparison features
### Immich Server
@@ -75,7 +76,6 @@ The Immich Microservices image uses the same `Dockerfile` as the Immich Server,
- Object Tagging
- Facial Recognition
- Storage Template Migration
- Search (Typesense synchronization)
- Sidecar (see [XMP Sidecars](/docs/features/xmp-sidecars.md))
- Background jobs (file deletion, user deletion)
@@ -108,9 +108,3 @@ See [Database Migrations](./database-migrations.md) for more information about h
### Redis
Immich uses [Redis](https://redis.com/) via [BullMQ](https://docs.bullmq.io/) to manage job queues. Some jobs trigger subsequent jobs. For example, object detection relies on thumbnail generation and automatically run after one is generated.
### Typesense
Immich synchronizes some of the Postgres data into Typesense, so it can execute vector related queries in order to implement certain features including, facial recognition and CLIP search.
<!-- - [NGINX](https://www.nginx.com/) for internal communication between containers and load balancing when scaling. -->

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2023-12-12T20:57:14.605Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" etag="df4JlXsRZhazQ-YEVHpD" version="22.1.7" type="google">
<diagram name="Page-1" id="B4lqvvtjK-gtNbJBlj3M">
<mxGraphModel dx="1434" dy="798" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="skFinV3TG75uy4JOHTMt-8" value="" style="verticalLabelPosition=bottom;verticalAlign=top;html=1;shape=mxgraph.basic.polygon;polyCoords=[[0.25,0],[0.75,0],[1,0.25],[1,0.75],[0.75,1],[0.25,1],[0,0.75],[0,0.25]];polyline=0;fillColor=#d0cee2;strokeColor=#56517e;strokeWidth=16;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="187" y="28" width="480" height="480" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-57" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.167;entryDx=0;entryDy=0;strokeWidth=3;endArrow=block;endFill=1;entryPerimeter=0;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-11" target="skFinV3TG75uy4JOHTMt-30">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-11" value="Web App&lt;br&gt;(SvelteKit)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="24" y="88" width="120" height="80" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-58" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=3;endArrow=block;endFill=1;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-12" target="skFinV3TG75uy4JOHTMt-30">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-12" value="Mobile App&lt;br&gt;(Flutter)" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="24" y="230" width="120" height="80" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-59" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.013;entryY=0.863;entryDx=0;entryDy=0;strokeWidth=3;endArrow=block;endFill=1;entryPerimeter=0;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-13" target="skFinV3TG75uy4JOHTMt-30">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-13" value="Immich CLI&lt;br&gt;(TypeScript)" style="whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="24" y="368" width="120" height="80" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-23" value="" style="verticalLabelPosition=bottom;verticalAlign=top;html=1;shape=mxgraph.basic.polygon;polyCoords=[[0.25,0],[0.75,0],[1,0.25],[1,0.75],[0.75,1],[0.25,1],[0,0.75],[0,0.25]];polyline=0;fillColor=#b1ddf0;strokeColor=#10739e;strokeWidth=2;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="335" y="183" width="170" height="170" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-28" value="&lt;h1&gt;Server&lt;/h1&gt;" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="377" y="28" width="100" height="70" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-116" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=3;endArrow=block;endFill=1;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-30" target="skFinV3TG75uy4JOHTMt-23">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="325" y="268" />
<mxPoint x="325" y="268" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-30" value="API &lt;br&gt;Controllers" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#b0e3e6;strokeColor=#0e8088;arcSize=0;strokeWidth=1;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;" vertex="1" parent="1">
<mxGeometry x="215" y="170" width="95" height="200" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-43" value="&lt;h2&gt;&lt;span style=&quot;background-color: initial; font-size: 12px; font-weight: normal;&quot;&gt;Repositories&lt;/span&gt;&lt;/h2&gt;&lt;b&gt;&quot;Infra&quot;&lt;br&gt;&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#b0e3e6;strokeColor=#0e8088;arcSize=0;strokeWidth=1;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;" vertex="1" parent="1">
<mxGeometry x="543" y="169" width="100" height="198" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-45" value="Cron" style="rhombus;whiteSpace=wrap;html=1;fillColor=#b0e3e6;strokeColor=#0e8088;strokeWidth=1;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="270" y="388" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-46" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.625;exitY=0.15;exitDx=0;exitDy=0;strokeWidth=3;endArrow=block;endFill=1;entryX=0.106;entryY=0.847;entryDx=0;entryDy=0;entryPerimeter=0;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;exitPerimeter=0;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-45" target="skFinV3TG75uy4JOHTMt-23">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-52" value="Postgres" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;fillColor=#dae8fc;strokeColor=#6c8ebf;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="745" y="270" width="60" height="80" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-128" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0;exitDx=0;exitDy=52.5;exitPerimeter=0;strokeColor=#000000;strokeWidth=3;endArrow=block;endFill=1;fillColor=#fad9d5;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-60" target="skFinV3TG75uy4JOHTMt-131">
<mxGeometry relative="1" as="geometry">
<mxPoint x="620" y="350" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-60" value="Redis" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;fillColor=#f8cecc;strokeColor=#b85450;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="745" y="380" width="60" height="80" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-67" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;edgeStyle=orthogonalEdgeStyle;strokeWidth=1;dashed=1;endArrow=none;endFill=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;fillColor=#ffff88;strokeColor=#000000;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-74" target="skFinV3TG75uy4JOHTMt-84">
<mxGeometry relative="1" as="geometry">
<mxPoint x="166" y="18.99000000000001" as="sourcePoint" />
<mxPoint x="165" y="508" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-69" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=3;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;endArrow=block;endFill=1;startArrow=none;startFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-43" target="skFinV3TG75uy4JOHTMt-52">
<mxGeometry relative="1" as="geometry">
<mxPoint x="645" y="168" as="sourcePoint" />
<mxPoint x="755" y="168" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-74" value="&lt;i style=&quot;font-size: 10px;&quot;&gt;HTTP (OpenAPI)&lt;/i&gt;" style="whiteSpace=wrap;html=1;fillColor=#eeeeee;strokeColor=#36393d;fontSize=10;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="110" y="48" width="105" height="20" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-78" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;startArrow=block;startFill=1;endArrow=none;endFill=0;strokeWidth=3;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-43" target="skFinV3TG75uy4JOHTMt-23">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-84" value="&lt;i style=&quot;font-size: 10px;&quot;&gt;HTTP (OpenAPI)&lt;/i&gt;" style="whiteSpace=wrap;html=1;fillColor=#eeeeee;strokeColor=#36393d;fontSize=10;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="110" y="468" width="105" height="20" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-85" value="&lt;h2&gt;Core&lt;br&gt;Services&lt;/h2&gt;&lt;div&gt;&lt;b&gt;&quot;Domain&quot;&lt;/b&gt;&lt;/div&gt;" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="370" y="208" width="100" height="100" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-87" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=3;endArrow=block;endFill=1;startArrow=none;startFill=0;entryX=0.25;entryY=1;entryDx=0;entryDy=0;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;exitX=1;exitY=0.5;exitDx=0;exitDy=0;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-43" target="skFinV3TG75uy4JOHTMt-94">
<mxGeometry relative="1" as="geometry">
<mxPoint x="635" y="268" as="sourcePoint" />
<mxPoint x="735" y="398" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-94" value="Machine Learning&lt;br&gt;(Python)" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="705" y="48" width="120" height="80" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-97" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=3;endArrow=block;endFill=1;startArrow=none;startFill=0;entryX=0;entryY=1;entryDx=0;entryDy=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-43" target="skFinV3TG75uy4JOHTMt-101">
<mxGeometry relative="1" as="geometry">
<mxPoint x="655" y="318" as="sourcePoint" />
<mxPoint x="785" y="298" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-101" value="File&lt;br&gt;System" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontColor=#333333;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="735" y="163" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-117" value="&lt;i style=&quot;font-size: 10px;&quot;&gt;HTTP&lt;/i&gt;" style="whiteSpace=wrap;html=1;fillColor=#eeeeee;strokeColor=#36393d;fontSize=10;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="682" y="163" width="48" height="20" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-129" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;endArrow=block;endFill=1;strokeWidth=3;strokeColor=#000000;fillColor=#fad9d5;entryX=0.888;entryY=0.865;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-131" target="skFinV3TG75uy4JOHTMt-23">
<mxGeometry relative="1" as="geometry">
<mxPoint x="565" y="278" as="sourcePoint" />
<mxPoint x="479" y="340" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-131" value="Jobs" style="whiteSpace=wrap;html=1;strokeWidth=2;fillColor=#b0e3e6;strokeColor=#0e8088;rounded=0;" vertex="1" parent="1">
<mxGeometry x="563" y="340" width="60" height="20" as="geometry" />
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-134" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=3;entryX=0;entryY=0;entryDx=0;entryDy=27.5;entryPerimeter=0;endArrow=block;endFill=1;startArrow=none;startFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;fillColor=#fad9d5;strokeColor=#000000;" edge="1" parent="1" source="skFinV3TG75uy4JOHTMt-43" target="skFinV3TG75uy4JOHTMt-60">
<mxGeometry relative="1" as="geometry">
<mxPoint x="634" y="318" as="sourcePoint" />
<mxPoint x="755" y="289" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="skFinV3TG75uy4JOHTMt-140" value="&lt;i style=&quot;font-size: 10px;&quot;&gt;Queue&lt;/i&gt;" style="whiteSpace=wrap;html=1;fillColor=#eeeeee;strokeColor=#36393d;fontSize=10;fontFamily=Overpass;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DOverpass;rounded=0;" vertex="1" parent="1">
<mxGeometry x="679" y="394" width="48" height="20" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -42,6 +42,7 @@ Usage: immich [options] [command]
Immich command line interface
Options:
-V, --version output the version number
-h, --help display help for command
Commands:

View File

@@ -1,18 +1,10 @@
# Search
Immich uses Typesense as the primary search database to enable high performance search mechanism.
Immich uses Postgres as its search database for both metadata and smart search.
Typesense is a powerful search engine that can be integrated with popular natural language processing (NLP) models like CLIP and SBERT to provide highly accurate and relevant search results. Here are some benefits of using Typesense integrated search for CLIP and SBERT:
Smart search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvecto.rs) extension, utilizing machine learning models like CLIP to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata.
Improved Search Accuracy: Typesense uses a combination of indexing, querying, and ranking algorithms to quickly and accurately retrieve relevant search results. When integrated with CLIP and SBERT, Typesense can leverage the semantic understanding and deep learning capabilities of these models to further improve the accuracy of search results.
Faster Search Response Times: Typesense is optimized for lightning-fast search response times, making it ideal for applications that require near-instantaneous search results. By integrating with CLIP and SBERT, Typesense can reduce the time required to process complex search queries, making it even faster and more efficient.
Enhanced Semantic Search Capabilities: CLIP and SBERT are powerful NLP models that can extract the semantic meaning from text, enabling more nuanced search queries. By integrating with Typesense, these models can help to improve the accuracy of semantic search, enabling users to find the most relevant results based on the true meaning of their query.
Greater Search Flexibility: Typesense provides flexible search capabilities, including fuzzy search, partial search, enabling users to find the information they need quickly and easily. When integrated with CLIP and SBERT, Typesense can offer even greater flexibility, allowing users to refine their search queries using natural language and providing more accurate and relevant results.
(Generated by Chat-GPT4)
Metadata search (prefixed with `m:`) can search specifically by text without the use of a model.
Some search examples:
<img src={require('./img/search-ex-2.webp').default} title='Search Example 1' />

View File

@@ -91,8 +91,7 @@ The default configuration looks like this:
"tileUrl": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
},
"reverseGeocoding": {
"enabled": true,
"citiesFileOverride": "cities500"
"enabled": true
},
"oauth": {
"enabled": false,

View File

@@ -2,6 +2,9 @@
sidebar_position: 30
---
import CodeBlock from '@theme/CodeBlock';
import ExampleEnv from '!!raw-loader!../../../docker/example.env';
# Docker Compose [Recommended]
Docker Compose is the recommended method to run Immich in production. Below are the steps to deploy Immich with Docker Compose.
@@ -40,104 +43,15 @@ Optionally, you can use the [`hwaccel.yml`][hw-file] file to enable hardware acc
### Step 2 - Populate the .env file with custom values
<details>
<summary>Example <code>.env</code> content</summary>
```bash
###################################################################################
# Database
###################################################################################
DB_HOSTNAME=immich_postgres
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=immich
# Optional Database settings:
# DB_PORT=5432
###################################################################################
# Redis
###################################################################################
REDIS_HOSTNAME=immich_redis
# Optional Redis settings:
# Note: these parameters are not automatically passed to the Redis Container
# to do so, please edit the docker-compose.yml file as well. Redis is not configured
# via environment variables, only redis.conf or the command line
# REDIS_PORT=6379
# REDIS_DBINDEX=0
# REDIS_PASSWORD=
# REDIS_SOCKET=
###################################################################################
# Upload File Location
#
# This is the location where uploaded files are stored.
###################################################################################
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Log message level - [simple|verbose]
###################################################################################
LOG_LEVEL=simple
###################################################################################
# Typesense
###################################################################################
# TYPESENSE_ENABLED=false
TYPESENSE_API_KEY=some-random-text
# TYPESENSE_HOST: typesense
# TYPESENSE_PORT: 8108
# TYPESENSE_PROTOCOL: http
###################################################################################
# Reverse Geocoding
#
# Reverse geocoding is done locally which has a small impact on memory usage
# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
# This ranges from 0-3 with 3 being the most precise
# 3 - Cities > 500 population: ~200MB RAM
# 2 - Cities > 1000 population: ~150MB RAM
# 1 - Cities > 5000 population: ~80MB RAM
# 0 - Cities > 15000 population: ~40MB RAM
####################################################################################
# DISABLE_REVERSE_GEOCODING=false
# REVERSE_GEOCODING_PRECISION=3
####################################################################################
# WEB - Optional
#
# Custom message on the login page, should be written in HTML form.
# For example:
# PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
####################################################################################
PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
###################################################################################
# Immich Version - Optional
#
# This allows all immich docker images to be pinned to a specific version. By default,
# the version is "release" but could be a specific version, like "v1.59.0".
###################################################################################
#IMMICH_VERSION=
```
<summary>
Example <code>.env</code> content
</summary>
<CodeBlock language="bash">{ExampleEnv}</CodeBlock>
</details>
- Populate custom database information if necessary.
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
- Consider changing `DB_PASSWORD` to something randomly generated
- Consider changing `TYPESENSE_API_KEY` to something randomly generated
### Step 3 - Start the containers
@@ -147,6 +61,19 @@ From the directory you created in Step 1, (which should now contain your customi
docker compose up -d
```
:::tip
If you get an error `unknown shorthand flag: 'd' in -d`, you are probably running the wrong Docker version. (This happens, for example, with the docker.io package in Ubuntu 22.04.3 LTS.) You can correct the problem by `apt remove`ing Ubuntu's docker.io package and installing docker and docker-compose via [Docker's official repository](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository).
Note that the correct command really is `docker compose`, not `docker-compose`. If you try the latter on vanilla Ubuntu 22.04 it will fail in a different way:
```
The Compose file './docker-compose.yml' is invalid because:
'name' does not match any of the regexes: '^x-'
```
See the previous paragraph about installing from the official docker repository.
:::
:::tip
For more information on how to use the application, please refer to the [Post Installation](/docs/install/post-install.mdx) guide.
:::

View File

@@ -17,10 +17,10 @@ If this should not work, try running `docker compose up -d --force-recreate`.
## Docker Compose
| Variable | Description | Default | Services |
| :---------------- | :-------------------- | :-------: | :------------------------------------------------------------- |
| `IMMICH_VERSION` | Image tags | `release` | server, microservices, machine learning, web, proxy, typesense |
| `UPLOAD_LOCATION` | Host Path for uploads | | server, microservices |
| Variable | Description | Default | Services |
| :---------------- | :-------------------- | :-------: | :-------------------------------------------------- |
| `IMMICH_VERSION` | Image tags | `release` | server, microservices, machine learning, web, proxy |
| `UPLOAD_LOCATION` | Host Path for uploads | | server, microservices |
:::tip
@@ -124,51 +124,6 @@ Redis (Sentinel) URL example JSON before encoding:
}
```
## Typesense
| Variable | Description | Default | Services |
| :------------------- | :----------------------- | :---------: | :------------------------------- |
| `TYPESENSE_ENABLED` | Enable Typesense | | server, microservices |
| `TYPESENSE_URL` | Typesense URL | | server, microservices |
| `TYPESENSE_HOST` | Typesense Host | `typesense` | server, microservices |
| `TYPESENSE_PORT` | Typesense Port | `8108` | server, microservices |
| `TYPESENSE_PROTOCOL` | Typesense Protocol | `http` | server, microservices |
| `TYPESENSE_API_KEY` | Typesense API Key | | server, microservices, typesense |
| `TYPESENSE_DATA_DIR` | Typesense Data Directory | `/data` | typesense |
:::info
`TYPESENSE_URL` must start with `ha://` and then include a `base64` encoded JSON string for the configuration.
`TYPESENSE_ENABLED`: Anything other than `false`, behaves as `true`.
Even undefined is treated as `true`.
- When `TYPESENSE_URL` is defined, the other typesense (`TYPESENSE_*`) variables are ignored.
:::
Typesense URL example JSON before encoding:
```json
[
{
"host": "typesense-1.example.net",
"port": "443",
"protocol": "https"
},
{
"host": "typesense-2.example.net",
"port": "443",
"protocol": "https"
},
{
"host": "typesense-3.example.net",
"port": "443",
"protocol": "https"
}
]
```
## Machine Learning
| Variable | Description | Default | Services |

View File

@@ -18,7 +18,4 @@ search home.lan
nameserver 192.168.1.1
```
When you encounter this bug, it will cause the immich-microservices to crash on startup because it cannot download
the geocoder data. This can be solved in one of two ways: Either reconfigure your nodes to remove the searchdomain from
`resolv.conf`, or set the `DISABLE_REVERSE_GEOCODING` environment variable for Immich to `true` to disable the geocoder.
:::

View File

@@ -5,7 +5,7 @@ sidebar_position: 20
# Install Script [Experimental]
:::caution
This method is experimental and not currently recommended for production use. For production, please refer to installing with [Docker Compose](/docs/install/docker-compose.md).
This method is experimental and not currently recommended for production use. For production, please refer to installing with [Docker Compose](/docs/install/docker-compose.mdx).
:::
In the shell, from a directory of your choice, run the following command:

View File

@@ -24,7 +24,7 @@ There are lots of non-monetary ways to contribute to Immich as well.
1. Testing - Using Immich and reporting bugs is a great way to help support the project. Found a bug? [Open an issue on GitHub][github-issue].
1. Translations - The Immich mobile app has been translated into [17 languages][github-langs] so far! To contribute with translations, email me at alex.tran1502@gmail.com or send me a message on discord.
1. Development - If you are a programmer or developer, take a look at Immich's [technology stack](/docs/developer/architecture.md) and consider fixing bugs or building new features. The team and I are always looking for new contributors. For information about how to contribute as a developer, see the [Developer](/docs/developer/architecture.md) section.
1. Development - If you are a programmer or developer, take a look at Immich's [technology stack](/docs/developer/architecture.mdx) and consider fixing bugs or building new features. The team and I are always looking for new contributors. For information about how to contribute as a developer, see the [Developer](/docs/developer/architecture.mdx) section.
[github-issue]: https://github.com/immich-app/immich/issues/new/choose
[github-langs]: https://github.com/immich-app/immich/tree/main/mobile/assets/i18n

View File

@@ -8,7 +8,7 @@ const darkCodeTheme = require('prism-react-renderer/themes/dracula');
const config = {
title: 'Immich',
tagline: 'High performance self-hosted photo and video backup solution directly from your mobile phone',
url: 'https://documentation.immich.app',
url: 'https://immich.app',
baseUrl: '/',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',

84
docs/package-lock.json generated
View File

@@ -20,6 +20,7 @@
"docusaurus-preset-openapi": "^0.6.3",
"postcss": "^8.4.25",
"prism-react-renderer": "^1.3.5",
"raw-loader": "^4.0.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"tailwindcss": "^3.2.4",
@@ -11094,6 +11095,57 @@
"node": ">=0.10.0"
}
},
"node_modules/raw-loader": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
"dependencies": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/raw-loader/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/raw-loader/node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
"dependencies": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -22929,6 +22981,38 @@
}
}
},
"raw-loader": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"dependencies": {
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
"requires": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
}
}
},
"rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",

View File

@@ -29,6 +29,7 @@
"docusaurus-preset-openapi": "^0.6.3",
"postcss": "^8.4.25",
"prism-react-renderer": "^1.3.5",
"raw-loader": "^4.0.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"tailwindcss": "^3.2.4",

View File

@@ -32,6 +32,15 @@ function HomepageHeader() {
>
Demo portal
</Link>
<a
href="https://github.com/sponsors/immich-app"
target="_blank"
rel="noreferrer"
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-sponsor rounded-full no-underline hover:no-underline text-white dark:text-immich-dark-bg dark:bg-immich-sponsor hover:text-white font-bold uppercase"
>
Sponsor
</a>
</div>
<img src="/img/immich-screenshots.png" alt="screenshots" width={'85%'} />

View File

@@ -20,6 +20,8 @@ module.exports = {
'immich-dark-bg': 'black',
'immich-dark-fg': '#e5e7eb',
'immich-dark-gray': '#212121',
'immich-sponsor': '#db61a2',
},
fontFamily: {
'immich-title': ['Snowburst One', 'cursive'],

View File

@@ -13,7 +13,7 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
FROM python:3.11-slim-bookworm@sha256:cc758519481092eb5a4a5ab0c1b303e288880d59afc601958d19e95b300bc86b
FROM python:3.11-slim-bookworm@sha256:cfd7ed5c11a88ce533d69a1da2fd932d647f9eb6791c5b4ddce081aedf7f7876
RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*

View File

@@ -1,12 +1,16 @@
import logging
import os
import sys
from pathlib import Path
from socket import socket
import gunicorn
import starlette
from gunicorn.arbiter import Arbiter
from pydantic import BaseSettings
from rich.console import Console
from rich.logging import RichHandler
from uvicorn import Server
from uvicorn.workers import UvicornWorker
from .schemas import ModelType
@@ -69,10 +73,26 @@ log_settings = LogSettings()
class CustomRichHandler(RichHandler):
def __init__(self) -> None:
console = Console(color_system="standard", no_color=log_settings.no_color)
super().__init__(
show_path=False, omit_repeated_times=False, console=console, tracebacks_suppress=[gunicorn, starlette]
)
super().__init__(show_path=False, omit_repeated_times=False, console=console, tracebacks_suppress=[starlette])
log = logging.getLogger("gunicorn.access")
log.setLevel(LOG_LEVELS.get(log_settings.log_level.lower(), logging.INFO))
# patches this issue https://github.com/encode/uvicorn/discussions/1803
class CustomUvicornServer(Server):
async def shutdown(self, sockets: list[socket] | None = None) -> None:
for sock in sockets or []:
sock.close()
await super().shutdown()
class CustomUvicornWorker(UvicornWorker):
async def _serve(self) -> None:
self.config.app = self.wsgi
server = CustomUvicornServer(config=self.config)
self._install_sigquit_handler()
await server.serve(sockets=self.sockets)
if not server.started:
sys.exit(Arbiter.WORKER_BOOT_ERROR)

View File

@@ -1,5 +1,4 @@
import json
from pathlib import Path
from typing import Any, Iterator
from unittest import mock
@@ -8,7 +7,7 @@ import pytest
from fastapi.testclient import TestClient
from PIL import Image
from .main import app, init_state
from .main import app
from .schemas import ndarray_f32
@@ -29,9 +28,9 @@ def mock_get_model() -> Iterator[mock.Mock]:
@pytest.fixture(scope="session")
def deployed_app() -> TestClient:
init_state()
return TestClient(app)
def deployed_app() -> Iterator[TestClient]:
with TestClient(app) as client:
yield client
@pytest.fixture(scope="session")

View File

@@ -1,15 +1,16 @@
import asyncio
import gc
import os
import signal
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from typing import Any, Iterator
from zipfile import BadZipFile
import orjson
from fastapi import FastAPI, Form, HTTPException, UploadFile
from fastapi import Depends, FastAPI, Form, HTTPException, UploadFile
from fastapi.responses import ORJSONResponse
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
from starlette.formparsers import MultiPartParser
@@ -27,9 +28,16 @@ from .schemas import (
MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger
app = FastAPI()
model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
thread_pool: ThreadPoolExecutor | None = None
lock = threading.Lock()
active_requests = 0
last_called: float | None = None
def init_state() -> None:
app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
@app.on_event("startup")
def startup() -> None:
global thread_pool
log.info(
(
"Created in-memory cache with unloading "
@@ -37,17 +45,30 @@ def init_state() -> None:
)
)
# asyncio is a huge bottleneck for performance, so we use a thread pool to run blocking code
app.state.thread_pool = ThreadPoolExecutor(settings.request_threads) if settings.request_threads > 0 else None
app.state.lock = threading.Lock()
app.state.last_called = None
thread_pool = ThreadPoolExecutor(settings.request_threads) if settings.request_threads > 0 else None
if settings.model_ttl > 0 and settings.model_ttl_poll_s > 0:
asyncio.ensure_future(idle_shutdown_task())
log.info(f"Initialized request thread pool with {settings.request_threads} threads.")
@app.on_event("startup")
async def startup_event() -> None:
init_state()
@app.on_event("shutdown")
def shutdown() -> None:
log.handlers.clear()
for model in model_cache.cache._cache.values():
del model
if thread_pool is not None:
thread_pool.shutdown()
gc.collect()
def update_state() -> Iterator[None]:
global active_requests, last_called
active_requests += 1
last_called = time.time()
try:
yield
finally:
active_requests -= 1
@app.get("/", response_model=MessageResponse)
@@ -60,7 +81,7 @@ def ping() -> str:
return "pong"
@app.post("/predict")
@app.post("/predict", dependencies=[Depends(update_state)])
async def predict(
model_name: str = Form(alias="modelName"),
model_type: ModelType = Form(alias="modelType"),
@@ -79,17 +100,16 @@ async def predict(
except orjson.JSONDecodeError:
raise HTTPException(400, f"Invalid options JSON: {options}")
model = await load(await app.state.model_cache.get(model_name, model_type, **kwargs))
model = await load(await model_cache.get(model_name, model_type, **kwargs))
model.configure(**kwargs)
outputs = await run(model, inputs)
return ORJSONResponse(outputs)
async def run(model: InferenceModel, inputs: Any) -> Any:
app.state.last_called = time.time()
if app.state.thread_pool is None:
if thread_pool is None:
return model.predict(inputs)
return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs)
return await asyncio.get_running_loop().run_in_executor(thread_pool, model.predict, inputs)
async def load(model: InferenceModel) -> InferenceModel:
@@ -97,15 +117,15 @@ async def load(model: InferenceModel) -> InferenceModel:
return model
def _load() -> None:
with app.state.lock:
with lock:
model.load()
loop = asyncio.get_running_loop()
try:
if app.state.thread_pool is None:
if thread_pool is None:
model.load()
else:
await loop.run_in_executor(app.state.thread_pool, _load)
await loop.run_in_executor(thread_pool, _load)
return model
except (OSError, InvalidProtobuf, BadZipFile, NoSuchFile):
log.warn(
@@ -115,32 +135,23 @@ async def load(model: InferenceModel) -> InferenceModel:
)
)
model.clear_cache()
if app.state.thread_pool is None:
if thread_pool is None:
model.load()
else:
await loop.run_in_executor(app.state.thread_pool, _load)
await loop.run_in_executor(thread_pool, _load)
return model
async def idle_shutdown_task() -> None:
while True:
log.debug("Checking for inactivity...")
if app.state.last_called is not None and time.time() - app.state.last_called > settings.model_ttl:
if (
last_called is not None
and not active_requests
and not lock.locked()
and time.time() - last_called > settings.model_ttl
):
log.info("Shutting down due to inactivity.")
loop = asyncio.get_running_loop()
for task in asyncio.all_tasks(loop):
if task is not asyncio.current_task():
try:
task.cancel()
except asyncio.CancelledError:
pass
sys.stderr.close()
sys.stdout.close()
sys.stdout = sys.stderr = open(os.devnull, "w")
try:
await app.state.model_cache.cache.clear()
gc.collect()
loop.stop()
except asyncio.CancelledError:
pass
os.kill(os.getpid(), signal.SIGINT)
break
await asyncio.sleep(settings.model_ttl_poll_s)

View File

@@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:e296d47be09fc5d260eba9b191f60496f028a4f3ec41e8a14d48c0bae2c60244 as builder
FROM mambaorg/micromamba:bookworm-slim@sha256:5ea70d22075f7209d0410e28b7ce5b1703623099fa04d1154081156b180f739c as builder
ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \

View File

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

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env sh
export LD_PRELOAD="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
export LD_BIND_NOW=1
: "${MACHINE_LEARNING_HOST:=0.0.0.0}"
: "${MACHINE_LEARNING_PORT:=3003}"
@@ -8,8 +9,9 @@ export LD_PRELOAD="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
: "${MACHINE_LEARNING_WORKER_TIMEOUT:=120}"
gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
-k app.config.CustomUvicornWorker \
-w $MACHINE_LEARNING_WORKERS \
-b $MACHINE_LEARNING_HOST:$MACHINE_LEARNING_PORT \
-t $MACHINE_LEARNING_WORKER_TIMEOUT \
--log-config-json log_conf.json
--log-config-json log_conf.json \
--graceful-timeout 0

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 114,
"android.injected.version.name" => "1.90.2",
"android.injected.version.code" => 115,
"android.injected.version.name" => "1.91.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

@@ -169,4 +169,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3

View File

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

View File

@@ -99,7 +99,7 @@ class AlbumSortByOptions extends _$AlbumSortByOptions {
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
return AlbumSortMode.values.firstWhere(
(e) => e.index == sortOpt,
(e) => e.storeIndex == sortOpt,
orElse: () => AlbumSortMode.title,
);
}

View File

@@ -19,9 +19,11 @@ class ImageViewerService {
ImageViewerService(this._apiService);
Future<bool> downloadAssetToDevice(Asset asset) async {
File? imageFile;
File? videoFile;
try {
// Download LivePhotos image and motion part
if (asset.isImage && asset.livePhotoVideoId != null) {
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.remoteId!,
);
@@ -40,11 +42,11 @@ class ImageViewerService {
return false;
}
final AssetEntity? entity;
AssetEntity? entity;
final tempDir = await getTemporaryDirectory();
File videoFile = await File('${tempDir.path}/livephoto.mov').create();
File imageFile = await File('${tempDir.path}/livephoto.heic').create();
videoFile = await File('${tempDir.path}/livephoto.mov').create();
imageFile = await File('${tempDir.path}/livephoto.heic').create();
videoFile.writeAsBytesSync(motionReponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
@@ -54,6 +56,17 @@ class ImageViewerService {
title: asset.fileName,
);
if (entity == null) {
_log.warning(
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
);
entity = await PhotoManager.editor.saveImage(
imageResponse.bodyBytes,
title: asset.fileName,
);
}
return entity != null;
} else {
var res = await _apiService.assetApi
@@ -75,17 +88,20 @@ class ImageViewerService {
);
} else {
final tempDir = await getTemporaryDirectory();
File tempFile =
await File('${tempDir.path}/${asset.fileName}').create();
tempFile.writeAsBytesSync(res.bodyBytes);
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
videoFile.writeAsBytesSync(res.bodyBytes);
entity = await PhotoManager.editor
.saveVideo(tempFile, title: asset.fileName);
.saveVideo(videoFile, title: asset.fileName);
}
return entity != null;
}
} catch (error, stack) {
_log.severe("Error saving file ${error.toString()}", error, stack);
return false;
} finally {
// Clear temp files
imageFile?.delete();
videoFile?.delete();
}
}
}

View File

@@ -453,7 +453,7 @@ class BackgroundService {
);
_cancellationToken = CancellationToken();
final pmProgressHandler = PMProgressHandler();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await backupService.backupAsset(
toUpload,

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
@@ -253,7 +255,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
albumMap[album.id] = album;
}
}
state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums =
@@ -293,6 +294,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
excludedBackupAlbums: excludedAlbums,
);
log.info(
"_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums",
);
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}
@@ -343,7 +347,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
if (allUniqueAssets.isEmpty) {
log.info("Not found albums or assets on the device to backup");
log.info("No assets are selected for back up");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: allAssetsInDatabase,
@@ -447,9 +451,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
final pmProgressHandler = PMProgressHandler();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
pmProgressHandler.stream.listen((event) {
pmProgressHandler?.stream.listen((event) {
final double progress = event.progress;
state = state.copyWith(iCloudDownloadProgress: progress);
});

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
@@ -208,7 +210,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
state.totalAssetsToUpload == 1;
state =
state.copyWith(showDetailedNotification: showDetailedNotification);
final pmProgressHandler = PMProgressHandler();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await ref.read(backupServiceProvider).backupAsset(
allUploadAssets,

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -206,7 +205,7 @@ class BackupService {
Future<bool> backupAsset(
Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken,
PMProgressHandler pmProgressHandler,
PMProgressHandler? pmProgressHandler,
Function(String, String, bool) uploadSuccessCb,
Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
@@ -226,7 +225,6 @@ class BackupService {
}
final String deviceId = Store.get(StoreKey.deviceId);
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
File? file;
bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
@@ -248,8 +246,12 @@ class BackupService {
: assetList.toList();
for (var entity in assetsToUpload) {
File? file;
File? livePhotoFile;
try {
final isAvailableLocally = await entity.isLocallyAvailable();
final isAvailableLocally =
await entity.isLocallyAvailable(isOrigin: true);
// Handle getting files from iCloud
if (!isAvailableLocally && Platform.isIOS) {
@@ -271,11 +273,19 @@ class BackupService {
);
file = await entity.loadFile(progressHandler: pmProgressHandler);
livePhotoFile = await entity.loadFile(
withSubtype: true,
progressHandler: pmProgressHandler,
);
} else {
if (entity.type == AssetType.video) {
file = await entity.originFile;
} else {
file = await entity.originFile.timeout(const Duration(seconds: 5));
if (entity.isLivePhoto) {
livePhotoFile = await entity.originFileWithSubtype
.timeout(const Duration(seconds: 5));
}
}
}
@@ -310,6 +320,27 @@ class BackupService {
req.files.add(assetRawUploadData);
if (entity.isLivePhoto) {
if (livePhotoFile != null) {
final livePhotoTitle = p.setExtension(
originalFileName,
p.extension(livePhotoFile.path),
);
final fileStream = livePhotoFile.openRead();
final livePhotoRawUploadData = http.MultipartFile(
"livePhotoData",
fileStream,
livePhotoFile.lengthSync(),
filename: livePhotoTitle,
);
req.files.add(livePhotoRawUploadData);
} else {
_log.warning(
"Failed to obtain motion part of the livePhoto - $originalFileName",
);
}
}
setCurrentUploadAssetCb(
CurrentUploadAsset(
id: entity.id,
@@ -325,29 +356,6 @@ class BackupService {
var response =
await httpClient.send(req, cancellationToken: cancelToken);
// Send live photo separately
if (entity.isLivePhoto) {
var livePhotoRawUploadData = await _getLivePhotoFile(entity);
if (livePhotoRawUploadData != null) {
var livePhotoReq = MultipartRequest(
req.method,
req.url,
onProgress: req.onProgress,
)
..headers.addAll(req.headers)
..fields.addAll(req.fields);
livePhotoReq.files.add(livePhotoRawUploadData);
// Send live photo only if the non-motion part is successful
if (response.statusCode == 200 || response.statusCode == 201) {
response = await httpClient.send(
livePhotoReq,
cancellationToken: cancelToken,
);
}
}
}
if (response.statusCode == 200) {
// asset is a duplicate (already exists on the server)
duplicatedAssetIds.add(entity.id);
@@ -387,6 +395,7 @@ class BackupService {
} finally {
if (Platform.isIOS) {
file?.deleteSync();
livePhotoFile?.deleteSync();
}
}
}
@@ -396,25 +405,6 @@ class BackupService {
return !anyErrors;
}
Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
var motionFilePath = await entity.getMediaUrl();
if (motionFilePath != null) {
var validPath = motionFilePath.replaceAll('file://', '');
var motionFile = File(validPath);
var fileStream = motionFile.openRead();
String fileName = p.basename(motionFile.path);
return http.MultipartFile(
"assetData",
fileStream,
motionFile.lengthSync(),
filename: fileName,
);
}
return null;
}
String _getAssetType(AssetType assetType) {
switch (assetType) {
case AssetType.audio:

View File

@@ -17,12 +17,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = context.isDarkTheme;
final allAlbums = ref.watch(backupProvider).availableAlbums;
// Albums which are displayed to the user
// by filtering out based on search
final filteredAlbums = useState(allAlbums);
final albums = filteredAlbums.value;
final albums = ref.watch(backupProvider).availableAlbums;
useEffect(
() {
@@ -153,47 +148,47 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
}).toSet();
}
buildSearchBar() {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
child: TextFormField(
onChanged: (searchValue) {
if (searchValue.isEmpty) {
filteredAlbums.value = allAlbums;
} else {
filteredAlbums.value = allAlbums
.where(
(album) => album.name
.toLowerCase()
.contains(searchValue.toLowerCase()),
)
.toList();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
hintText: "Search",
hintStyle: TextStyle(
color: isDarkTheme ? Colors.white : Colors.grey,
fontSize: 14.0,
),
prefixIcon: const Icon(
Icons.search,
color: Colors.grey,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
filled: true,
fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
),
),
);
}
// buildSearchBar() {
// return Padding(
// padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
// child: TextFormField(
// onChanged: (searchValue) {
// // if (searchValue.isEmpty) {
// // albums = availableAlbums;
// // } else {
// // albums.value = availableAlbums
// // .where(
// // (album) => album.name
// // .toLowerCase()
// // .contains(searchValue.toLowerCase()),
// // )
// // .toList();
// // }
// },
// decoration: InputDecoration(
// contentPadding: const EdgeInsets.symmetric(
// horizontal: 8.0,
// vertical: 8.0,
// ),
// hintText: "Search",
// hintStyle: TextStyle(
// color: isDarkTheme ? Colors.white : Colors.grey,
// fontSize: 14.0,
// ),
// prefixIcon: const Icon(
// Icons.search,
// color: Colors.grey,
// ),
// border: OutlineInputBorder(
// borderRadius: BorderRadius.circular(10),
// borderSide: BorderSide.none,
// ),
// filled: true,
// fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
// ),
// ),
// );
// }
return Scaffold(
appBar: AppBar(
@@ -301,7 +296,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
),
buildSearchBar(),
// buildSearchBar(),
],
),
),

View File

@@ -92,18 +92,6 @@ class ControlBottomAppBar extends ConsumerWidget {
.tr(),
onPressed: enabled ? onFavorite : null,
),
if (hasRemote && onEditTime != null)
ControlBoxButton(
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".tr(),
onPressed: enabled ? onEditTime : null,
),
if (hasRemote && onEditLocation != null)
ControlBoxButton(
iconData: Icons.edit_location_alt_outlined,
label: "control_bottom_app_bar_edit_location".tr(),
onPressed: enabled ? onEditLocation : null,
),
if (onDelete != null)
ControlBoxButton(
iconData: Icons.delete_outline_rounded,
@@ -125,6 +113,18 @@ class ControlBottomAppBar extends ConsumerWidget {
}
: null,
),
if (hasRemote && onEditTime != null)
ControlBoxButton(
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".tr(),
onPressed: enabled ? onEditTime : null,
),
if (hasRemote && onEditLocation != null)
ControlBoxButton(
iconData: Icons.edit_location_alt_outlined,
label: "control_bottom_app_bar_edit_location".tr(),
onPressed: enabled ? onEditLocation : null,
),
if (!hasLocal &&
selectionAssetState.selectedCount > 1 &&
onStack != null)

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
@@ -95,8 +96,12 @@ class HomePage extends HookConsumerWidget {
}
}
Widget buildBody() {
return MultiselectGrid(
return Scaffold(
appBar: ref.watch(multiselectProvider) ? null : const ImmichAppBar(),
body: MultiselectGrid(
topWidget: (currentUser != null && currentUser.memoryEnabled)
? const MemoryLane()
: const SizedBox(),
renderListProvider: timelineUsers.length > 1
? multiUserAssetsProvider(timelineUsers)
: assetsProvider(currentUser?.isarId),
@@ -105,12 +110,7 @@ class HomePage extends HookConsumerWidget {
stackEnabled: true,
archiveEnabled: true,
editEnabled: true,
);
}
return Scaffold(
appBar: ref.watch(multiselectProvider) ? null : const ImmichAppBar(),
body: buildBody(),
),
);
}
}

View File

@@ -44,7 +44,7 @@ class MapStateNotifier extends StateNotifier<MapState> {
state.mapStyle != null && state.mapStyle!.rasterTileProvider != null;
double get maxZoom =>
(isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 14)
(isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 18)
.toDouble();
void switchTheme(bool isDarkTheme) {

View File

@@ -42,7 +42,7 @@ class UserCircleAvatar extends ConsumerWidget {
child: user.profileImagePath.isEmpty
? textIcon
: ClipRRect(
borderRadius: BorderRadius.circular(50),
borderRadius: const BorderRadius.all(Radius.circular(50)),
child: CachedNetworkImage(
fit: BoxFit.cover,
cacheKey: user.profileImagePath,

View File

@@ -82,6 +82,7 @@ doc/LibraryApi.md
doc/LibraryResponseDto.md
doc/LibraryStatsResponseDto.md
doc/LibraryType.md
doc/LogLevel.md
doc/LoginCredentialDto.md
doc/LoginResponseDto.md
doc/LogoutResponseDto.md
@@ -142,6 +143,7 @@ doc/SystemConfigFFmpegDto.md
doc/SystemConfigJobDto.md
doc/SystemConfigLibraryDto.md
doc/SystemConfigLibraryScanDto.md
doc/SystemConfigLoggingDto.md
doc/SystemConfigMachineLearningDto.md
doc/SystemConfigMapDto.md
doc/SystemConfigNewVersionCheckDto.md
@@ -274,6 +276,7 @@ lib/model/job_status_dto.dart
lib/model/library_response_dto.dart
lib/model/library_stats_response_dto.dart
lib/model/library_type.dart
lib/model/log_level.dart
lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart
@@ -327,6 +330,7 @@ lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_job_dto.dart
lib/model/system_config_library_dto.dart
lib/model/system_config_library_scan_dto.dart
lib/model/system_config_logging_dto.dart
lib/model/system_config_machine_learning_dto.dart
lib/model/system_config_map_dto.dart
lib/model/system_config_new_version_check_dto.dart
@@ -439,6 +443,7 @@ test/library_api_test.dart
test/library_response_dto_test.dart
test/library_stats_response_dto_test.dart
test/library_type_test.dart
test/log_level_test.dart
test/login_credential_dto_test.dart
test/login_response_dto_test.dart
test/logout_response_dto_test.dart
@@ -499,6 +504,7 @@ test/system_config_f_fmpeg_dto_test.dart
test/system_config_job_dto_test.dart
test/system_config_library_dto_test.dart
test/system_config_library_scan_dto_test.dart
test/system_config_logging_dto_test.dart
test/system_config_machine_learning_dto_test.dart
test/system_config_map_dto_test.dart
test/system_config_new_version_check_dto_test.dart

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.90.2
- API version: 1.91.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -281,6 +281,7 @@ Class | Method | HTTP request | Description
- [LibraryResponseDto](doc//LibraryResponseDto.md)
- [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md)
- [LibraryType](doc//LibraryType.md)
- [LogLevel](doc//LogLevel.md)
- [LoginCredentialDto](doc//LoginCredentialDto.md)
- [LoginResponseDto](doc//LoginResponseDto.md)
- [LogoutResponseDto](doc//LogoutResponseDto.md)
@@ -334,6 +335,7 @@ Class | Method | HTTP request | Description
- [SystemConfigJobDto](doc//SystemConfigJobDto.md)
- [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md)
- [SystemConfigLibraryScanDto](doc//SystemConfigLibraryScanDto.md)
- [SystemConfigLoggingDto](doc//SystemConfigLoggingDto.md)
- [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md)
- [SystemConfigMapDto](doc//SystemConfigMapDto.md)
- [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md)

View File

@@ -725,7 +725,7 @@ Name | Type | Description | Notes
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: image/jpeg, image/webp
- **Accept**: application/octet-stream
[[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)

14
mobile/openapi/doc/LogLevel.md generated Normal file
View File

@@ -0,0 +1,14 @@
# openapi.model.LogLevel
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[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

@@ -343,7 +343,7 @@ Name | Type | Description | Notes
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: image/jpeg
- **Accept**: application/octet-stream
[[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)

View File

@@ -66,7 +66,7 @@ This endpoint does not need any parameter.
[[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)
# **search**
> SearchResponseDto search(q, query, clip, type, isFavorite, isArchived, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, exifInfoPeriodProjectionType, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion)
> SearchResponseDto search(q, query, clip, type, recent, motion)
@@ -93,21 +93,11 @@ final q = q_example; // String |
final query = query_example; // String |
final clip = true; // bool |
final type = type_example; // String |
final isFavorite = true; // bool |
final isArchived = true; // bool |
final exifInfoPeriodCity = exifInfoPeriodCity_example; // String |
final exifInfoPeriodState = exifInfoPeriodState_example; // String |
final exifInfoPeriodCountry = exifInfoPeriodCountry_example; // String |
final exifInfoPeriodMake = exifInfoPeriodMake_example; // String |
final exifInfoPeriodModel = exifInfoPeriodModel_example; // String |
final exifInfoPeriodProjectionType = exifInfoPeriodProjectionType_example; // String |
final smartInfoPeriodObjects = []; // List<String> |
final smartInfoPeriodTags = []; // List<String> |
final recent = true; // bool |
final motion = true; // bool |
try {
final result = api_instance.search(q, query, clip, type, isFavorite, isArchived, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, exifInfoPeriodProjectionType, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion);
final result = api_instance.search(q, query, clip, type, recent, motion);
print(result);
} catch (e) {
print('Exception when calling SearchApi->search: $e\n');
@@ -122,16 +112,6 @@ Name | Type | Description | Notes
**query** | **String**| | [optional]
**clip** | **bool**| | [optional]
**type** | **String**| | [optional]
**isFavorite** | **bool**| | [optional]
**isArchived** | **bool**| | [optional]
**exifInfoPeriodCity** | **String**| | [optional]
**exifInfoPeriodState** | **String**| | [optional]
**exifInfoPeriodCountry** | **String**| | [optional]
**exifInfoPeriodMake** | **String**| | [optional]
**exifInfoPeriodModel** | **String**| | [optional]
**exifInfoPeriodProjectionType** | **String**| | [optional]
**smartInfoPeriodObjects** | [**List<String>**](String.md)| | [optional] [default to const []]
**smartInfoPeriodTags** | [**List<String>**](String.md)| | [optional] [default to const []]
**recent** | **bool**| | [optional]
**motion** | **bool**| | [optional]

View File

@@ -11,6 +11,7 @@ Name | Type | Description | Notes
**ffmpeg** | [**SystemConfigFFmpegDto**](SystemConfigFFmpegDto.md) | |
**job** | [**SystemConfigJobDto**](SystemConfigJobDto.md) | |
**library_** | [**SystemConfigLibraryDto**](SystemConfigLibraryDto.md) | |
**logging** | [**SystemConfigLoggingDto**](SystemConfigLoggingDto.md) | |
**machineLearning** | [**SystemConfigMachineLearningDto**](SystemConfigMachineLearningDto.md) | |
**map** | [**SystemConfigMapDto**](SystemConfigMapDto.md) | |
**newVersionCheck** | [**SystemConfigNewVersionCheckDto**](SystemConfigNewVersionCheckDto.md) | |

View File

@@ -0,0 +1,16 @@
# openapi.model.SystemConfigLoggingDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**enabled** | **bool** | |
**level** | [**LogLevel**](LogLevel.md) | |
[[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

@@ -343,7 +343,7 @@ This endpoint does not need any parameter.
[[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)
# **getProfileImage**
> Object getProfileImage(id)
> MultipartFile getProfileImage(id)
@@ -384,7 +384,7 @@ Name | Type | Description | Notes
### Return type
[**Object**](Object.md)
[**MultipartFile**](MultipartFile.md)
### Authorization
@@ -393,7 +393,7 @@ Name | Type | Description | Notes
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
- **Accept**: application/octet-stream
[[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)

View File

@@ -117,6 +117,7 @@ part 'model/job_status_dto.dart';
part 'model/library_response_dto.dart';
part 'model/library_stats_response_dto.dart';
part 'model/library_type.dart';
part 'model/log_level.dart';
part 'model/login_credential_dto.dart';
part 'model/login_response_dto.dart';
part 'model/logout_response_dto.dart';
@@ -170,6 +171,7 @@ part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_job_dto.dart';
part 'model/system_config_library_dto.dart';
part 'model/system_config_library_scan_dto.dart';
part 'model/system_config_logging_dto.dart';
part 'model/system_config_machine_learning_dto.dart';
part 'model/system_config_map_dto.dart';
part 'model/system_config_new_version_check_dto.dart';

View File

@@ -71,30 +71,10 @@ class SearchApi {
///
/// * [String] type:
///
/// * [bool] isFavorite:
///
/// * [bool] isArchived:
///
/// * [String] exifInfoPeriodCity:
///
/// * [String] exifInfoPeriodState:
///
/// * [String] exifInfoPeriodCountry:
///
/// * [String] exifInfoPeriodMake:
///
/// * [String] exifInfoPeriodModel:
///
/// * [String] exifInfoPeriodProjectionType:
///
/// * [List<String>] smartInfoPeriodObjects:
///
/// * [List<String>] smartInfoPeriodTags:
///
/// * [bool] recent:
///
/// * [bool] motion:
Future<Response> searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? isFavorite, bool? isArchived, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, String? exifInfoPeriodProjectionType, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, bool? recent, bool? motion, }) async {
Future<Response> searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async {
// ignore: prefer_const_declarations
final path = r'/search';
@@ -117,36 +97,6 @@ class SearchApi {
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (exifInfoPeriodCity != null) {
queryParams.addAll(_queryParams('', 'exifInfo.city', exifInfoPeriodCity));
}
if (exifInfoPeriodState != null) {
queryParams.addAll(_queryParams('', 'exifInfo.state', exifInfoPeriodState));
}
if (exifInfoPeriodCountry != null) {
queryParams.addAll(_queryParams('', 'exifInfo.country', exifInfoPeriodCountry));
}
if (exifInfoPeriodMake != null) {
queryParams.addAll(_queryParams('', 'exifInfo.make', exifInfoPeriodMake));
}
if (exifInfoPeriodModel != null) {
queryParams.addAll(_queryParams('', 'exifInfo.model', exifInfoPeriodModel));
}
if (exifInfoPeriodProjectionType != null) {
queryParams.addAll(_queryParams('', 'exifInfo.projectionType', exifInfoPeriodProjectionType));
}
if (smartInfoPeriodObjects != null) {
queryParams.addAll(_queryParams('multi', 'smartInfo.objects', smartInfoPeriodObjects));
}
if (smartInfoPeriodTags != null) {
queryParams.addAll(_queryParams('multi', 'smartInfo.tags', smartInfoPeriodTags));
}
if (recent != null) {
queryParams.addAll(_queryParams('', 'recent', recent));
}
@@ -178,31 +128,11 @@ class SearchApi {
///
/// * [String] type:
///
/// * [bool] isFavorite:
///
/// * [bool] isArchived:
///
/// * [String] exifInfoPeriodCity:
///
/// * [String] exifInfoPeriodState:
///
/// * [String] exifInfoPeriodCountry:
///
/// * [String] exifInfoPeriodMake:
///
/// * [String] exifInfoPeriodModel:
///
/// * [String] exifInfoPeriodProjectionType:
///
/// * [List<String>] smartInfoPeriodObjects:
///
/// * [List<String>] smartInfoPeriodTags:
///
/// * [bool] recent:
///
/// * [bool] motion:
Future<SearchResponseDto?> search({ String? q, String? query, bool? clip, String? type, bool? isFavorite, bool? isArchived, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, String? exifInfoPeriodProjectionType, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, bool? recent, bool? motion, }) async {
final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, isFavorite: isFavorite, isArchived: isArchived, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, exifInfoPeriodProjectionType: exifInfoPeriodProjectionType, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, recent: recent, motion: motion, );
Future<SearchResponseDto?> search({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async {
final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, recent: recent, motion: motion, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -327,7 +327,7 @@ class UserApi {
/// Parameters:
///
/// * [String] id (required):
Future<Object?> getProfileImage(String id,) async {
Future<MultipartFile?> getProfileImage(String id,) async {
final response = await getProfileImageWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
@@ -336,7 +336,7 @@ class UserApi {
// 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), 'Object',) as Object;
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;

View File

@@ -321,6 +321,8 @@ class ApiClient {
return LibraryStatsResponseDto.fromJson(value);
case 'LibraryType':
return LibraryTypeTypeTransformer().decode(value);
case 'LogLevel':
return LogLevelTypeTransformer().decode(value);
case 'LoginCredentialDto':
return LoginCredentialDto.fromJson(value);
case 'LoginResponseDto':
@@ -427,6 +429,8 @@ class ApiClient {
return SystemConfigLibraryDto.fromJson(value);
case 'SystemConfigLibraryScanDto':
return SystemConfigLibraryScanDto.fromJson(value);
case 'SystemConfigLoggingDto':
return SystemConfigLoggingDto.fromJson(value);
case 'SystemConfigMachineLearningDto':
return SystemConfigMachineLearningDto.fromJson(value);
case 'SystemConfigMapDto':

View File

@@ -88,6 +88,9 @@ String parameterToString(dynamic value) {
if (value is LibraryType) {
return LibraryTypeTypeTransformer().encode(value).toString();
}
if (value is LogLevel) {
return LogLevelTypeTransformer().encode(value).toString();
}
if (value is MapTheme) {
return MapThemeTypeTransformer().encode(value).toString();
}

97
mobile/openapi/lib/model/log_level.dart generated Normal file
View File

@@ -0,0 +1,97 @@
//
// 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 LogLevel {
/// Instantiate a new enum with the provided [value].
const LogLevel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const verbose = LogLevel._(r'verbose');
static const debug = LogLevel._(r'debug');
static const log = LogLevel._(r'log');
static const warn = LogLevel._(r'warn');
static const error = LogLevel._(r'error');
static const fatal = LogLevel._(r'fatal');
/// List of all possible values in this [enum][LogLevel].
static const values = <LogLevel>[
verbose,
debug,
log,
warn,
error,
fatal,
];
static LogLevel? fromJson(dynamic value) => LogLevelTypeTransformer().decode(value);
static List<LogLevel>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <LogLevel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = LogLevel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [LogLevel] to String,
/// and [decode] dynamic data back to [LogLevel].
class LogLevelTypeTransformer {
factory LogLevelTypeTransformer() => _instance ??= const LogLevelTypeTransformer._();
const LogLevelTypeTransformer._();
String encode(LogLevel data) => data.value;
/// Decodes a [dynamic value][data] to a LogLevel.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
LogLevel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'verbose': return LogLevel.verbose;
case r'debug': return LogLevel.debug;
case r'log': return LogLevel.log;
case r'warn': return LogLevel.warn;
case r'error': return LogLevel.error;
case r'fatal': return LogLevel.fatal;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [LogLevelTypeTransformer] instance.
static LogLevelTypeTransformer? _instance;
}

View File

@@ -16,6 +16,7 @@ class SystemConfigDto {
required this.ffmpeg,
required this.job,
required this.library_,
required this.logging,
required this.machineLearning,
required this.map,
required this.newVersionCheck,
@@ -34,6 +35,8 @@ class SystemConfigDto {
SystemConfigLibraryDto library_;
SystemConfigLoggingDto logging;
SystemConfigMachineLearningDto machineLearning;
SystemConfigMapDto map;
@@ -59,6 +62,7 @@ class SystemConfigDto {
other.ffmpeg == ffmpeg &&
other.job == job &&
other.library_ == library_ &&
other.logging == logging &&
other.machineLearning == machineLearning &&
other.map == map &&
other.newVersionCheck == newVersionCheck &&
@@ -76,6 +80,7 @@ class SystemConfigDto {
(ffmpeg.hashCode) +
(job.hashCode) +
(library_.hashCode) +
(logging.hashCode) +
(machineLearning.hashCode) +
(map.hashCode) +
(newVersionCheck.hashCode) +
@@ -88,13 +93,14 @@ class SystemConfigDto {
(trash.hashCode);
@override
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ffmpeg'] = this.ffmpeg;
json[r'job'] = this.job;
json[r'library'] = this.library_;
json[r'logging'] = this.logging;
json[r'machineLearning'] = this.machineLearning;
json[r'map'] = this.map;
json[r'newVersionCheck'] = this.newVersionCheck;
@@ -119,6 +125,7 @@ class SystemConfigDto {
ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!,
job: SystemConfigJobDto.fromJson(json[r'job'])!,
library_: SystemConfigLibraryDto.fromJson(json[r'library'])!,
logging: SystemConfigLoggingDto.fromJson(json[r'logging'])!,
machineLearning: SystemConfigMachineLearningDto.fromJson(json[r'machineLearning'])!,
map: SystemConfigMapDto.fromJson(json[r'map'])!,
newVersionCheck: SystemConfigNewVersionCheckDto.fromJson(json[r'newVersionCheck'])!,
@@ -179,6 +186,7 @@ class SystemConfigDto {
'ffmpeg',
'job',
'library',
'logging',
'machineLearning',
'map',
'newVersionCheck',

View File

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

21
mobile/openapi/test/log_level_test.dart generated Normal file
View File

@@ -0,0 +1,21 @@
//
// 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 LogLevel
void main() {
group('test LogLevel', () {
});
}

View File

@@ -22,7 +22,7 @@ void main() {
// TODO
});
//Future<SearchResponseDto> search({ String q, String query, bool clip, String type, bool isFavorite, bool isArchived, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, String exifInfoPeriodProjectionType, List<String> smartInfoPeriodObjects, List<String> smartInfoPeriodTags, bool recent, bool motion }) async
//Future<SearchResponseDto> search({ String q, String query, bool clip, String type, bool recent, bool motion }) async
test('test search', () async {
// TODO
});

View File

@@ -31,6 +31,11 @@ void main() {
// TODO
});
// SystemConfigLoggingDto logging
test('to test the property `logging`', () async {
// TODO
});
// SystemConfigMachineLearningDto machineLearning
test('to test the property `machineLearning`', () 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 SystemConfigLoggingDto
void main() {
// final instance = SystemConfigLoggingDto();
group('test SystemConfigLoggingDto', () {
// bool enabled
test('to test the property `enabled`', () async {
// TODO
});
// LogLevel level
test('to test the property `level`', () async {
// TODO
});
});
}

View File

@@ -47,7 +47,7 @@ void main() {
// TODO
});
//Future<Object> getProfileImage(String id) async
//Future<MultipartFile> getProfileImage(String id) async
test('test getProfileImage', () async {
// TODO
});

View File

@@ -964,14 +964,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
mockito:
mocktail:
dependency: "direct dev"
description:
name: mockito
sha256: "7d5b53bcd556c1bc7ffbe4e4d5a19c3e112b7e925e9e172dd7c6ad0630812616"
name: mocktail
sha256: bac151b31e4ed78bd59ab89aa4c0928f297b1180186d5daf03734519e5f596c1
url: "https://pub.dev"
source: hosted
version: "5.4.2"
version: "1.0.1"
nested:
dependency: transitive
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.90.2+114
version: 1.91.0+115
isar_version: &isar_version 3.1.0+1
environment:
@@ -84,12 +84,12 @@ dev_dependencies:
flutter_launcher_icons: "^0.9.2"
flutter_native_splash: ^2.2.16
isar_generator: *isar_version
mockito: ^5.3.2
integration_test:
sdk: flutter
custom_lint: ^0.5.6
riverpod_lint: ^2.1.0
riverpod_generator: ^2.3.3
mocktail: ^1.0.1
flutter:
uses-material-design: true

View File

@@ -1,182 +1,321 @@
import 'package:collection/collection.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'album.stub.dart';
import 'asset.stub.dart';
import 'fixtures/album.stub.dart';
import 'fixtures/asset.stub.dart';
import 'mocks/app_settings_provider.mock.dart';
import 'test_utils.dart';
void main() {
late final Isar db;
/// Verify the sort modes
group("AlbumSortMode", () {
late final Isar db;
setUpAll(() async {
await Isar.initializeIsarCore(download: true);
db = await Isar.open(
[
AssetSchema,
AlbumSchema,
UserSchema,
],
maxSizeMiB: 256,
directory: ".",
);
});
final albums = [
AlbumStub.emptyAlbum,
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
];
setUp(() {
db.writeTxnSync(() {
db.clearSync();
// Save all assets
db.assets.putAllSync([AssetStub.image1, AssetStub.image2]);
db.albums.putAllSync(albums);
for (final album in albums) {
album.sharedUsers.saveSync();
album.assets.saveSync();
}
});
expect(db.albums.countSync(), 4);
expect(db.assets.countSync(), 2);
});
group("Album sort - Created Time", () {
const created = AlbumSortMode.created;
test("Created time - ASC", () {
final sorted = created.sortFn(albums, false);
expect(sorted.isSortedBy((a) => a.createdAt), true);
setUpAll(() async {
db = await TestUtils.initIsar();
});
test("Created time - DESC", () {
final sorted = created.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.createdAt.compareTo(b.createdAt)),
true,
);
final albums = [
AlbumStub.emptyAlbum,
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
];
setUp(() {
db.writeTxnSync(() {
db.clearSync();
// Save all assets
db.assets.putAllSync([AssetStub.image1, AssetStub.image2]);
db.albums.putAllSync(albums);
for (final album in albums) {
album.sharedUsers.saveSync();
album.assets.saveSync();
}
});
expect(db.albums.countSync(), 4);
expect(db.assets.countSync(), 2);
});
group("Album sort - Created Time", () {
const created = AlbumSortMode.created;
test("Created time - ASC", () {
final sorted = created.sortFn(albums, false);
expect(sorted.isSortedBy((a) => a.createdAt), true);
});
test("Created time - DESC", () {
final sorted = created.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.createdAt.compareTo(b.createdAt)),
true,
);
});
});
group("Album sort - Asset count", () {
const assetCount = AlbumSortMode.assetCount;
test("Asset Count - ASC", () {
final sorted = assetCount.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.assetCount.compareTo(b.assetCount)),
true,
);
});
test("Asset Count - DESC", () {
final sorted = assetCount.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.assetCount.compareTo(b.assetCount)),
true,
);
});
});
group("Album sort - Last modified", () {
const lastModified = AlbumSortMode.lastModified;
test("Last modified - ASC", () {
final sorted = lastModified.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.modifiedAt.compareTo(b.modifiedAt)),
true,
);
});
test("Last modified - DESC", () {
final sorted = lastModified.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.modifiedAt.compareTo(b.modifiedAt)),
true,
);
});
});
group("Album sort - Created", () {
const created = AlbumSortMode.created;
test("Created - ASC", () {
final sorted = created.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.createdAt.compareTo(b.createdAt)),
true,
);
});
test("Created - DESC", () {
final sorted = created.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.createdAt.compareTo(b.createdAt)),
true,
);
});
});
group("Album sort - Most Recent", () {
const mostRecent = AlbumSortMode.mostRecent;
test("Most Recent - ASC", () {
final sorted = mostRecent.sortFn(albums, false);
expect(
sorted,
[
AlbumStub.sharedWithUser,
AlbumStub.twoAsset,
AlbumStub.oneAsset,
AlbumStub.emptyAlbum,
],
);
});
test("Most Recent - DESC", () {
final sorted = mostRecent.sortFn(albums, true);
expect(
sorted,
[
AlbumStub.emptyAlbum,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
AlbumStub.sharedWithUser,
],
);
});
});
group("Album sort - Most Oldest", () {
const mostOldest = AlbumSortMode.mostOldest;
test("Most Oldest - ASC", () {
final sorted = mostOldest.sortFn(albums, false);
expect(
sorted,
[
AlbumStub.twoAsset,
AlbumStub.emptyAlbum,
AlbumStub.oneAsset,
AlbumStub.sharedWithUser,
],
);
});
test("Most Oldest - DESC", () {
final sorted = mostOldest.sortFn(albums, true);
expect(
sorted,
[
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.emptyAlbum,
AlbumStub.twoAsset,
],
);
});
});
});
group("Album sort - Asset count", () {
const assetCount = AlbumSortMode.assetCount;
test("Asset Count - ASC", () {
final sorted = assetCount.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.assetCount.compareTo(b.assetCount)),
true,
/// Verify the sort mode provider
group('AlbumSortByOptions', () {
late AppSettingsService settingsMock;
late ProviderContainer container;
setUp(() async {
settingsMock = AppSettingsServiceMock();
container = TestUtils.createContainer(
overrides: [getAppSettingsServiceMock(settingsMock)],
);
});
test("Asset Count - DESC", () {
final sorted = assetCount.sortFn(albums, true);
test('Returns the default sort mode when none set', () {
// Returns the default value when nothing is set
when(
() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortOrder),
).thenReturn(0);
expect(AlbumSortMode.created, container.read(albumSortByOptionsProvider));
});
test('Returns the correct sort mode with index from Store', () {
// Returns the default value when nothing is set
when(
() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortOrder),
).thenReturn(3);
expect(
sorted.isSorted((b, a) => a.assetCount.compareTo(b.assetCount)),
true,
AlbumSortMode.lastModified,
container.read(albumSortByOptionsProvider),
);
});
test('Properly saves the correct store index of sort mode', () {
container
.read(albumSortByOptionsProvider.notifier)
.changeSortMode(AlbumSortMode.mostOldest);
verify(
() => settingsMock.setSetting(
AppSettingsEnum.selectedAlbumSortOrder,
AlbumSortMode.mostOldest.storeIndex,
),
);
});
test('Notifies listeners on state change', () {
when(
() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortOrder),
).thenReturn(0);
final listener = ListenerMock<AlbumSortMode>();
container.listen(
albumSortByOptionsProvider,
listener,
fireImmediately: true,
);
// Created -> Most Oldest
container
.read(albumSortByOptionsProvider.notifier)
.changeSortMode(AlbumSortMode.mostOldest);
// Most Oldest -> Title
container
.read(albumSortByOptionsProvider.notifier)
.changeSortMode(AlbumSortMode.title);
verifyInOrder([
() => listener.call(null, AlbumSortMode.created),
() => listener.call(AlbumSortMode.created, AlbumSortMode.mostOldest),
() => listener.call(AlbumSortMode.mostOldest, AlbumSortMode.title),
]);
verifyNoMoreInteractions(listener);
});
});
group("Album sort - Last modified", () {
const lastModified = AlbumSortMode.lastModified;
test("Last modified - ASC", () {
final sorted = lastModified.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.modifiedAt.compareTo(b.modifiedAt)),
true,
/// Verify the sort order provider
group('AlbumSortOrder', () {
late AppSettingsService settingsMock;
late ProviderContainer container;
setUp(() async {
settingsMock = AppSettingsServiceMock();
container = TestUtils.createContainer(
overrides: [getAppSettingsServiceMock(settingsMock)],
);
});
test("Last modified - DESC", () {
final sorted = lastModified.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.modifiedAt.compareTo(b.modifiedAt)),
true,
);
});
});
test('Returns the default sort order when none set - false', () {
when(
() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortReverse),
).thenReturn(false);
group("Album sort - Created", () {
const created = AlbumSortMode.created;
test("Created - ASC", () {
final sorted = created.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.createdAt.compareTo(b.createdAt)),
true,
expect(false, container.read(albumSortOrderProvider));
});
test('Properly saves the correct order', () {
container.read(albumSortOrderProvider.notifier).changeSortDirection(true);
verify(
() => settingsMock.setSetting(
AppSettingsEnum.selectedAlbumSortReverse,
true,
),
);
});
test("Created - DESC", () {
final sorted = created.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.createdAt.compareTo(b.createdAt)),
true,
test('Notifies listeners on state change', () {
when(
() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortReverse),
).thenReturn(false);
final listener = ListenerMock<bool>();
container.listen(
albumSortOrderProvider,
listener,
fireImmediately: true,
);
});
});
group("Album sort - Most Recent", () {
const mostRecent = AlbumSortMode.mostRecent;
// false -> true
container.read(albumSortOrderProvider.notifier).changeSortDirection(true);
test("Most Recent - ASC", () {
final sorted = mostRecent.sortFn(albums, false);
expect(
sorted,
[
AlbumStub.sharedWithUser,
AlbumStub.twoAsset,
AlbumStub.oneAsset,
AlbumStub.emptyAlbum,
],
);
});
// true -> false
container
.read(albumSortOrderProvider.notifier)
.changeSortDirection(false);
test("Most Recent - DESC", () {
final sorted = mostRecent.sortFn(albums, true);
expect(
sorted,
[
AlbumStub.emptyAlbum,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
AlbumStub.sharedWithUser,
],
);
});
});
verifyInOrder([
() => listener.call(null, false),
() => listener.call(false, true),
() => listener.call(true, false),
]);
group("Album sort - Most Oldest", () {
const mostOldest = AlbumSortMode.mostOldest;
test("Most Oldest - ASC", () {
final sorted = mostOldest.sortFn(albums, false);
expect(
sorted,
[
AlbumStub.twoAsset,
AlbumStub.emptyAlbum,
AlbumStub.oneAsset,
AlbumStub.sharedWithUser,
],
);
});
test("Most Oldest - DESC", () {
final sorted = mostOldest.sortFn(albums, true);
expect(
sorted,
[
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.emptyAlbum,
AlbumStub.twoAsset,
],
);
verifyNoMoreInteractions(listener);
});
});
}

View File

@@ -0,0 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:mocktail/mocktail.dart';
class AppSettingsServiceMock with Mock implements AppSettingsService {}
Override getAppSettingsServiceMock(AppSettingsService service) =>
appSettingsServiceProvider.overrideWith((ref) => service);

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/shared/services/hash.service.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:isar/isar.dart';
import 'package:mockito/mockito.dart';
import 'package:mocktail/mocktail.dart';
void main() {
Asset makeAsset({

View File

@@ -0,0 +1,71 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/android_device_asset.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/ios_device_asset.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
// Listener Mock to test when a provider notifies its listeners
class ListenerMock<T> extends Mock {
// ignore: avoid-declaring-call-method
void call(T? previous, T next);
}
final class TestUtils {
const TestUtils._();
/// Downloads Isar binaries (if required) and initializes a new Isar db
static Future<Isar> initIsar() async {
await Isar.initializeIsarCore(download: true);
final db = await Isar.open(
[
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
AndroidDeviceAssetSchema,
IOSDeviceAssetSchema,
],
maxSizeMiB: 256,
directory: ".",
);
// Clear and close db on test end
addTearDown(() async {
await db.writeTxn(() => db.clear());
await db.close();
});
return db;
}
/// Creates a new ProviderContainer to test Riverpod providers
static ProviderContainer createContainer({
ProviderContainer? parent,
List<Override> overrides = const [],
List<ProviderObserver>? observers,
}) {
final container = ProviderContainer(
parent: parent,
overrides: overrides,
observers: observers,
);
// Dispose on test end
addTearDown(container.dispose);
return container;
}
}

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "immich",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}

View File

@@ -71,5 +71,6 @@
"vector_map_tiles",
"flutter_map",
"flutter_map_heatmap"
]
],
"labels": ["renovate"]
}

2
server/.gitignore vendored
View File

@@ -11,6 +11,8 @@ yarn-debug.log*
yarn-error.log*
lerna-debug.log*
www/
# OS
.DS_Store

View File

@@ -1,29 +1,37 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20231201@sha256:4701c0c5920c78e73040dd2b74d22042ffce393f1a9d3453d90a0ecf81ff8649 as dev
FROM ghcr.io/immich-app/base-server-dev:20231214@sha256:cd5be516b27c0c402bee3a6a93d8c83dfd5a827c18a2343cb97b55f3be98151b as dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./
RUN npm ci
RUN npm ci && \
# sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning
rm -rf node_modules/@img/sharp-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64
COPY server .
ENV PATH="${PATH}:/usr/src/app/bin"
ENTRYPOINT ["tini", "--", "/bin/sh"]
FROM dev AS prod
RUN npm run build
RUN npm prune --omit=dev --omit=optional
COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
# web build
FROM node:iron-alpine3.18 as web
WORKDIR /usr/src/app
COPY web/package.json web/package-lock.json ./
COPY web/package*.json web/svelte.config.js .
RUN npm ci
COPY web .
RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20231201@sha256:b8e86cf4c3cad872f54bab25a83f7503480049eea5c0ae36a8b8460b13cad3b5
FROM ghcr.io/immich-app/base-server-prod:20231214@sha256:b214f86683fde081b09beed2d7bfc28bec55c829751ccf2e02ad7dd18293f5e0
WORKDIR /usr/src/app
ENV NODE_ENV=production

2
server/bin/immich-dev Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
node /usr/src/app/node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch -- "$@"

View File

@@ -1465,6 +1465,15 @@
"get": {
"operationId": "serveFile",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isThumb",
"required": false,
@@ -1483,15 +1492,6 @@
"type": "boolean"
}
},
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
@@ -1926,13 +1926,7 @@
"responses": {
"200": {
"content": {
"image/jpeg": {
"schema": {
"format": "binary",
"type": "string"
}
},
"image/webp": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
@@ -4499,7 +4493,7 @@
"responses": {
"200": {
"content": {
"image/jpeg": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
@@ -4567,92 +4561,6 @@
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "exifInfo.city",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.state",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.country",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.make",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.model",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.projectionType",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "smartInfo.objects",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "smartInfo.tags",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "recent",
"required": false,
@@ -6166,9 +6074,10 @@
"responses": {
"200": {
"content": {
"application/json": {
"application/octet-stream": {
"schema": {
"type": "object"
"format": "binary",
"type": "string"
}
}
},
@@ -6279,7 +6188,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.90.2",
"version": "1.91.0",
"contact": {}
},
"tags": [],
@@ -8040,6 +7949,17 @@
],
"type": "string"
},
"LogLevel": {
"enum": [
"verbose",
"debug",
"log",
"warn",
"error",
"fatal"
],
"type": "string"
},
"LoginCredentialDto": {
"properties": {
"email": {
@@ -9130,6 +9050,9 @@
"library": {
"$ref": "#/components/schemas/SystemConfigLibraryDto"
},
"logging": {
"$ref": "#/components/schemas/SystemConfigLoggingDto"
},
"machineLearning": {
"$ref": "#/components/schemas/SystemConfigMachineLearningDto"
},
@@ -9163,6 +9086,7 @@
},
"required": [
"ffmpeg",
"logging",
"machineLearning",
"map",
"newVersionCheck",
@@ -9334,6 +9258,21 @@
],
"type": "object"
},
"SystemConfigLoggingDto": {
"properties": {
"enabled": {
"type": "boolean"
},
"level": {
"$ref": "#/components/schemas/LogLevel"
}
},
"required": [
"level",
"enabled"
],
"type": "object"
},
"SystemConfigMachineLearningDto": {
"properties": {
"classification": {

1596
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.90.2",
"version": "1.91.0",
"description": "",
"author": "",
"private": true,
@@ -48,8 +48,9 @@
"@nestjs/swagger": "^7.1.8",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/websockets": "^10.2.2",
"@socket.io/redis-adapter": "^8.2.1",
"@socket.io/postgres-adapter": "^0.3.1",
"archiver": "^6.0.0",
"async-lock": "^1.4.0",
"axios": "^1.5.0",
"bcrypt": "^5.1.1",
"bullmq": "^4.8.0",
@@ -69,15 +70,15 @@
"luxon": "^3.4.2",
"mv": "^2.1.1",
"nest-commander": "^3.11.1",
"node-addon-api": "^7.0.0",
"openid-client": "^5.4.3",
"pg": "^8.11.3",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"sharp": "^0.32.6",
"sharp": "^0.33.0",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.17",
"typesense": "^1.7.1",
"ua-parser-js": "^1.0.35"
},
"devDependencies": {
@@ -87,9 +88,9 @@
"@openapitools/openapi-generator-cli": "2.7.0",
"@testcontainers/postgresql": "^10.2.1",
"@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/imagemin": "^8.0.1",
@@ -116,7 +117,7 @@
"prettier-plugin-organize-imports": "^3.2.3",
"rimraf": "^5.0.1",
"source-map-support": "^0.5.21",
"sql-formatter": "^14.0.0",
"sql-formatter": "^15.0.0",
"supertest": "^6.3.3",
"testcontainers": "^10.2.1",
"ts-jest": "^29.1.1",
@@ -152,9 +153,6 @@
"statements": 90
}
},
"setupFilesAfterEnv": [
"<rootDir>/test/setup.ts"
],
"testEnvironment": "node",
"moduleNameMapper": {
"^@test(|/.*)$": "<rootDir>/test/$1",

View File

@@ -1,5 +1,6 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { SharedLinkEntity } from '../../infra/entities';
import { AuthDto } from '../auth';
import { setDifference, setIsEqual, setUnion } from '../domain.util';
import { IAccessRepository } from '../repositories';
@@ -64,20 +65,20 @@ export class AccessCore {
instance = null;
}
requireUploadAccess(authUser: AuthUserDto | null): AuthUserDto {
if (!authUser || (authUser.isPublicUser && !authUser.isAllowUpload)) {
requireUploadAccess(auth: AuthDto | null): AuthDto {
if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) {
throw new UnauthorizedException();
}
return authUser;
return auth;
}
/**
* Check if user has access to all ids, for the given permission.
* Throws error if user does not have access to any of the ids.
*/
async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
async requirePermission(auth: AuthDto, permission: Permission, ids: string[] | string) {
ids = Array.isArray(ids) ? ids : [ids];
const allowedIds = await this.checkAccess(authUser, permission, ids);
const allowedIds = await this.checkAccess(auth, permission, ids);
if (!setIsEqual(new Set(ids), allowedIds)) {
throw new BadRequestException(`Not found or no ${permission} access`);
}
@@ -89,23 +90,21 @@ export class AccessCore {
*
* @returns Set<string>
*/
async checkAccess(authUser: AuthUserDto, permission: Permission, ids: Set<string> | string[]) {
async checkAccess(auth: AuthDto, permission: Permission, ids: Set<string> | string[]) {
const idSet = Array.isArray(ids) ? new Set(ids) : ids;
if (idSet.size === 0) {
return new Set();
}
const isSharedLink = authUser.isPublicUser ?? false;
return isSharedLink
? await this.checkAccessSharedLink(authUser, permission, idSet)
: await this.checkAccessOther(authUser, permission, idSet);
if (auth.sharedLink) {
return this.checkAccessSharedLink(auth.sharedLink, permission, idSet);
}
return this.checkAccessOther(auth, permission, idSet);
}
private async checkAccessSharedLink(authUser: AuthUserDto, permission: Permission, ids: Set<string>) {
const sharedLinkId = authUser.sharedLinkId;
if (!sharedLinkId) {
return new Set();
}
private async checkAccessSharedLink(sharedLink: SharedLinkEntity, permission: Permission, ids: Set<string>) {
const sharedLinkId = sharedLink.id;
switch (permission) {
case Permission.ASSET_READ:
@@ -115,22 +114,22 @@ export class AccessCore {
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
case Permission.ASSET_DOWNLOAD:
return !!authUser.isAllowDownload
return !!sharedLink.allowDownload
? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids)
: new Set();
case Permission.ASSET_UPLOAD:
return authUser.isAllowUpload ? ids : new Set();
return sharedLink.allowUpload ? ids : new Set();
case Permission.ASSET_SHARE:
// TODO: fix this to not use authUser.id for shared link access control
return await this.repository.asset.checkOwnerAccess(authUser.id, ids);
// TODO: fix this to not use sharedLink.userId for access control
return await this.repository.asset.checkOwnerAccess(sharedLink.userId, ids);
case Permission.ALBUM_READ:
return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids);
case Permission.ALBUM_DOWNLOAD:
return !!authUser.isAllowDownload
return !!sharedLink.allowDownload
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
: new Set();
@@ -139,129 +138,129 @@ export class AccessCore {
}
}
private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set<string>) {
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>) {
switch (permission) {
case Permission.ASSET_READ: {
const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner));
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await this.repository.asset.checkPartnerAccess(
authUser.id,
auth.user.id,
setDifference(ids, isOwner, isAlbum),
);
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_SHARE: {
const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids);
const isPartner = await this.repository.asset.checkPartnerAccess(authUser.id, setDifference(ids, isOwner));
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isPartner = await this.repository.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.ASSET_VIEW: {
const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner));
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await this.repository.asset.checkPartnerAccess(
authUser.id,
auth.user.id,
setDifference(ids, isOwner, isAlbum),
);
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_DOWNLOAD: {
const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner));
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await this.repository.asset.checkPartnerAccess(
authUser.id,
auth.user.id,
setDifference(ids, isOwner, isAlbum),
);
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_UPDATE:
return await this.repository.asset.checkOwnerAccess(authUser.id, ids);
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
case Permission.ASSET_DELETE:
return await this.repository.asset.checkOwnerAccess(authUser.id, ids);
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
case Permission.ASSET_RESTORE:
return await this.repository.asset.checkOwnerAccess(authUser.id, ids);
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
case Permission.ALBUM_READ: {
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner));
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await this.repository.album.checkSharedAlbumAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isShared);
}
case Permission.ALBUM_UPDATE:
return await this.repository.album.checkOwnerAccess(authUser.id, ids);
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
case Permission.ALBUM_DELETE:
return await this.repository.album.checkOwnerAccess(authUser.id, ids);
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
case Permission.ALBUM_SHARE:
return await this.repository.album.checkOwnerAccess(authUser.id, ids);
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
case Permission.ALBUM_DOWNLOAD: {
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner));
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await this.repository.album.checkSharedAlbumAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isShared);
}
case Permission.ALBUM_REMOVE_ASSET:
return await this.repository.album.checkOwnerAccess(authUser.id, ids);
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
case Permission.ASSET_UPLOAD:
return await this.repository.library.checkOwnerAccess(authUser.id, ids);
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
case Permission.ARCHIVE_READ:
return ids.has(authUser.id) ? new Set([authUser.id]) : new Set();
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
case Permission.AUTH_DEVICE_DELETE:
return await this.repository.authDevice.checkOwnerAccess(authUser.id, ids);
return await this.repository.authDevice.checkOwnerAccess(auth.user.id, ids);
case Permission.TIMELINE_READ: {
const isOwner = ids.has(authUser.id) ? new Set([authUser.id]) : new Set<string>();
const isPartner = await this.repository.timeline.checkPartnerAccess(authUser.id, setDifference(ids, isOwner));
const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
const isPartner = await this.repository.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.TIMELINE_DOWNLOAD:
return ids.has(authUser.id) ? new Set([authUser.id]) : new Set();
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
case Permission.LIBRARY_READ: {
const isOwner = await this.repository.library.checkOwnerAccess(authUser.id, ids);
const isPartner = await this.repository.library.checkPartnerAccess(authUser.id, setDifference(ids, isOwner));
const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids);
const isPartner = await this.repository.library.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.LIBRARY_UPDATE:
return await this.repository.library.checkOwnerAccess(authUser.id, ids);
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
case Permission.LIBRARY_DELETE:
return await this.repository.library.checkOwnerAccess(authUser.id, ids);
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
case Permission.PERSON_READ:
return await this.repository.person.checkOwnerAccess(authUser.id, ids);
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
case Permission.PERSON_WRITE:
return await this.repository.person.checkOwnerAccess(authUser.id, ids);
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
case Permission.PERSON_MERGE:
return await this.repository.person.checkOwnerAccess(authUser.id, ids);
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
case Permission.PERSON_CREATE:
return this.repository.person.hasFaceOwnerAccess(authUser.id, ids);
return this.repository.person.hasFaceOwnerAccess(auth.user.id, ids);
case Permission.PERSON_REASSIGN:
return this.repository.person.hasFaceOwnerAccess(authUser.id, ids);
return this.repository.person.hasFaceOwnerAccess(auth.user.id, ids);
case Permission.PARTNER_UPDATE:
return await this.repository.partner.checkUpdateAccess(authUser.id, ids);
return await this.repository.partner.checkUpdateAccess(auth.user.id, ids);
}
const allowedIds = new Set();
for (const id of ids) {
const hasAccess = await this.hasOtherAccess(authUser, permission, id);
const hasAccess = await this.hasOtherAccess(auth, permission, id);
if (hasAccess) {
allowedIds.add(id);
}
@@ -270,17 +269,17 @@ export class AccessCore {
}
// TODO: Migrate logic to checkAccessOther to evaluate permissions in bulk.
private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) {
private async hasOtherAccess(auth: AuthDto, permission: Permission, id: string) {
switch (permission) {
// uses album id
case Permission.ACTIVITY_CREATE:
return await this.repository.activity.hasCreateAccess(authUser.id, id);
return await this.repository.activity.hasCreateAccess(auth.user.id, id);
// uses activity id
case Permission.ACTIVITY_DELETE:
return (
(await this.repository.activity.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.activity.hasAlbumOwnerAccess(authUser.id, id))
(await this.repository.activity.hasOwnerAccess(auth.user.id, id)) ||
(await this.repository.activity.hasAlbumOwnerAccess(auth.user.id, id))
);
default:

View File

@@ -1,7 +1,7 @@
import { ActivityEntity } from '@app/infra/entities';
import { Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { AuthDto } from '../auth';
import { IAccessRepository, IActivityRepository } from '../repositories';
import {
ActivityCreateDto,
@@ -26,8 +26,8 @@ export class ActivityService {
this.access = AccessCore.create(accessRepository);
}
async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId);
const activities = await this.repository.search({
userId: dto.userId,
albumId: dto.albumId,
@@ -38,16 +38,16 @@ export class ActivityService {
return activities.map(mapActivity);
}
async getStatistics(authUser: AuthUserDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId);
return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) };
}
async create(authUser: AuthUserDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
await this.access.requirePermission(authUser, Permission.ACTIVITY_CREATE, dto.albumId);
async create(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
await this.access.requirePermission(auth, Permission.ACTIVITY_CREATE, dto.albumId);
const common = {
userId: authUser.id,
userId: auth.user.id,
assetId: dto.assetId,
albumId: dto.albumId,
};
@@ -77,8 +77,8 @@ export class ActivityService {
return { duplicate, value: mapActivity(activity) };
}
async delete(authUser: AuthUserDto, id: string): Promise<void> {
await this.access.requirePermission(authUser, Permission.ACTIVITY_DELETE, id);
async delete(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.ACTIVITY_DELETE, id);
await this.repository.delete(id);
}
}

View File

@@ -12,7 +12,6 @@ import {
} from '@test';
import _ from 'lodash';
import { BulkIdErrorReason } from '../asset';
import { JobName } from '../job';
import { IAlbumRepository, IAssetRepository, IJobRepository, IUserRepository } from '../repositories';
import { AlbumService } from './album.service';
@@ -49,9 +48,9 @@ describe(AlbumService.name, () => {
notShared: 0,
});
expect(albumMock.getOwned).toHaveBeenCalledWith(authStub.admin.id);
expect(albumMock.getShared).toHaveBeenCalledWith(authStub.admin.id);
expect(albumMock.getNotShared).toHaveBeenCalledWith(authStub.admin.id);
expect(albumMock.getOwned).toHaveBeenCalledWith(authStub.admin.user.id);
expect(albumMock.getShared).toHaveBeenCalledWith(authStub.admin.user.id);
expect(albumMock.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id);
});
});
@@ -188,13 +187,8 @@ describe(AlbumService.name, () => {
assetIds: ['123'],
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ALBUM,
data: { ids: [albumStub.empty.id] },
});
expect(albumMock.create).toHaveBeenCalledWith({
ownerId: authStub.admin.id,
ownerId: authStub.admin.user.id,
albumName: albumStub.empty.albumName,
description: albumStub.empty.description,
sharedUsers: [{ id: 'user-id' }],
@@ -270,10 +264,6 @@ describe(AlbumService.name, () => {
id: 'album-4',
albumName: 'new album name',
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ALBUM,
data: { ids: [albumStub.oneAsset.id] },
});
});
});
@@ -322,7 +312,7 @@ describe(AlbumService.name, () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.user.id] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});
@@ -342,11 +332,11 @@ describe(AlbumService.name, () => {
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(userStub.user2);
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.user.id] });
expect(albumMock.update).toHaveBeenCalledWith({
id: albumStub.sharedWithAdmin.id,
updatedAt: expect.any(Date),
sharedUsers: [userStub.admin, { id: authStub.user2.id }],
sharedUsers: [userStub.admin, { id: authStub.user2.user.id }],
});
});
});
@@ -380,12 +370,12 @@ describe(AlbumService.name, () => {
albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple);
await expect(
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
authStub.user1.id,
authStub.user1.user.id,
new Set([albumStub.sharedWithMultiple.id]),
);
});
@@ -393,7 +383,7 @@ describe(AlbumService.name, () => {
it('should allow a shared user to remove themselves', async () => {
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id);
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id);
expect(albumMock.update).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({
@@ -419,7 +409,7 @@ describe(AlbumService.name, () => {
it('should not allow the owner to be removed', async () => {
albumMock.getById.mockResolvedValue(albumStub.empty);
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.user.id)).rejects.toBeInstanceOf(
BadRequestException,
);
@@ -454,7 +444,7 @@ describe(AlbumService.name, () => {
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.id,
authStub.admin.user.id,
new Set([albumStub.oneAsset.id]),
);
});
@@ -475,7 +465,7 @@ describe(AlbumService.name, () => {
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
authStub.adminSharedLink.sharedLink?.id,
new Set(['album-123']),
);
});
@@ -495,14 +485,20 @@ describe(AlbumService.name, () => {
await sut.get(authStub.user1, 'album-123', {});
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123']));
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
authStub.user1.user.id,
new Set(['album-123']),
);
});
it('should throw an error for no access', async () => {
await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-123']));
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-123']));
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123']));
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set(['album-123']),
);
});
});
@@ -600,7 +596,7 @@ describe(AlbumService.name, () => {
});
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
authStub.adminSharedLink.sharedLink?.id,
new Set(['album-123']),
);
});
@@ -620,7 +616,7 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
});
it('should skip duplicate assets', async () => {
@@ -645,8 +641,8 @@ describe(AlbumService.name, () => {
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
]);
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
});
it('should not allow unauthorized access to the album', async () => {
@@ -739,7 +735,7 @@ describe(AlbumService.name, () => {
// // await expect(
// // sut.removeAssetsFromAlbum(
// // authUser,
// // auth,
// // {
// // ids: ['1'],
// // },
@@ -765,6 +761,6 @@ describe(AlbumService.name, () => {
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
// await expect(sut.removeAssets(authUser, albumId, { ids: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
// await expect(sut.removeAssets(auth, albumId, { ids: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
// });
});

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