Compare commits

...

29 Commits

Author SHA1 Message Date
Alex The Bot
b40859551b Version v1.91.4 2023-12-19 03:34:19 +00:00
Jonathan Jogenfors
4e9b96ff1a test(cli): e2e testing (#5101)
* Allow building and installing cli

* feat: add format fix

* docs: remove cli folder

* feat: use immich scoped package

* feat: rewrite cli readme

* docs: add info on running without building

* cleanup

* chore: remove import functionality from cli

* feat: add logout to cli

* docs: add todo for file format from server

* docs: add compilation step to cli

* fix: success message spacing

* feat: can create albums

* fix: add check step to cli

* fix: typos

* feat: pull file formats from server

* chore: use crawl service from server

* chore: fix lint

* docs: add cli documentation

* chore: rename ignore pattern

* chore: add version number to cli

* feat: use sdk

* fix: cleanup

* feat: album name on windows

* chore: remove skipped asset field

* feat: add more info to server-info command

* chore: cleanup

* wip

* chore: remove unneeded packages

* e2e test can start

* git ignore for geocode in cli

* add cli e2e to github actions

* can do e2e tests in the cli

* simplify e2e test

* cleanup

* set matrix strategy in workflow

* run npm ci in server

* choose different working directory

* check out submodules too

* increase test timeout

* set node version

* cli docker e2e tests

* fix cli docker file

* run cli e2e in correct folder

* set docker context

* correct docker build

* remove cli from dockerignore

* chore: fix docs links

* feat: add cli v2 milestone

* fix: set correct cli date

* remove submodule

* chore: add npmignore

* chore(cli): push to npm

* fix: server e2e

* run npm ci in server

* remove state from e2e

* run npm ci in server

* reshuffle docker compose files

* use new e2e composes in makefile

* increase test timeout to 10 minutes

* make github actions run makefile e2e tests

* cleanup github test names

* assert on server version

* chore: split cli e2e tests into one file per command

* chore: set cli release working dir

* chore: add repo url to npmjs

* chore: bump node setup to v4

* chore: normalize the github url

* check e2e code in lint

* fix lint

* test key login flow

* feat: allow configurable config dir

* fix session service tests

* create missing dir

* cleanup

* bump cli version to 2.0.4

* remove form-data

* feat: allow single files as argument

* add version option

* bump dependencies

* fix lint

* wip use axios as upload

* version bump

* cApiTALiZaTiON

* don't touch package lock

* wip: don't use job queues

* don't use make for cli e2e

* fix server e2e

* chore: remove old gha step

* add npm ci to server

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-12-18 20:29:26 -06:00
martin
baed16dab6 fix(web): shared link background color on dark mode (#5846) 2023-12-18 20:26:55 -06:00
Jon Howell
a7b4727c20 feat(docs): Add a linear quick-start guide (#5812)
* feat(docs): Add a linear quick-start guide

* prettier

* fix: format

* removed unused text

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-12-18 20:45:49 +00:00
Alex
9834693fab fix(web): access /search throw error (#5834) 2023-12-18 14:42:25 -06:00
shenlong
085dc6cd93 fix(mobile): use safe area for gallery_viewer bottom sheet (#5831)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-12-18 11:22:06 -06:00
Mert
de1514a441 chore(server): startup check for pgvecto.rs (#5815)
* startup check for pgvecto.rs

* prefilter after assertion

* formatting

* add assert to migration

* more specific import

* use runner
2023-12-18 10:38:25 -06:00
Alex
fade8b627f chore(web): display places on a single row (#5825) 2023-12-18 10:34:25 -06:00
Jason Rasmussen
d3e1572229 fix(server): file sending and cache control (#5829)
* fix: file sending

* fix: tests
2023-12-18 10:33:46 -06:00
Alex
ffc31f034c chore(mobile): handle delete file error (#5827) 2023-12-18 09:54:42 -06:00
Alex
3beeffaaf0 fix(server): metadata search does not return all EXIF info (#5810)
* docs: update default config content

* fix(server): metadata search does not return all EXIF info

* remove console log

* generate sql

* Correct sql generation
2023-12-18 07:13:36 -06:00
Ferdinand Mütsch
b68800d45c chore(docs): add caddy reverse proxy config example (#5777) 2023-12-18 02:22:59 +00:00
Mert
b520955d0e fix(server): add more conditions to smart search (#5806)
* add more asset conditions

* udpate sql
2023-12-17 20:17:30 -06:00
Mert
6e7b3d6f24 fix(server): fix metadata search not working (#5800)
* don't require ml

* update e2e

* fixes

* fix e2e

* add additional conditions

* select all exif columns

* more fixes

* update sql
2023-12-17 20:16:08 -06:00
Alex
c45e8cc170 fix(web): cannot open map cluster (#5797) 2023-12-17 20:13:55 -06:00
Michael Manganiello
c6f56d9591 chore(server): Check activity permissions in bulk (#5775)
Modify Access repository, to evaluate `asset` permissions in bulk.
This is the last set of permission changes, to migrate all of them to
run in bulk!
Queries have been validated to match what they currently generate for single ids.

Queries:

* `activity` owner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "activity" "ActivityEntity"
  WHERE
    "ActivityEntity"."id" = $1
    AND "ActivityEntity"."userId" = $2
)
LIMIT 1

-- After
SELECT "ActivityEntity"."id" AS "ActivityEntity_id"
FROM "activity" "ActivityEntity"
WHERE
  "ActivityEntity"."id" IN ($1)
  AND "ActivityEntity"."userId" = $2
```

* `activity` album owner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "activity" "ActivityEntity"
    LEFT JOIN "albums" "ActivityEntity__ActivityEntity_album"
      ON "ActivityEntity__ActivityEntity_album"."id"="ActivityEntity"."albumId"
      AND "ActivityEntity__ActivityEntity_album"."deletedAt" IS NULL
  WHERE
    "ActivityEntity"."id" = $1
    AND "ActivityEntity__ActivityEntity_album"."ownerId" = $2
)
LIMIT 1

-- After
SELECT "ActivityEntity"."id" AS "ActivityEntity_id"
FROM "activity" "ActivityEntity"
  LEFT JOIN "albums" "ActivityEntity__ActivityEntity_album"
    ON "ActivityEntity__ActivityEntity_album"."id"="ActivityEntity"."albumId"
    AND "ActivityEntity__ActivityEntity_album"."deletedAt" IS NULL
WHERE
  "ActivityEntity"."id" IN ($1)
  AND "ActivityEntity__ActivityEntity_album"."ownerId" = $2
```

* `activity` create access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "albums" "AlbumEntity"
    LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"
      ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id"
    LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers"
      ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId"
      AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL
  WHERE
    (
      (
        "AlbumEntity"."id" = $1
        AND "AlbumEntity"."isActivityEnabled" = $2
        AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3
      )
      OR (
        "AlbumEntity"."id" = $4
        AND "AlbumEntity"."isActivityEnabled" = $5
        AND "AlbumEntity"."ownerId" = $6
      )
    )
    AND "AlbumEntity"."deletedAt" IS NULL
)
LIMIT 1

-- After
SELECT "AlbumEntity"."id" AS "AlbumEntity_id"
FROM "albums" "AlbumEntity"
  LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"
    ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id"
  LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers"
    ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId"
    AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL
WHERE
  (
    (
      "AlbumEntity"."id" IN ($1)
      AND "AlbumEntity"."isActivityEnabled" = $2
      AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3
    )
    OR (
      "AlbumEntity"."id" IN ($4)
      AND "AlbumEntity"."isActivityEnabled" = $5
      AND "AlbumEntity"."ownerId" = $6
    )
  )
  AND "AlbumEntity"."deletedAt" IS NULL
```
2023-12-17 12:10:21 -06:00
Alex
691e20521d docs: update default config content (#5798) 2023-12-17 12:07:53 -06:00
Quek
27f8dd6040 doc: documentation of the Immich Flutter Architectural Pattern (#5748)
* Added Documentation of the Immich Flutter Architectural Pattern

* Update README.md

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-12-17 17:51:03 +00:00
Mert
e3fa32ad23 fix(server): fix inconsistent explore queries (#5774)
* remove limits

* update sql
2023-12-17 11:04:35 -06:00
Alex The Bot
08f66c2ae5 Version v1.91.3 2023-12-17 16:57:16 +00:00
Mert
4f38a283b4 fix(server): stricter dim size check for pgvecto.rs migration (#5767)
* stricter dim size check

* remove unused import

* added null check
2023-12-17 10:55:35 -06:00
Jon Howell
00771899da fix(docs): remove inline dev inquiries (#5733)
* fix(docs): correct link; remove inline dev inquiries

* unfix relative path
2023-12-17 10:46:29 -05:00
Mert
09402eb6d0 fix(ml): disable core dumps (#5770)
* update dockerfile

* remove sysctl line
2023-12-16 20:30:29 -06:00
Jon Howell
d9b5adf0f7 fix(docs): remove spurious hyphen in docker compose cmd (#5771)
The command example is correct, but the text just before it still references the old docker-compose command.
2023-12-16 19:49:14 -05:00
Alex The Bot
a15c799ba3 Version v1.91.2 2023-12-16 23:19:58 +00:00
Daniel Dietzler
bda9fd9dfe fix(web): settings switch state when disabled, simplify classes (#5762) 2023-12-16 17:17:38 -06:00
Daniel Dietzler
19754d4b21 fix clip concurrency not being persisted after queue renaming (#5769) 2023-12-16 22:32:15 +00:00
Alex
62347edf43 chore(web): improve map pin (#5761)
* chore(web): improve map pin

* zoom level
2023-12-16 20:21:13 +00:00
Daniel Dietzler
67f020380f disable version check settings when config file is set (#5756) 2023-12-16 20:39:17 +01:00
123 changed files with 3162 additions and 512 deletions

View File

@@ -1,5 +1,5 @@
.vscode/
cli/
design/
docker/
docs/
@@ -18,3 +18,8 @@ web/node_modules/
web/coverage/
web/.svelte-kit
web/build/
cli/node_modules
cli/.reverse-geocoding-dump/
cli/upload/
cli/dist/

View File

@@ -21,7 +21,7 @@ jobs:
submodules: "recursive"
- name: Run e2e tests
run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
run: make test-server-e2e
doc-tests:
name: Docs
@@ -90,9 +90,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Run npm install
- name: Run npm install in cli
run: npm ci
- name: Run npm install in server
run: npm ci
working-directory: ./server
- name: Run linter
run: npm run lint
if: ${{ !cancelled() }}
@@ -109,6 +113,29 @@ jobs:
run: npm run test:cov
if: ${{ !cancelled() }}
cli-e2e-tests:
name: CLI (e2e)
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./cli
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Run npm install in cli
run: npm ci
- name: Run npm install in server
run: npm ci
working-directory: ./server
- name: Run e2e tests
run: npm run test:e2e
web-unit-tests:
name: Web
runs-on: ubuntu-latest

View File

@@ -16,8 +16,8 @@ stage:
pull-stage:
docker compose -f ./docker/docker-compose.staging.yml pull
test-e2e:
docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
test-server-e2e:
docker compose -f ./server/test/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans

4
cli/.gitignore vendored
View File

@@ -10,4 +10,6 @@ oclif.manifest.json
.vscode
.idea
/coverage/
/coverage/
.reverse-geocoding-dump/
upload/

View File

@@ -1,4 +1,6 @@
**/*.spec.js
test/**
upload/**
.editorconfig
.eslintignore
.eslintrc.js

19
cli/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM ghcr.io/immich-app/base-server-dev:20231109 as test
WORKDIR /usr/src/app/server
COPY server/package.json server/package-lock.json ./
RUN npm ci
COPY ./server/ .
WORKDIR /usr/src/app/cli
COPY cli/package.json cli/package-lock.json ./
RUN npm ci
COPY ./cli/ .
FROM ghcr.io/immich-app/base-server-prod:20231109
VOLUME /usr/src/app/upload
EXPOSE 3001
ENTRYPOINT ["tini", "--", "/bin/sh"]

1925
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.0.4",
"version": "2.0.5",
"description": "Command Line Interface (CLI) for Immich",
"main": "dist/index.js",
"bin": {
@@ -21,6 +21,7 @@
"yaml": "^2.3.1"
},
"devDependencies": {
"@testcontainers/postgresql": "^10.4.0",
"@types/byte-size": "^8.1.0",
"@types/chai": "^4.3.5",
"@types/cli-progress": "^3.11.0",
@@ -37,6 +38,7 @@
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-unicorn": "^49.0.0",
"immich": "file:../server",
"jest": "^29.5.0",
"jest-extended": "^4.0.0",
"jest-message-util": "^29.5.0",
@@ -50,13 +52,15 @@
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"prepack": "npm run build",
"test": "jest",
"test:cov": "jest --coverage",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config test/e2e/jest-e2e.json --runInBand"
},
"jest": {
"clearMocks": true,
@@ -71,10 +75,15 @@
"^.+\\.ts$": "ts-jest"
},
"collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s"
"<rootDir>/src/**/*.(t|j)s",
"!**/open-api/**"
],
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>/src/api/$1"
"^@api(|/.*)$": "<rootDir>/src/api/$1",
"^@test(|/.*)$": "<rootDir>../server/test/$1",
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
"^@app/domain(|/.*)$": "<rootDir>../server/src/domain/$1"
},
"coverageDirectory": "./coverage",
"testEnvironment": "node"

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.91.1
* The version of the OpenAPI document: 1.91.4
*
*
* 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.91.1
* The version of the OpenAPI document: 1.91.4
*
*
* 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.91.1
* The version of the OpenAPI document: 1.91.4
*
*
* 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.91.1
* The version of the OpenAPI document: 1.91.4
*
*
* 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.91.1
* The version of the OpenAPI document: 1.91.4
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -1,10 +1,9 @@
import { ImmichApi } from '../api/client';
import path from 'node:path';
import { SessionService } from '../services/session.service';
import { LoginError } from '../cores/errors/login-error';
import { exit } from 'node:process';
import os from 'os';
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
export abstract class BaseCommand {
protected sessionService!: SessionService;
@@ -12,14 +11,11 @@ export abstract class BaseCommand {
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto;
protected configDir;
protected authPath;
constructor() {
const userHomeDir = os.homedir();
this.configDir = path.join(userHomeDir, '.config/immich/');
this.sessionService = new SessionService(this.configDir);
this.authPath = path.join(this.configDir, 'auth.yml');
constructor(options: BaseOptionsDto) {
if (!options.config) {
throw new Error('Config directory is required');
}
this.sessionService = new SessionService(options.config);
}
public async connect(): Promise<void> {

View File

@@ -2,7 +2,7 @@ import { Asset } from '../cores/models/asset';
import { CrawlService } from '../services';
import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
import fs from 'node:fs';
import cliProgress from 'cli-progress';
import byteSize from 'byte-size';
import { BaseCommand } from '../cli/base-command';
@@ -15,8 +15,6 @@ export default class Upload extends BaseCommand {
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
await this.connect();
const deviceId = 'CLI';
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
@@ -25,14 +23,26 @@ export default class Upload extends BaseCommand {
crawlOptions.recursive = options.recursive;
crawlOptions.exclusionPatterns = options.exclusionPatterns;
const files: string[] = [];
for (const pathArgument of paths) {
const fileStat = await fs.promises.lstat(pathArgument);
if (fileStat.isFile()) {
files.push(pathArgument);
}
}
const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
crawledFiles.push(...files);
if (crawledFiles.length === 0) {
console.log('No assets found, exiting');
return;
}
const assetsToUpload = crawledFiles.map((path) => new Asset(path, deviceId));
const assetsToUpload = crawledFiles.map((path) => new Asset(path));
const uploadProgress = new cliProgress.SingleBar(
{

37
cli/src/constants.ts Normal file
View File

@@ -0,0 +1,37 @@
import pkg from '../package.json';
export interface ICLIVersion {
major: number;
minor: number;
patch: number;
}
export class CLIVersion implements ICLIVersion {
constructor(
public readonly major: number,
public readonly minor: number,
public readonly patch: number,
) {}
toString() {
return `${this.major}.${this.minor}.${this.patch}`;
}
toJSON() {
const { major, minor, patch } = this;
return { major, minor, patch };
}
static fromString(version: string): CLIVersion {
const regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
const matchResult = version.match(regex);
if (matchResult) {
const [, major, minor, patch] = matchResult.map(Number);
return new CLIVersion(major, minor, patch);
} else {
throw new Error(`Invalid version format: ${version}`);
}
}
}
export const cliVersion = CLIVersion.fromString(pkg.version);

View File

@@ -0,0 +1,3 @@
export class BaseOptionsDto {
config?: string;
}

View File

@@ -1,9 +1,8 @@
export class UploadOptionsDto {
recursive = false;
exclusionPatterns!: string[];
dryRun = false;
skipHash = false;
delete = false;
readOnly = true;
album = false;
recursive? = false;
exclusionPatterns?: string[] = [];
dryRun? = false;
skipHash? = false;
delete? = false;
album? = false;
}

View File

@@ -2,10 +2,8 @@ export class LoginError extends Error {
constructor(message: string) {
super(message);
// assign the error class name in your custom error (as a shortcut)
this.name = this.constructor.name;
// capturing the stack trace keeps the reference to your error class
Error.captureStackTrace(this, this.constructor);
}
}

View File

@@ -17,9 +17,8 @@ export class Asset {
fileSize!: number;
albumName?: string;
constructor(path: string, deviceId: string) {
constructor(path: string) {
this.path = path;
this.deviceId = deviceId;
}
async process() {
@@ -45,12 +44,11 @@ export class Asset {
if (!this.deviceAssetId) throw new Error('Device asset id not set');
if (!this.fileCreatedAt) throw new Error('File created at not set');
if (!this.fileModifiedAt) throw new Error('File modified at not set');
if (!this.deviceId) throw new Error('Device id not set');
const data: any = {
assetData: this.assetData as any,
deviceAssetId: this.deviceAssetId,
deviceId: this.deviceId,
deviceId: 'CLI',
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: String(false),

View File

@@ -1,13 +1,23 @@
#! /usr/bin/env node
import { program, Option } from 'commander';
import { Option, Command } from 'commander';
import Upload from './commands/upload';
import ServerInfo from './commands/server-info';
import LoginKey from './commands/login/key';
import Logout from './commands/logout';
import { version } from '../package.json';
program.name('immich').description('Immich command line interface').version(version);
import path from 'node:path';
import os from 'os';
const userHomeDir = os.homedir();
const configDir = path.join(userHomeDir, '.config/immich/');
const program = new Command()
.name('immich')
.version(version)
.description('Command line interface for Immich')
.addOption(new Option('-d, --config', 'Configuration directory').env('IMMICH_CONFIG_DIR').default(configDir));
program
.command('upload')
@@ -30,14 +40,14 @@ program
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => {
options.exclusionPatterns = options.ignore;
await new Upload().run(paths, options);
await new Upload(program.opts()).run(paths, options);
});
program
.command('server-info')
.description('Display server information')
.action(async () => {
await new ServerInfo().run();
await new ServerInfo(program.opts()).run();
});
program
@@ -46,14 +56,14 @@ program
.argument('[instanceUrl]')
.argument('[apiKey]')
.action(async (paths, options) => {
await new LoginKey().run(paths, options);
await new LoginKey(program.opts()).run(paths, options);
});
program
.command('logout')
.description('Remove stored credentials')
.action(async () => {
await new Logout().run();
await new Logout(program.opts()).run();
});
program.parse(process.argv);

View File

@@ -1,8 +1,17 @@
import { SessionService } from './session.service';
import mockfs from 'mock-fs';
import fs from 'node:fs';
import yaml from 'yaml';
import { LoginError } from '../cores/errors/login-error';
import {
TEST_AUTH_FILE,
TEST_CONFIG_DIR,
TEST_IMMICH_API_KEY,
TEST_IMMICH_INSTANCE_URL,
createTestAuthFile,
deleteAuthFile,
readTestAuthFile,
spyOnConsole,
} from '../../test/cli-test-utils';
const mockPingServer = jest.fn(() => Promise.resolve({ data: { res: 'pong' } }));
const mockUserInfo = jest.fn(() => Promise.resolve({ data: { email: 'admin@example.com' } }));
@@ -22,74 +31,85 @@ jest.mock('../api/open-api', () => {
describe('SessionService', () => {
let sessionService: SessionService;
let consoleSpy: jest.SpyInstance;
beforeAll(() => {
// Write a dummy output before mock-fs to prevent some annoying errors
console.log();
consoleSpy = spyOnConsole();
});
beforeEach(() => {
const configDir = '/config';
sessionService = new SessionService(configDir);
deleteAuthFile();
sessionService = new SessionService(TEST_CONFIG_DIR);
});
afterEach(() => {
deleteAuthFile();
});
it('should connect to immich', async () => {
mockfs({
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
});
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await sessionService.connect();
expect(mockPingServer).toHaveBeenCalledTimes(1);
});
it('should error if no auth file exists', async () => {
mockfs();
await sessionService.connect().catch((error) => {
expect(error.message).toEqual('No auth file exist. Please login first');
});
});
it('should error if auth file is missing instance URl', async () => {
mockfs({
'/config/auth.yml': 'foo: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\napiKey: https://test/api',
});
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
}),
);
await sessionService.connect().catch((error) => {
expect(error).toBeInstanceOf(LoginError);
expect(error.message).toEqual('Instance URL missing in auth config file /config/auth.yml');
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
});
});
it('should error if auth file is missing api key', async () => {
mockfs({
'/config/auth.yml': 'instanceUrl: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\nbar: https://test/api',
});
await sessionService.connect().catch((error) => {
expect(error).toBeInstanceOf(LoginError);
expect(error.message).toEqual('API key missing in auth config file /config/auth.yml');
});
await createTestAuthFile(
JSON.stringify({
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await expect(sessionService.connect()).rejects.toThrow(
new LoginError(`API key missing in auth config file ${TEST_AUTH_FILE}`),
);
});
it.skip('should create auth file when logged in', async () => {
mockfs();
it('should create auth file when logged in', async () => {
await sessionService.keyLogin(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
const data: string = await fs.promises.readFile('/config/auth.yml', 'utf8');
const data: string = await readTestAuthFile();
const authConfig = yaml.parse(data);
expect(authConfig.instanceUrl).toBe('https://test/api');
expect(authConfig.apiKey).toBe('pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
});
it('should delete auth file when logging out', async () => {
mockfs({
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
});
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await sessionService.logout();
await fs.promises.access('/auth.yml', fs.constants.F_OK).catch((error) => {
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
expect(error.message).toContain('ENOENT');
});
});
afterEach(() => {
mockfs.restore();
expect(consoleSpy.mock.calls).toEqual([[`Removed auth file ${TEST_AUTH_FILE}`]]);
});
});

View File

@@ -5,33 +5,39 @@ import { ImmichApi } from '../api/client';
import { LoginError } from '../cores/errors/login-error';
export class SessionService {
readonly configDir: string;
readonly configDir!: string;
readonly authPath!: string;
private api!: ImmichApi;
constructor(configDir: string) {
this.configDir = configDir;
this.authPath = path.join(this.configDir, 'auth.yml');
this.authPath = path.join(configDir, '/auth.yml');
}
public async connect(): Promise<ImmichApi> {
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
if (error.code === 'ENOENT') {
throw new LoginError('No auth file exist. Please login first');
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
let apiKey = process.env.IMMICH_API_KEY;
if (!instanceUrl || !apiKey) {
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
if (error.code === 'ENOENT') {
throw new LoginError('No auth file exist. Please login first');
}
});
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
const parsedConfig = yaml.parse(data);
instanceUrl = parsedConfig.instanceUrl;
apiKey = parsedConfig.apiKey;
if (!instanceUrl) {
throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`);
}
});
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
const parsedConfig = yaml.parse(data);
const instanceUrl: string = parsedConfig.instanceUrl;
const apiKey: string = parsedConfig.apiKey;
if (!instanceUrl) {
throw new LoginError('Instance URL missing in auth config file ' + this.authPath);
}
if (!apiKey) {
throw new LoginError('API key missing in auth config file ' + this.authPath);
if (!apiKey) {
throw new LoginError(`API key missing in auth config file ${this.authPath}`);
}
}
this.api = new ImmichApi(instanceUrl, apiKey);
@@ -59,10 +65,6 @@ export class SessionService {
}
}
if (!fs.existsSync(this.configDir)) {
console.error('waah');
}
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
console.log('Wrote auth info to ' + this.authPath);
@@ -82,7 +84,7 @@ export class SessionService {
});
if (pingResponse.res !== 'pong') {
throw new Error('Unexpected ping reply');
throw new Error(`Could not parse response. Is Immich listening on ${this.api.apiConfiguration.instanceUrl}?`);
}
}
}

View File

@@ -0,0 +1,38 @@
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
import fs from 'node:fs';
import path from 'node:path';
export const TEST_CONFIG_DIR = '/tmp/immich/';
export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
export const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
export const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
export const CLI_BASE_OPTIONS: BaseOptionsDto = { config: TEST_CONFIG_DIR };
export const spyOnConsole = () => jest.spyOn(console, 'log').mockImplementation();
export const createTestAuthFile = async (contents: string) => {
if (!fs.existsSync(TEST_CONFIG_DIR)) {
// Create config folder if it doesn't exist
const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
if (!created) {
throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
}
}
fs.writeFileSync(TEST_AUTH_FILE, contents);
};
export const readTestAuthFile = async (): Promise<string> => {
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
};
export const deleteAuthFile = () => {
try {
fs.unlinkSync(TEST_AUTH_FILE);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
}
};

View File

@@ -0,0 +1,24 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"modulePaths": ["<rootDir>"],
"rootDir": "../..",
"globalSetup": "<rootDir>/test/e2e/setup.ts",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"testTimeout": 6000000,
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s",
"!<rootDir>/src/**/*.spec.(t|s)s",
"!<rootDir>/src/infra/migrations/**"
],
"coverageDirectory": "./coverage",
"moduleNameMapper": {
"^@test(|/.*)$": "<rootDir>../server/test/$1",
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
"^@app/domain(|/.*)$": "<rootDir>/../server/src/domain/$1"
}
}

View File

@@ -0,0 +1,48 @@
import { api } from '@test/api';
import { restoreTempFolder, testApp } from 'immich/test/test-utils';
import { LoginResponseDto } from 'src/api/open-api';
import { APIKeyCreateResponseDto } from '@app/domain';
import LoginKey from 'src/commands/login/key';
import { LoginError } from 'src/cores/errors/login-error';
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
describe(`login-key (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
let instanceUrl: string;
spyOnConsole();
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
if (!process.env.IMMICH_INSTANCE_URL) {
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
} else {
instanceUrl = process.env.IMMICH_INSTANCE_URL;
}
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
process.env.IMMICH_API_KEY = apiKey.secret;
});
it('should error when providing an invalid API key', async () => {
await expect(async () => await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
new LoginError(`Failed to connect to server ${instanceUrl}: Request failed with status code 401`),
);
});
it('should log in when providing the correct API key', async () => {
await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, apiKey.secret);
});
});

View File

@@ -0,0 +1,42 @@
import { api } from '@test/api';
import { restoreTempFolder, testApp } from 'immich/test/test-utils';
import { LoginResponseDto } from 'src/api/open-api';
import ServerInfo from 'src/commands/server-info';
import { APIKeyCreateResponseDto } from '@app/domain';
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
describe(`server-info (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
const consoleSpy = spyOnConsole();
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
process.env.IMMICH_API_KEY = apiKey.secret;
});
it('should show server version', async () => {
await new ServerInfo(CLI_BASE_OPTIONS).run();
expect(consoleSpy.mock.calls).toEqual([
[expect.stringMatching(new RegExp('Server is running version \\d+.\\d+.\\d+'))],
[expect.stringMatching('Supported image types: .*')],
[expect.stringMatching('Supported video types: .*')],
['Images: 0, Videos: 0, Total: 0'],
]);
});
});

43
cli/test/e2e/setup.ts Normal file
View File

@@ -0,0 +1,43 @@
import path from 'path';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { access } from 'fs/promises';
export default async () => {
let IMMICH_TEST_ASSET_PATH: string = '';
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../../server/test/assets/`);
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
} else {
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
}
const directoryExists = async (dirPath: string) =>
await access(dirPath)
.then(() => true)
.catch(() => false);
if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) {
throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`,
);
}
if (process.env.DB_HOSTNAME === undefined) {
// DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container.
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.1.11')
.withExposedPorts(5432)
.withDatabase('immich')
.withUsername('postgres')
.withPassword('postgres')
.withReuse()
.start();
process.env.DB_URL = pg.getConnectionUri();
}
process.env.NODE_ENV = 'development';
process.env.IMMICH_TEST_ENV = 'true';
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/test/e2e/immich-e2e-config.json`);
process.env.TZ = 'Z';
};

View File

@@ -0,0 +1,49 @@
import { api } from '@test/api';
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from 'immich/test/test-utils';
import { LoginResponseDto } from 'src/api/open-api';
import Upload from 'src/commands/upload';
import { APIKeyCreateResponseDto } from '@app/domain';
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
describe(`upload (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
spyOnConsole();
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
process.env.IMMICH_API_KEY = apiKey.secret;
});
it('should upload a folder recursively', async () => {
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets.length).toBeGreaterThan(4);
});
it('should create album from folder name', async () => {
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
album: true,
});
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
expect(albums.length).toEqual(1);
const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature');
});
});

3
cli/test/global-setup.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = async () => {
process.env.TZ = 'UTC';
};

View File

@@ -8,17 +8,24 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2021",
"moduleResolution": "node16",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"rootDirs": ["src", "../server/src"],
"baseUrl": "./",
"paths": {
"@test": ["test"],
"@test/*": ["test/*"]
"@test": ["../server/test"],
"@test/*": ["../server/test/*"],
"@app/immich": ["../server/src/immich"],
"@app/immich/*": ["../server/src/immich/*"],
"@app/infra": ["../server/src/infra"],
"@app/infra/*": ["../server/src/infra/*"],
"@app/domain": ["../server/src/domain"],
"@app/domain/*": ["../server/src/domain/*"]
}
},
"exclude": ["dist", "node_modules", "upload"]

View File

@@ -28,3 +28,13 @@ server {
}
}
```
### Caddy example config
As an alternative to nginx, you can also use [Caddy](https://caddyserver.com/) as a reverse proxy (with automatic HTTPS configuration). Below is an example config.
```
immich.example.org {
reverse_proxy http://<snip>:2283
}
```

View File

@@ -16,9 +16,6 @@ Edit `docker-compose.yml` to add two new mount points under `volumes:`
Be sure to add exactly the same line to both `immich-server:` and `immich-microservices:`.
[Question for the devs: Is editing docker-compose.yml really the desirable way to solve this problem?
I assumed user changes were supposed to be kept to .env?]
Edit `.env` to define `EXTERNAL_PATH`, substituting in the correct path for your computer:
```
@@ -87,7 +84,7 @@ In the Immich web UI:
- Save the new path
<img src={require('./img/path-save.png').default} width="50%" title="Path Save button" />
- Click the three-dots menu and select **Scan New Library Files** [I'm not sure whether this is necessary]
- Click the three-dots menu and select **Scan New Library Files**
<img src={require('./img/scan-new-library-files.png').default} width="50%" title="Scan New Library Files menu option" />
# Confirm stuff is happening

View File

@@ -32,7 +32,7 @@ The default configuration looks like this:
"backgroundTask": {
"concurrency": 5
},
"clipEncoding": {
"smartSearch": {
"concurrency": 2
},
"metadataExtraction": {
@@ -66,11 +66,15 @@ The default configuration looks like this:
"concurrency": 1
}
},
"logging": {
"enabled": true,
"level": "log"
},
"machineLearning": {
"enabled": true,
"url": "http://immich-machine-learning:3003",
"classification": {
"enabled": true,
"enabled": false,
"modelName": "microsoft/resnet-50",
"minScore": 0.9
},
@@ -88,7 +92,8 @@ The default configuration looks like this:
},
"map": {
"enabled": true,
"tileUrl": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
"lightStyle": "",
"darkStyle": ""
},
"reverseGeocoding": {
"enabled": true
@@ -133,9 +138,6 @@ The default configuration looks like this:
"enabled": true,
"cronExpression": "0 0 * * *"
}
},
"stylesheets": {
"css": ""
}
}
```

View File

@@ -55,7 +55,7 @@ Optionally, you can use the [`hwaccel.yml`][hw-file] file to enable hardware acc
### Step 3 - Start the containers
From the directory you created in Step 1, (which should now contain your customized `docker-compose.yml` and `.env` files) run `docker-compose up -d`.
From the directory you created in Step 1, (which should now contain your customized `docker-compose.yml` and `.env` files) run `docker compose up -d`.
```bash title="Start the containers using docker compose command"
docker compose up -d

View File

@@ -1,5 +1,5 @@
---
sidebar_position: 4
sidebar_position: 5
---
# Help Me!

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,5 +1,5 @@
---
sidebar_position: 2
sidebar_position: 3
---
# Logo

View File

@@ -0,0 +1,85 @@
---
sidebar_position: 2
---
# Quick Start
Here is a quick, no-choices path to install Immich and take it for a test drive.
Once you've tried it, perhaps you'll use one of the many other ways
to install and use it.
## Requirements
Check the [requirements page](../install/requirements) to get started.
## Install and launch via Docker Compose
Follow the [Docker Compose (Recommended)](../install/docker-compose) instructions
to install the server.
- Where random passwords are required, `pwgen` is a handy utility.
- `UPLOAD_LOCATION` should be set to some new directory on the server
with free space.
- You may ignore "Step 4 - Upgrading".
## Try the Web UI
import RegisterAdminUser from '../partials/_register-admin.md';
<RegisterAdminUser />
Try uploading a picture from your browser.
<img src={require('./img/upload-button.png').default} title="Upload button" />
## Try the Mobile UI
### Download the Mobile App
import MobileAppDownload from '../partials/_mobile-app-download.md';
<MobileAppDownload />
### Login to the Mobile App
import MobileAppLogin from '../partials/_mobile-app-login.md';
<MobileAppLogin />
In the mobile app, you should see the photo you uploaded from the web UI.
### Transfer Photos from your Mobile Device
import MobileAppBackup from '../partials/_mobile-app-backup.md';
<MobileAppBackup />
Depending on how many photos are on your mobile device, this backup may
take quite a while.
You can select the Jobs tab to see Immich processing your photos.
<img src={require('../guides/img/jobs-tab.png').default} title="Jobs tab" />
## Where to go from here?
You may decide you'd like to install the server a different way;
the Install category on the left menu provides many options.
You may decide you'd like to add the _rest_ of your photos from Google Photos,
even those not on your mobile device, via Google Takeout.
You can use [immich-go](https://github.com/simulot/immich-go) for this.
You may want to
[upload photos from your own archive](../features/command-line-interface).
You may want to incorporate an immutable archive of photos from an
[External Library](../features/libraries#external-libraries);
there's a [Guide](../guides/external-library) for that.
You may want your mobile device to
[back photos up to your server automatically](../features/automatic-backup).
You may want to back up the content of your Immich instance
along with other parts of your server; be sure to read about
[database backup](../administration/backup-and-restore).

View File

@@ -1,5 +1,5 @@
---
sidebar_position: 3
sidebar_position: 4
---
# Support The Project

View File

@@ -25,6 +25,11 @@ ENV NODE_ENV=production \
PATH="/opt/venv/bin:$PATH" \
PYTHONPATH=/usr/src
# prevent core dumps
RUN echo "hard core 0" >> /etc/security/limits.conf && \
echo "fs.suid_dumpable 0" >> /etc/sysctl.conf && \
echo 'ulimit -S -c 0 > /dev/null 2>&1' >> /etc/profile
COPY --from=builder /opt/venv /opt/venv
COPY start.sh log_conf.json ./
COPY app .

View File

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

View File

@@ -1 +1,54 @@
# Immich Mobile Application - Flutter
The Immich mobile app is a Flutter-based solution leveraging the Isar Database for local storage and Riverpod for state management. This structure optimizes functionality and maintainability, allowing for efficient development and robust performance.
## Setup
You must set up Flutter toolchain in your machine before you can perform any of the development.
## Immich-Flutter Directory Structure
Below are the directory inside the `lib` directory:
- `constants`: Store essential constants utilized across the application, like colors and locale.
- `extensions`: Extensions enhancing various existing functionalities within the app, such as asset_extensions.dart, string_extensions.dart, and more.
- `module_template`: Provides a template structure for different modules within the app, including subdivisions like models, providers, services, UI, and views.
- `models`: Placeholder for storing module-specific models.
- `providers`: Section to define module-specific Riverpod providers.
- `services`: Houses services tailored to the module's functionality.
- `ui`: Contains UI components and widgets for the module.
- `views`: Placeholder for module-specific views.
- `modules`: Organizes different functional modules of the app, each containing subdivisions for models, providers, services, UI, and views. This structure promotes modular development and scalability.
- `routing`: Includes guards like auth_guard.dart, backup_permission_guard.dart, and routers like router.dart and router.gr.dart for streamlined navigation and permission management.
- `shared`: cache, models, providers, services, ui, views: Encapsulates shared functionalities, such as caching mechanisms, common models, providers, services, UI components, and views accessible across the application.
- `utils`: A collection of utility classes and functions catering to different app functionalities, including async_mutex.dart, bytes_units.dart, debounce.dart, migration.dart, and more.
## Immich Architectural Pattern
The Immich Flutter app embraces a well-defined architectural pattern inspired by the Model-View-ViewModel (MVVM) approach. This layout organizes modules for models, providers, services, UI, and views, creating a modular development approach that strongly emphasizes a clean separation of concerns.
Please use the `module_template` provided to create a new module.
### Architecture Breakdown
Below is how your code needs to be structured:
- Models: In Immich, Models are like the app's blueprint—they're essential for organizing and using information. Imagine them as containers that hold data the app needs to function. They also handle basic rules and logic for managing and interacting with this data across the app.
- Providers (Riverpod): Providers in Immich are a bit like traffic managers. They help different parts of the app communicate and share information effectively. They ensure that the right data gets to the right places at the right time. These providers use Riverpod, a tool that helps with managing and organizing how the app's information flows. Everything related to the state goes here.
- Services: Services are the helpful behind-the-scenes workers in Immich. They handle important tasks like handling network requests or managing other essential functions. These services work independently and focus on supporting the app's main functionalities.
- UI: In Immich, the UI focuses solely on how things appear and feel without worrying about the app's complex inner workings. You can slot in your reusable widget here.
- Views: Views use Providers to get the needed information and handle actions without dealing with the technical complexities behind the scenes. Normally Flutter's screen & pages goes here.
## Contributing
Please refer to the [architecture](https://immich.app/docs/developer/architecture/) for contributing to the mobile app!

View File

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

View File

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

View File

@@ -179,18 +179,18 @@ class GalleryViewerPage extends HookConsumerWidget {
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
useSafeArea: true,
context: context,
builder: (context) {
if (ref
.watch(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)) {
return AdvancedBottomSheet(assetDetail: asset());
}
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: ExifBottomSheet(asset: asset()),
child: ref
.watch(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
? AdvancedBottomSheet(assetDetail: asset())
: ExifBottomSheet(asset: asset()),
);
},
);

View File

@@ -394,8 +394,12 @@ class BackupService {
continue;
} finally {
if (Platform.isIOS) {
file?.deleteSync();
livePhotoFile?.deleteSync();
try {
await file?.delete();
await livePhotoFile?.delete();
} catch (e) {
debugPrint("ERROR deleting file: ${e.toString()}");
}
}
}
}

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.91.1
- API version: 1.91.4
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.91.1+115
version: 1.91.4+116
isar_version: &isar_version 3.1.0+1
environment:

View File

@@ -6188,7 +6188,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.91.1",
"version": "1.91.4",
"contact": {}
},
"tags": [],

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.91.1",
"version": "1.91.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.91.1",
"version": "1.91.4",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.22.11",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.91.1",
"version": "1.91.4",
"description": "",
"author": "",
"private": true,

View File

@@ -140,6 +140,20 @@ export class AccessCore {
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>) {
switch (permission) {
// uses album id
case Permission.ACTIVITY_CREATE:
return await this.repository.activity.checkCreateAccess(auth.user.id, ids);
// uses activity id
case Permission.ACTIVITY_DELETE: {
const isOwner = await this.repository.activity.checkOwnerAccess(auth.user.id, ids);
const isAlbumOwner = await this.repository.activity.checkAlbumOwnerAccess(
auth.user.id,
setDifference(ids, isOwner),
);
return setUnion(isOwner, isAlbumOwner);
}
case Permission.ASSET_READ: {
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
@@ -249,41 +263,16 @@ export class AccessCore {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
case Permission.PERSON_CREATE:
return this.repository.person.hasFaceOwnerAccess(auth.user.id, ids);
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
case Permission.PERSON_REASSIGN:
return this.repository.person.hasFaceOwnerAccess(auth.user.id, ids);
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
case Permission.PARTNER_UPDATE:
return await this.repository.partner.checkUpdateAccess(auth.user.id, ids);
}
const allowedIds = new Set();
for (const id of ids) {
const hasAccess = await this.hasOtherAccess(auth, permission, id);
if (hasAccess) {
allowedIds.add(id);
}
}
return allowedIds;
}
// TODO: Migrate logic to checkAccessOther to evaluate permissions in bulk.
private async hasOtherAccess(auth: AuthDto, permission: Permission, id: string) {
switch (permission) {
// uses album id
case Permission.ACTIVITY_CREATE:
return await this.repository.activity.hasCreateAccess(auth.user.id, id);
// uses activity id
case Permission.ACTIVITY_DELETE:
return (
(await this.repository.activity.hasOwnerAccess(auth.user.id, id)) ||
(await this.repository.activity.hasAlbumOwnerAccess(auth.user.id, id))
);
default:
return false;
return new Set();
}
}
}

View File

@@ -93,7 +93,7 @@ describe(ActivityService.name, () => {
});
it('should create a comment', async () => {
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
activityMock.create.mockResolvedValue(activityStub.oneComment);
await sut.create(authStub.admin, {
@@ -114,7 +114,6 @@ describe(ActivityService.name, () => {
it('should fail because activity is disabled for the album', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
accessMock.activity.hasCreateAccess.mockResolvedValue(false);
activityMock.create.mockResolvedValue(activityStub.oneComment);
await expect(
@@ -128,7 +127,7 @@ describe(ActivityService.name, () => {
});
it('should create a like', async () => {
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
activityMock.create.mockResolvedValue(activityStub.liked);
activityMock.search.mockResolvedValue([]);
@@ -148,7 +147,7 @@ describe(ActivityService.name, () => {
it('should skip if like exists', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
activityMock.search.mockResolvedValue([activityStub.liked]);
await sut.create(authStub.admin, {
@@ -163,19 +162,18 @@ describe(ActivityService.name, () => {
describe('delete', () => {
it('should require access', async () => {
accessMock.activity.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException);
expect(activityMock.delete).not.toHaveBeenCalled();
});
it('should let the activity owner delete a comment', async () => {
accessMock.activity.hasOwnerAccess.mockResolvedValue(true);
accessMock.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id']));
await sut.delete(authStub.admin, 'activity-id');
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
});
it('should let the album owner delete a comment', async () => {
accessMock.activity.hasAlbumOwnerAccess.mockResolvedValue(true);
accessMock.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id']));
await sut.delete(authStub.admin, 'activity-id');
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
});

View File

@@ -16,7 +16,7 @@ import {
} from '@test';
import { when } from 'jest-when';
import { Readable } from 'stream';
import { ImmichFileResponse } from '../domain.util';
import { CacheControl, ImmichFileResponse } from '../domain.util';
import { JobName } from '../job';
import {
AssetStats,
@@ -482,7 +482,7 @@ describe(AssetService.name, () => {
new ImmichFileResponse({
path: '/original/path.jpg',
contentType: 'image/jpeg',
cacheControl: false,
cacheControl: CacheControl.NONE,
}),
);
});

View File

@@ -8,7 +8,7 @@ import sanitize from 'sanitize-filename';
import { AccessCore, Permission } from '../access';
import { AuthDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, ImmichFileResponse, usePagination } from '../domain.util';
import { CacheControl, HumanReadableSize, ImmichFileResponse, usePagination } from '../domain.util';
import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import {
ClientEvent,
@@ -290,7 +290,7 @@ export class AssetService {
return new ImmichFileResponse({
path: asset.originalPath,
contentType: mimeTypes.lookup(asset.originalPath),
cacheControl: false,
cacheControl: CacheControl.NONE,
});
}

View File

@@ -16,10 +16,16 @@ import { CronJob } from 'cron';
import { basename, extname } from 'node:path';
import sanitize from 'sanitize-filename';
export enum CacheControl {
PRIVATE_WITH_CACHE = 'private_with_cache',
PRIVATE_WITHOUT_CACHE = 'private_without_cache',
NONE = 'none',
}
export class ImmichFileResponse {
public readonly path!: string;
public readonly contentType!: string;
public readonly cacheControl!: boolean;
public readonly cacheControl!: CacheControl;
constructor(response: ImmichFileResponse) {
Object.assign(this, response);

View File

@@ -18,7 +18,7 @@ import {
personStub,
} from '@test';
import { BulkIdErrorReason } from '../asset';
import { ImmichFileResponse } from '../domain.util';
import { CacheControl, ImmichFileResponse } from '../domain.util';
import { JobName } from '../job';
import {
IAssetRepository,
@@ -208,7 +208,7 @@ describe(PersonService.name, () => {
new ImmichFileResponse({
path: '/path/to/thumbnail.jpg',
contentType: 'image/jpeg',
cacheControl: true,
cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE,
}),
);
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
@@ -360,7 +360,7 @@ describe(PersonService.name, () => {
it('should reassign a face', async () => {
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
personMock.getById.mockResolvedValue(personStub.noName);
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
personMock.reassignFace.mockResolvedValue(1);
personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
@@ -415,7 +415,7 @@ describe(PersonService.name, () => {
describe('reassignFacesById', () => {
it('should create a new person', async () => {
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
personMock.getFaceById.mockResolvedValue(faceStub.face1);
personMock.reassignFace.mockResolvedValue(1);
personMock.getById.mockResolvedValue(personStub.noName);
@@ -437,7 +437,6 @@ describe(PersonService.name, () => {
it('should fail if user has not the correct permissions on the asset', async () => {
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set());
personMock.getFaceById.mockResolvedValue(faceStub.face1);
personMock.reassignFace.mockResolvedValue(1);
personMock.getById.mockResolvedValue(personStub.noName);
@@ -456,7 +455,7 @@ describe(PersonService.name, () => {
it('should create a new person', async () => {
personMock.create.mockResolvedValue(personStub.primaryPerson);
personMock.getFaceById.mockResolvedValue(faceStub.face1);
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson);
});

View File

@@ -6,7 +6,7 @@ import { AccessCore, Permission } from '../access';
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { ImmichFileResponse, usePagination } from '../domain.util';
import { CacheControl, ImmichFileResponse, usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { FACE_THUMBNAIL_SIZE } from '../media';
import {
@@ -183,7 +183,7 @@ export class PersonService {
return new ImmichFileResponse({
path: person.thumbnailPath,
contentType: mimeTypes.lookup(person.thumbnailPath),
cacheControl: true,
cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE,
});
}

View File

@@ -2,9 +2,9 @@ export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository {
activity: {
hasOwnerAccess(userId: string, activityId: string): Promise<boolean>;
hasAlbumOwnerAccess(userId: string, activityId: string): Promise<boolean>;
hasCreateAccess(userId: string, albumId: string): Promise<boolean>;
checkOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>>;
checkAlbumOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>>;
checkCreateAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>;
};
asset: {
@@ -34,7 +34,7 @@ export interface IAccessRepository {
};
person: {
hasFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
checkFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
};

View File

@@ -17,7 +17,7 @@ import {
userStub,
} from '@test';
import { when } from 'jest-when';
import { ImmichFileResponse } from '../domain.util';
import { CacheControl, ImmichFileResponse } from '../domain.util';
import { JobName } from '../job';
import {
IAlbumRepository,
@@ -396,7 +396,7 @@ describe(UserService.name, () => {
new ImmichFileResponse({
path: '/path/to/profile.jpg',
contentType: 'image/jpeg',
cacheControl: false,
cacheControl: CacheControl.NONE,
}),
);

View File

@@ -3,7 +3,7 @@ import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { AuthDto } from '../auth';
import { ImmichFileResponse } from '../domain.util';
import { CacheControl, ImmichFileResponse } from '../domain.util';
import { IEntityJob, JobName } from '../job';
import {
IAlbumRepository,
@@ -109,7 +109,7 @@ export class UserService {
return new ImmichFileResponse({
path: user.profileImagePath,
contentType: 'image/jpeg',
cacheControl: false,
cacheControl: CacheControl.NONE,
});
}

View File

@@ -5,18 +5,20 @@ import {
Get,
HttpCode,
HttpStatus,
Next,
Param,
ParseFilePipe,
Post,
Query,
Response,
Res,
UploadedFiles,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { NextFunction, Response } from 'express';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../../app.guard';
import { sendFile } from '../../app.utils';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from '../../interceptors';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
@@ -58,7 +60,7 @@ export class AssetController {
@Auth() auth: AuthDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@Body(new ValidationPipe({ transform: true })) dto: CreateAssetDto,
@Response({ passthrough: true }) res: Res,
@Res({ passthrough: true }) res: Response,
): Promise<AssetFileUploadResponseDto> {
const file = mapToUploadFile(files.assetData[0]);
const _livePhotoFile = files.livePhotoData?.[0];
@@ -84,23 +86,27 @@ export class AssetController {
@SharedLinkRoute()
@Get('/file/:id')
@FileResponse()
serveFile(
async serveFile(
@Res() res: Response,
@Next() next: NextFunction,
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: ServeFileDto,
) {
return this.assetService.serveFile(auth, id, dto);
await sendFile(res, next, () => this.assetService.serveFile(auth, id, dto));
}
@SharedLinkRoute()
@Get('/thumbnail/:id')
@FileResponse()
getAssetThumbnail(
async getAssetThumbnail(
@Res() res: Response,
@Next() next: NextFunction,
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: GetAssetThumbnailDto,
) {
return this.assetService.serveThumbnail(auth, id, dto);
await sendFile(res, next, () => this.assetService.serveThumbnail(auth, id, dto));
}
@Get('/curated-objects')

View File

@@ -2,6 +2,7 @@ import {
AccessCore,
AssetResponseDto,
AuthDto,
CacheControl,
getLivePhotoMotionFilename,
IAccessRepository,
IJobRepository,
@@ -147,7 +148,11 @@ export class AssetService {
const filepath = this.getThumbnailPath(asset, dto.format);
return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true });
return new ImmichFileResponse({
path: filepath,
contentType: mimeTypes.lookup(filepath),
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
});
}
public async serveFile(auth: AuthDto, assetId: string, dto: ServeFileDto): Promise<ImmichFileResponse> {
@@ -166,7 +171,11 @@ export class AssetService {
? this.getServePath(asset, dto, allowOriginalFile)
: asset.encodedVideoPath || asset.originalPath;
return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true });
return new ImmichFileResponse({
path: filepath,
contentType: mimeTypes.lookup(filepath),
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
});
}
async getAssetSearchTerm(auth: AuthDto): Promise<string[]> {

View File

@@ -32,7 +32,7 @@ import {
TagController,
UserController,
} from './controllers';
import { ErrorInterceptor, FileServeInterceptor, FileUploadInterceptor } from './interceptors';
import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
@Module({
imports: [
@@ -66,7 +66,6 @@ import { ErrorInterceptor, FileServeInterceptor, FileUploadInterceptor } from '.
],
providers: [
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
{ provide: APP_INTERCEPTOR, useClass: FileServeInterceptor },
{ provide: APP_GUARD, useClass: AppGuard },
{ provide: IAssetRepository, useClass: AssetRepository },
AppService,

View File

@@ -1,11 +1,15 @@
import {
CacheControl,
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_API_KEY_NAME,
ImmichFileResponse,
ImmichReadStream,
isConnectionAborted,
serverVersion,
} from '@app/domain';
import { INestApplication, StreamableFile } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { HttpException, INestApplication, StreamableFile } from '@nestjs/common';
import {
DocumentBuilder,
OpenAPIObject,
@@ -13,8 +17,11 @@ import {
SwaggerDocumentOptions,
SwaggerModule,
} from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { writeFileSync } from 'fs';
import path from 'path';
import { access, constants } from 'fs/promises';
import path, { isAbsolute } from 'path';
import { promisify } from 'util';
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
import { Metadata } from './app.guard';
@@ -30,6 +37,57 @@ export function UseValidation() {
);
}
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
const logger = new ImmichLogger('SendFile');
export const sendFile = async (
res: Response,
next: NextFunction,
handler: () => Promise<ImmichFileResponse>,
): Promise<void> => {
const _sendFile = (path: string, options: SendFileOptions) =>
promisify<string, SendFileOptions>(res.sendFile).bind(res)(path, options);
try {
const file = await handler();
switch (file.cacheControl) {
case CacheControl.PRIVATE_WITH_CACHE:
res.set('Cache-Control', 'private, max-age=86400, no-transform');
break;
case CacheControl.PRIVATE_WITHOUT_CACHE:
res.set('Cache-Control', 'private, no-cache, no-transform');
break;
}
res.header('Content-Type', file.contentType);
const options: SendFileOptions = { dotfiles: 'allow' };
if (!isAbsolute(file.path)) {
options.root = process.cwd();
}
await access(file.path, constants.R_OK);
return _sendFile(file.path, options);
} catch (error: Error | any) {
// ignore client-closed connection
if (isConnectionAborted(error)) {
return;
}
// log non-http errors
if (error instanceof HttpException === false) {
logger.error(`Unable to send file: ${error.name}`, error.stack);
}
res.header('Cache-Control', 'none');
next(error);
}
};
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length });
};

View File

@@ -12,7 +12,6 @@ import {
BulkIdsDto,
DownloadInfoDto,
DownloadResponseDto,
ImmichFileResponse,
MapMarkerDto,
MapMarkerResponseDto,
MemoryLaneDto,
@@ -32,16 +31,19 @@ import {
Get,
HttpCode,
HttpStatus,
Next,
Param,
Post,
Put,
Query,
Res,
StreamableFile,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { DeviceIdDto } from '../api-v1/asset/dto/device-id.dto';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard';
import { UseValidation, asStreamableFile } from '../app.utils';
import { UseValidation, asStreamableFile, sendFile } from '../app.utils';
import { Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto';
@@ -98,8 +100,13 @@ export class AssetController {
@Post('download/:id')
@HttpCode(HttpStatus.OK)
@FileResponse()
downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ImmichFileResponse> {
return this.service.downloadFile(auth, id);
async downloadFile(
@Res() res: Response,
@Next() next: NextFunction,
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
) {
await sendFile(res, next, () => this.service.downloadFile(auth, id));
}
/**

View File

@@ -12,10 +12,11 @@ import {
PersonStatisticsResponseDto,
PersonUpdateDto,
} from '@app/domain';
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Get, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Auth, Authenticated, FileResponse } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UseValidation, sendFile } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Person')
@@ -70,8 +71,13 @@ export class PersonController {
@Get(':id/thumbnail')
@FileResponse()
getPersonThumbnail(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.getThumbnail(auth, id);
async getPersonThumbnail(
@Res() res: Response,
@Next() next: NextFunction,
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
) {
await sendFile(res, next, () => this.service.getThumbnail(auth, id));
}
@Get(':id/assets')

View File

@@ -3,7 +3,6 @@ import {
CreateUserDto as CreateDto,
CreateProfileImageDto,
CreateProfileImageResponseDto,
ImmichFileResponse,
UpdateUserDto as UpdateDto,
UserResponseDto,
UserService,
@@ -13,19 +12,21 @@ import {
Controller,
Delete,
Get,
Header,
HttpCode,
HttpStatus,
Next,
Param,
Post,
Put,
Query,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AdminRoute, Auth, Authenticated, FileResponse } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UseValidation, sendFile } from '../app.utils';
import { FileUploadInterceptor, Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto';
@@ -93,9 +94,8 @@ export class UserController {
}
@Get('profile-image/:id')
@Header('Cache-Control', 'private, no-cache, no-transform')
@FileResponse()
getProfileImage(@Param() { id }: UUIDParamDto): Promise<ImmichFileResponse> {
return this.service.getProfileImage(id);
async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
await sendFile(res, next, () => this.service.getProfileImage(id));
}
}

View File

@@ -22,7 +22,7 @@ export class ErrorInterceptor implements NestInterceptor {
if (error instanceof HttpException === false) {
const errorMessage = routeToErrorMessage(context.getHandler().name);
if (!isConnectionAborted(error)) {
this.logger.error(errorMessage, error, error?.errors);
this.logger.error(errorMessage, error, error?.errors, error?.stack);
}
return new InternalServerErrorException(errorMessage);
} else {

View File

@@ -1,56 +0,0 @@
import { ImmichFileResponse, isConnectionAborted } from '@app/domain';
import { ImmichLogger } from '@app/infra/logger';
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Response } from 'express';
import { access, constants } from 'fs/promises';
import { isAbsolute } from 'path';
import { Observable, mergeMap } from 'rxjs';
import { promisify } from 'util';
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
export class FileServeInterceptor implements NestInterceptor {
private logger = new ImmichLogger(FileServeInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const http = context.switchToHttp();
const res = http.getResponse<Response>();
const sendFile = (path: string, options: SendFileOptions) =>
promisify<string, SendFileOptions>(res.sendFile).bind(res)(path, options);
return next.handle().pipe(
mergeMap(async (file) => {
if (file instanceof ImmichFileResponse === false) {
return file;
}
try {
if (file.cacheControl) {
res.set('Cache-Control', 'private, max-age=86400, no-transform');
}
res.header('Content-Type', file.contentType);
const options: SendFileOptions = { dotfiles: 'allow' };
if (!isAbsolute(file.path)) {
options.root = process.cwd();
}
await access(file.path, constants.R_OK);
return sendFile(file.path, options);
} catch (error: Error | any) {
res.header('Cache-Control', 'none');
if (!isConnectionAborted(error)) {
this.logger.error(`Unable to send file: ${error.name}`, error.stack);
}
// throwing closes the connection and prevents `Error: write EPIPE`
throw error;
}
}),
);
}
}

View File

@@ -1,3 +1,2 @@
export * from './error.interceptor';
export * from './file-serve.interceptor';
export * from './file-upload.interceptor';

View File

@@ -1,5 +1,5 @@
import { envName, isDev, serverVersion } from '@app/domain';
import { WebSocketAdapter, enablePrefilter } from '@app/infra';
import { WebSocketAdapter, databaseChecks } from '@app/infra';
import { ImmichLogger } from '@app/infra/logger';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
@@ -31,7 +31,7 @@ export async function bootstrap() {
app.useStaticAssets('www');
app.use(app.get(AppService).ssr(excludePaths));
await enablePrefilter();
await databaseChecks();
const server = await app.listen(port);
server.requestTimeout = 30 * 60 * 1000;

View File

@@ -1,4 +1,4 @@
import { DataSource } from 'typeorm';
import { DataSource, QueryRunner } from 'typeorm';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
const url = process.env.DB_URL;
@@ -26,9 +26,50 @@ export const databaseConfig: PostgresConnectionOptions = {
// this export is used by TypeORM commands in package.json#scripts
export const dataSource = new DataSource(databaseConfig);
export async function enablePrefilter() {
export async function databaseChecks() {
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
await dataSource.query(`SET vectors.enable_prefilter = on`);
await assertVectors(dataSource);
await enablePrefilter(dataSource);
await dataSource.runMigrations();
}
export async function enablePrefilter(runner: DataSource | QueryRunner) {
await runner.query(`SET vectors.enable_prefilter = on`);
}
export async function getExtensionVersion(extName: string, runner: DataSource | QueryRunner): Promise<string | null> {
const res = await runner.query(`SELECT extversion FROM pg_extension WHERE extname = $1`, [extName]);
return res[0]?.['extversion'] ?? null;
}
export async function getPostgresVersion(runner: DataSource | QueryRunner): Promise<string> {
const res = await runner.query(`SHOW server_version`);
return res[0]['server_version'].split('.')[0];
}
export async function assertVectors(runner: DataSource | QueryRunner) {
const postgresVersion = await getPostgresVersion(runner);
const expected = ['0.1.1', '0.1.11'];
const image = `tensorchord/pgvecto-rs:pg${postgresVersion}-v${expected[expected.length - 1]}`;
await runner.query('CREATE EXTENSION IF NOT EXISTS vectors').catch((err) => {
console.error(
'Failed to create pgvecto.rs extension. ' +
`If you have not updated your Postgres instance to an image that supports pgvecto.rs (such as '${image}'), please do so. ` +
'See the v1.91.0 release notes for more info: https://github.com/immich-app/immich/releases/tag/v1.91.0',
);
throw err;
});
const version = await getExtensionVersion('vectors', runner);
if (version != null && !expected.includes(version)) {
throw new Error(
`The pgvecto.rs extension version is ${version} instead of the expected version ${
expected[expected.length - 1]
}.` + `If you're using the 'latest' tag, please switch to '${image}'.`,
);
}
}

View File

@@ -37,7 +37,7 @@ export enum SystemConfigKey {
JOB_VIDEO_CONVERSION_CONCURRENCY = 'job.videoConversion.concurrency',
JOB_OBJECT_TAGGING_CONCURRENCY = 'job.objectTagging.concurrency',
JOB_RECOGNIZE_FACES_CONCURRENCY = 'job.recognizeFaces.concurrency',
JOB_CLIP_ENCODING_CONCURRENCY = 'job.clipEncoding.concurrency',
JOB_CLIP_ENCODING_CONCURRENCY = 'job.smartSearch.concurrency',
JOB_BACKGROUND_TASK_CONCURRENCY = 'job.backgroundTask.concurrency',
JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency',
JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',

View File

@@ -5,6 +5,7 @@ import { RedisOptions } from 'ioredis';
function parseRedisConfig(): RedisOptions {
if (process.env.IMMICH_TEST_ENV == 'true') {
// Currently running e2e tests, do not use redis
return {};
}

View File

@@ -101,6 +101,7 @@ const imports = [
const moduleExports = [...providers];
if (process.env.IMMICH_TEST_ENV !== 'true') {
// Currently not running e2e tests, set up redis and bull queues
imports.push(BullModule.forRoot(bullConfig));
imports.push(BullModule.registerQueue(...bullQueues));
moduleExports.push(BullModule);

View File

@@ -1,22 +1,22 @@
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { assertVectors } from '../database.config';
export class UsePgVectors1700713871511 implements MigrationInterface {
name = 'UsePgVectors1700713871511';
public async up(queryRunner: QueryRunner): Promise<void> {
await assertVectors(queryRunner);
const faceDimQuery = await queryRunner.query(`
SELECT CARDINALITY(embedding::real[]) as dimsize
FROM asset_faces
LIMIT 1`);
const clipDimQuery = await queryRunner.query(`
SELECT CARDINALITY("clipEmbedding"::real[]) as dimsize
FROM smart_info
LIMIT 1`);
const faceDimSize = faceDimQuery?.[0]?.['dimsize'] ?? 512;
const clipDimSize = clipDimQuery?.[0]?.['dimsize'] ?? 512;
await queryRunner.query('CREATE EXTENSION IF NOT EXISTS vectors');
const clipModelNameQuery = await queryRunner.query(`SELECT value FROM system_config WHERE key = 'machineLearning.clip.modelName'`);
const clipModelName: string = clipModelNameQuery?.[0]?.['value'] ?? 'ViT-B-32__openai';
const clipDimSize = getCLIPModelInfo(clipModelName.replace(/"/g, '')).dimSize;
await queryRunner.query(`
ALTER TABLE asset_faces
@@ -32,7 +32,9 @@ export class UsePgVectors1700713871511 implements MigrationInterface {
INSERT INTO smart_search("assetId", embedding)
SELECT si."assetId", si."clipEmbedding"
FROM smart_info si
WHERE "clipEmbedding" IS NOT NULL`);
WHERE "clipEmbedding" IS NOT NULL
AND CARDINALITY("clipEmbedding"::real[]) = ${clipDimSize}
AND array_position(si."clipEmbedding", NULL) IS NULL`);
await queryRunner.query(`ALTER TABLE smart_info DROP COLUMN IF EXISTS "clipEmbedding"`);
}

View File

@@ -27,41 +27,64 @@ export class AccessRepository implements IAccessRepository {
) {}
activity = {
hasOwnerAccess: (userId: string, activityId: string): Promise<boolean> => {
return this.activityRepository.exist({
where: {
id: activityId,
userId,
},
});
},
hasAlbumOwnerAccess: (userId: string, activityId: string): Promise<boolean> => {
return this.activityRepository.exist({
where: {
id: activityId,
album: {
ownerId: userId,
checkOwnerAccess: async (userId: string, activityIds: Set<string>): Promise<Set<string>> => {
if (activityIds.size === 0) {
return new Set();
}
return this.activityRepository
.find({
select: { id: true },
where: {
id: In([...activityIds]),
userId,
},
},
});
})
.then((activities) => new Set(activities.map((activity) => activity.id)));
},
hasCreateAccess: (userId: string, albumId: string): Promise<boolean> => {
return this.albumRepository.exist({
where: [
{
id: albumId,
isActivityEnabled: true,
sharedUsers: {
id: userId,
checkAlbumOwnerAccess: async (userId: string, activityIds: Set<string>): Promise<Set<string>> => {
if (activityIds.size === 0) {
return new Set();
}
return this.activityRepository
.find({
select: { id: true },
where: {
id: In([...activityIds]),
album: {
ownerId: userId,
},
},
{
id: albumId,
isActivityEnabled: true,
ownerId: userId,
},
],
});
})
.then((activities) => new Set(activities.map((activity) => activity.id)));
},
checkCreateAccess: async (userId: string, albumIds: Set<string>): Promise<Set<string>> => {
if (albumIds.size === 0) {
return new Set();
}
return this.albumRepository
.find({
select: { id: true },
where: [
{
id: In([...albumIds]),
isActivityEnabled: true,
sharedUsers: {
id: userId,
},
},
{
id: In([...albumIds]),
isActivityEnabled: true,
ownerId: userId,
},
],
})
.then((albums) => new Set(albums.map((album) => album.id)));
},
};
@@ -320,7 +343,8 @@ export class AccessRepository implements IAccessRepository {
})
.then((persons) => new Set(persons.map((person) => person.id)));
},
hasFaceOwnerAccess: async (userId: string, assetFaceIds: Set<string>): Promise<Set<string>> => {
checkFaceOwnerAccess: async (userId: string, assetFaceIds: Set<string>): Promise<Set<string>> => {
if (assetFaceIds.size === 0) {
return new Set();
}

View File

@@ -706,9 +706,7 @@ export class AssetRepository implements IAssetRepository {
.createQueryBuilder('e')
.select('city')
.groupBy('city')
.having('count(city) >= :minAssetsPerField', { minAssetsPerField })
.orderBy('random()')
.limit(maxFields);
.having('count(city) >= :minAssetsPerField', { minAssetsPerField });
const items = await this.getBuilder({
userIds: [ownerId],
@@ -737,9 +735,7 @@ export class AssetRepository implements IAssetRepository {
.createQueryBuilder('si')
.select('unnest(tags)', 'tag')
.groupBy('tag')
.having('count(*) >= :minAssetsPerField', { minAssetsPerField })
.orderBy('random()')
.limit(maxFields);
.having('count(*) >= :minAssetsPerField', { minAssetsPerField });
const items = await this.getBuilder({
userIds: [ownerId],
@@ -808,38 +804,75 @@ export class AssetRepository implements IAssetRepository {
return builder;
}
@GenerateSql({ params: [DummyValue.STRING, DummyValue.UUID, { numResults: 250 }] })
async searchMetadata(query: string, ownerId: string, { numResults }: MetadataSearchOptions): Promise<AssetEntity[]> {
const rows = await this.repository
.createQueryBuilder('assets')
.select('assets.*')
.addSelect('e.country', 'country')
.addSelect('e.state', 'state')
.addSelect('e.city', 'city')
.addSelect('e.description', 'description')
.addSelect('e.model', 'model')
.addSelect('e.make', 'make')
const rows = await this.getBuilder({
userIds: [ownerId],
exifInfo: false,
isArchived: false,
})
.select('asset.*')
.addSelect('e.*')
.addSelect('COALESCE(si.tags, array[]::text[])', 'tags')
.addSelect('COALESCE(si.objects, array[]::text[])', 'objects')
.innerJoin('smart_info', 'si', 'si."assetId" = assets."id"')
.innerJoin('exif', 'e', 'assets."id" = e."assetId"')
.where('a.ownerId = :ownerId', { ownerId })
.where(
'(e."exifTextSearchableColumn" || si."smartInfoTextSearchableColumn") @@ PLAINTO_TSQUERY(\'english\', :query)',
.innerJoin('exif', 'e', 'asset."id" = e."assetId"')
.leftJoin('smart_info', 'si', 'si."assetId" = asset."id"')
.andWhere(
`(e."exifTextSearchableColumn" || COALESCE(si."smartInfoTextSearchableColumn", to_tsvector('english', '')))
@@ PLAINTO_TSQUERY('english', :query)`,
{ query },
)
.limit(numResults)
.getRawMany();
return rows.map(
({ tags, objects, country, state, city, description, model, make, ...assetInfo }) =>
({
tags,
objects,
country,
state,
city,
description,
model,
make,
dateTimeOriginal,
exifImageHeight,
exifImageWidth,
exposureTime,
fNumber,
fileSizeInByte,
focalLength,
iso,
latitude,
lensModel,
longitude,
modifyDate,
projectionType,
timeZone,
...assetInfo
}) =>
({
exifInfo: {
country,
state,
city,
country,
dateTimeOriginal,
description,
model,
exifImageHeight,
exifImageWidth,
exposureTime,
fNumber,
fileSizeInByte,
focalLength,
iso,
latitude,
lensModel,
longitude,
make,
model,
modifyDate,
projectionType,
state,
timeZone,
},
smartInfo: {
tags,

View File

@@ -56,6 +56,9 @@ export class SmartInfoRepository implements ISmartInfoRepository {
.createQueryBuilder(AssetEntity, 'a')
.innerJoin('a.smartSearch', 's')
.where('a.ownerId = :ownerId')
.andWhere('a.isVisible = true')
.andWhere('a.isArchived = false')
.andWhere('a.fileCreatedAt < NOW()')
.leftJoinAndSelect('a.exifInfo', 'e')
.orderBy('s.embedding <=> :embedding')
.setParameters({ ownerId, embedding: asVector(embedding) })

View File

@@ -618,10 +618,6 @@ WITH
city
HAVING
count(city) >= $1
ORDER BY
random() ASC
LIMIT
12
)
SELECT DISTINCT
ON (c.city) "asset"."id" AS "data",
@@ -653,10 +649,6 @@ WITH
tag
HAVING
count(*) >= $1
ORDER BY
random() ASC
LIMIT
12
)
SELECT DISTINCT
ON (unnest("si"."tags")) "asset"."id" AS "data",
@@ -676,3 +668,30 @@ WHERE
AND ("asset"."deletedAt" IS NULL)
LIMIT
12
-- AssetRepository.searchMetadata
SELECT
asset.*,
e.*,
COALESCE("si"."tags", array[]::text []) AS "tags",
COALESCE("si"."objects", array[]::text []) AS "objects"
FROM
"assets" "asset"
INNER JOIN "exif" "e" ON asset."id" = e."assetId"
LEFT JOIN "smart_info" "si" ON si."assetId" = asset."id"
WHERE
(
"asset"."isVisible" = true
AND "asset"."fileCreatedAt" < NOW()
AND "asset"."ownerId" IN ($1)
AND "asset"."isArchived" = $2
AND (
e."exifTextSearchableColumn" || COALESCE(
si."smartInfoTextSearchableColumn",
to_tsvector('english', '')
)
) @@ PLAINTO_TSQUERY('english', $3)
)
AND ("asset"."deletedAt" IS NULL)
LIMIT
250

View File

@@ -66,7 +66,12 @@ FROM
INNER JOIN "smart_search" "s" ON "s"."assetId" = "a"."id"
LEFT JOIN "exif" "e" ON "e"."assetId" = "a"."id"
WHERE
("a"."ownerId" = $1)
(
"a"."ownerId" = $1
AND "a"."isVisible" = true
AND "a"."isArchived" = false
AND "a"."fileCreatedAt" < NOW()
)
AND ("a"."deletedAt" IS NULL)
ORDER BY
"s"."embedding" <= > $2 ASC

View File

@@ -1,5 +1,5 @@
import { envName, serverVersion } from '@app/domain';
import { WebSocketAdapter, enablePrefilter } from '@app/infra';
import { WebSocketAdapter, databaseChecks } from '@app/infra';
import { ImmichLogger } from '@app/infra/logger';
import { NestFactory } from '@nestjs/core';
import { MicroservicesModule } from './microservices.module';
@@ -12,7 +12,7 @@ export async function bootstrap() {
app.useLogger(app.get(ImmichLogger));
app.useWebSocketAdapter(new WebSocketAdapter(app));
await enablePrefilter();
await databaseChecks();
await app.listen(port);

View File

@@ -20,4 +20,9 @@ export const albumApi = {
expect(res.status).toEqual(200);
return res.body as AlbumResponseDto;
},
getAllAlbums: async (server: any, accessToken: string) => {
const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send();
expect(res.status).toEqual(200);
return res.body as AlbumResponseDto[];
},
};

View File

@@ -0,0 +1,16 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { apiKeyCreateStub } from '@test';
import request from 'supertest';
export const apiKeyApi = {
createApiKey: async (server: any, accessToken: string) => {
const { status, body } = await request(server)
.post('/api-key')
.set('Authorization', `Bearer ${accessToken}`)
.send(apiKeyCreateStub);
expect(status).toBe(201);
return body as APIKeyCreateResponseDto;
},
};

View File

@@ -1,5 +1,6 @@
import { activityApi } from './activity-api';
import { albumApi } from './album-api';
import { apiKeyApi } from './api-key-api';
import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
@@ -10,6 +11,7 @@ import { userApi } from './user-api';
export const api = {
activityApi,
authApi,
apiKeyApi,
assetApi,
libraryApi,
sharedLinkApi,

View File

@@ -1,18 +1,17 @@
version: "3.8"
version: '3.8'
name: "immich-test-e2e"
name: 'immich-test-e2e'
services:
immich-server:
image: immich-server-dev:latest
build:
context: ../
context: ../../
dockerfile: server/Dockerfile
target: dev
entrypoint: [ "/usr/local/bin/npm", "run" ]
entrypoint: ['/usr/local/bin/npm', 'run']
command: test:e2e
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
environment:
- DB_HOSTNAME=database

View File

@@ -15,7 +15,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
let nonOwner: LoginResponseDto;
beforeAll(async () => {
[server] = await testApp.create();
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);

View File

@@ -24,7 +24,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
let user2Albums: AlbumResponseDto[];
beforeAll(async () => {
[server] = await testApp.create();
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {

View File

@@ -63,7 +63,8 @@ describe(`${AssetController.name} (e2e)`, () => {
};
beforeAll(async () => {
[server, app] = await testApp.create();
app = await testApp.create();
server = app.getHttpServer();
assetRepository = app.get<IAssetRepository>(IAssetRepository);
await testApp.reset();

View File

@@ -39,8 +39,7 @@ describe(`${AuthController.name} (e2e)`, () => {
let accessToken: string;
beforeAll(async () => {
await testApp.reset();
[server] = await testApp.create();
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {

View File

@@ -90,10 +90,7 @@ describe(`Supported file formats (e2e)`, () => {
iso: 20,
focalLength: 3.99,
fNumber: 1.8,
state: 'Douglas County, Nebraska',
timeZone: 'America/Chicago',
city: 'Ralston',
country: 'United States of America',
},
},
{
@@ -168,7 +165,7 @@ describe(`Supported file formats (e2e)`, () => {
const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
beforeAll(async () => {
[server] = await testApp.create({ jobs: true });
server = (await testApp.create({ jobs: true })).getHttpServer();
});
afterAll(async () => {

View File

@@ -0,0 +1,11 @@
{
"reverseGeocoding": {
"enabled": false
},
"machineLearning": {
"enabled": false
},
"logging": {
"enabled": false
}
}

View File

@@ -13,7 +13,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
let admin: LoginResponseDto;
beforeAll(async () => {
[server] = await testApp.create({ jobs: true });
server = (await testApp.create({ jobs: true })).getHttpServer();
});
afterAll(async () => {

View File

@@ -8,7 +8,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
let server: any;
beforeAll(async () => {
[server] = await testApp.create();
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {

View File

@@ -12,7 +12,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
let user3: LoginResponseDto;
beforeAll(async () => {
[server] = await testApp.create();
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);

View File

@@ -17,7 +17,8 @@ describe(`${PersonController.name}`, () => {
let hiddenPerson: PersonEntity;
beforeAll(async () => {
[server, app] = await testApp.create();
app = await testApp.create();
server = app.getHttpServer();
personRepository = app.get<IPersonRepository>(IPersonRepository);
});

View File

@@ -9,7 +9,7 @@ import {
import { SearchController } from '@app/immich';
import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { errorStub } from '@test/fixtures';
import { errorStub, searchStub } from '@test/fixtures';
import { generateAsset, testApp } from '@test/test-utils';
import request from 'supertest';
@@ -24,7 +24,8 @@ describe(`${SearchController.name}`, () => {
let asset1: AssetResponseDto;
beforeAll(async () => {
[server, app] = await testApp.create();
app = await testApp.create();
server = app.getHttpServer();
assetRepository = app.get<IAssetRepository>(IAssetRepository);
smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
});
@@ -39,32 +40,19 @@ describe(`${SearchController.name}`, () => {
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
libraries = await api.libraryApi.getAll(server, accessToken);
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
await assetRepository.upsertExif({
assetId,
latitude: 90,
longitude: 90,
city: 'Immich',
state: 'Nebraska',
country: 'United States',
make: 'Canon',
model: 'EOS Rebel T7',
lensModel: 'Fancy lens',
});
await smartInfoRepository.upsert(
{ assetId, objects: ['car', 'tree'], tags: ['accident'] },
Array.from({ length: 512 }, Math.random),
);
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true });
if (!assetWithMetadata) {
throw new Error('Asset not found');
}
asset1 = mapAsset(assetWithMetadata);
});
describe('GET /search', () => {
beforeEach(async () => {});
describe('GET /search (exif)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
if (!assetWithMetadata) {
throw new Error('Asset not found');
}
asset1 = mapAsset(assetWithMetadata);
});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/search');
@@ -174,6 +162,20 @@ describe(`${SearchController.name}`, () => {
},
});
});
});
describe('GET /search (smart info)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random));
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true });
if (!assetWithMetadata) {
throw new Error('Asset not found');
}
asset1 = mapAsset(assetWithMetadata);
});
it('should return assets when searching by object', async () => {
if (!asset1?.smartInfo?.objects) {

View File

@@ -11,7 +11,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
[server] = await testApp.create();
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
@@ -74,10 +74,10 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
expect(status).toBe(200);
expect(body).toEqual({
clipEncode: false,
configFile: false,
configFile: true,
facialRecognition: false,
map: true,
reverseGeocoding: true,
reverseGeocoding: false,
oauth: false,
oauthAutoLaunch: false,
passwordLogin: true,

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