mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 04:41:40 -08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40859551b | ||
|
|
4e9b96ff1a | ||
|
|
baed16dab6 | ||
|
|
a7b4727c20 | ||
|
|
9834693fab | ||
|
|
085dc6cd93 | ||
|
|
de1514a441 | ||
|
|
fade8b627f | ||
|
|
d3e1572229 | ||
|
|
ffc31f034c | ||
|
|
3beeffaaf0 | ||
|
|
b68800d45c | ||
|
|
b520955d0e | ||
|
|
6e7b3d6f24 | ||
|
|
c45e8cc170 | ||
|
|
c6f56d9591 | ||
|
|
691e20521d | ||
|
|
27f8dd6040 | ||
|
|
e3fa32ad23 | ||
|
|
08f66c2ae5 | ||
|
|
4f38a283b4 | ||
|
|
00771899da | ||
|
|
09402eb6d0 | ||
|
|
d9b5adf0f7 | ||
|
|
a15c799ba3 | ||
|
|
bda9fd9dfe | ||
|
|
19754d4b21 | ||
|
|
62347edf43 | ||
|
|
67f020380f |
@@ -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/
|
||||
31
.github/workflows/test.yml
vendored
31
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
4
Makefile
4
Makefile
@@ -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
4
cli/.gitignore
vendored
@@ -10,4 +10,6 @@ oclif.manifest.json
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
/coverage/
|
||||
/coverage/
|
||||
.reverse-geocoding-dump/
|
||||
upload/
|
||||
@@ -1,4 +1,6 @@
|
||||
**/*.spec.js
|
||||
test/**
|
||||
upload/**
|
||||
.editorconfig
|
||||
.eslintignore
|
||||
.eslintrc.js
|
||||
|
||||
19
cli/Dockerfile
Normal file
19
cli/Dockerfile
Normal 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
1925
cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
2
cli/src/api/open-api/api.ts
generated
2
cli/src/api/open-api/api.ts
generated
@@ -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).
|
||||
|
||||
2
cli/src/api/open-api/base.ts
generated
2
cli/src/api/open-api/base.ts
generated
@@ -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).
|
||||
|
||||
2
cli/src/api/open-api/common.ts
generated
2
cli/src/api/open-api/common.ts
generated
@@ -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).
|
||||
|
||||
2
cli/src/api/open-api/configuration.ts
generated
2
cli/src/api/open-api/configuration.ts
generated
@@ -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).
|
||||
|
||||
2
cli/src/api/open-api/index.ts
generated
2
cli/src/api/open-api/index.ts
generated
@@ -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).
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
37
cli/src/constants.ts
Normal 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);
|
||||
3
cli/src/cores/dto/base-options-dto.ts
Normal file
3
cli/src/cores/dto/base-options-dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class BaseOptionsDto {
|
||||
config?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
cli/test/cli-test-utils.ts
Normal file
38
cli/test/cli-test-utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
24
cli/test/e2e/jest-e2e.json
Normal file
24
cli/test/e2e/jest-e2e.json
Normal 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"
|
||||
}
|
||||
}
|
||||
48
cli/test/e2e/login-key.e2e-spec.ts
Normal file
48
cli/test/e2e/login-key.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
cli/test/e2e/server-info.e2e-spec.ts
Normal file
42
cli/test/e2e/server-info.e2e-spec.ts
Normal 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
43
cli/test/e2e/setup.ts
Normal 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';
|
||||
};
|
||||
49
cli/test/e2e/upload.e2e-spec.ts
Normal file
49
cli/test/e2e/upload.e2e-spec.ts
Normal 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
3
cli/test/global-setup.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
};
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# Help Me!
|
||||
|
||||
BIN
docs/docs/overview/img/upload-button.png
Normal file
BIN
docs/docs/overview/img/upload-button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Logo
|
||||
|
||||
85
docs/docs/overview/quick-start.mdx
Normal file
85
docs/docs/overview/quick-start.mdx
Normal 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).
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Support The Project
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6188,7 +6188,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.91.1",
|
||||
"version": "1.91.4",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.91.1",
|
||||
"version": "1.91.4",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './error.interceptor';
|
||||
export * from './file-serve.interceptor';
|
||||
export * from './file-upload.interceptor';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
|
||||
16
server/test/api/api-key-api.ts
Normal file
16
server/test/api/api-key-api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -24,7 +24,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
||||
let user2Albums: AlbumResponseDto[];
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
11
server/test/e2e/immich-e2e-config.json
Normal file
11
server/test/e2e/immich-e2e-config.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"reverseGeocoding": {
|
||||
"enabled": false
|
||||
},
|
||||
"machineLearning": {
|
||||
"enabled": false
|
||||
},
|
||||
"logging": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user