0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-08 03:01:32 -05:00

refactor(cli): organize files, simplify types, use @immich/sdk (#6747)

This commit is contained in:
Jason Rasmussen 2024-01-30 07:55:34 -05:00 committed by GitHub
parent 64fad67713
commit 64da2c1698
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 308 additions and 350 deletions

7
cli/package-lock.json generated
View file

@ -16,7 +16,6 @@
"commander": "^11.0.0",
"form-data": "^4.0.0",
"glob": "^10.3.1",
"graceful-fs": "^4.2.11",
"yaml": "^2.3.1"
},
"bin": {
@ -4184,7 +4183,8 @@
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
"node_modules/graphemer": {
"version": "1.4.0",
@ -10436,7 +10436,8 @@
"graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
"graphemer": {
"version": "1.4.0",

View file

@ -19,7 +19,6 @@
"commander": "^11.0.0",
"form-data": "^4.0.0",
"glob": "^10.3.1",
"graceful-fs": "^4.2.11",
"yaml": "^2.3.1"
},
"devDependencies": {
@ -74,7 +73,6 @@
"!**/open-api/**"
],
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>/src/api/$1",
"^@test(|/.*)$": "<rootDir>../server/test/$1",
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",

View file

@ -1,9 +1,6 @@
import { ImmichApi } from '../api/client';
import { SessionService } from '../services/session.service';
import { LoginError } from '../cores/errors/login-error';
import { exit } from 'node:process';
import { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk';
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
import { ImmichApi } from '../services/api.service';
import { SessionService } from '../services/session.service';
export abstract class BaseCommand {
protected sessionService!: SessionService;
@ -11,7 +8,7 @@ export abstract class BaseCommand {
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto;
constructor(options: BaseOptionsDto) {
constructor(options: { config?: string }) {
if (!options.config) {
throw new Error('Config directory is required');
}
@ -19,15 +16,6 @@ export abstract class BaseCommand {
}
public async connect(): Promise<void> {
try {
this.immichApi = await this.sessionService.connect();
} catch (error) {
if (error instanceof LoginError) {
console.log(error.message);
exit(1);
} else {
throw error;
}
}
this.immichApi = await this.sessionService.connect();
}
}

View file

@ -0,0 +1,7 @@
import { BaseCommand } from './base-command';
export class LoginCommand extends BaseCommand {
public async run(instanceUrl: string, apiKey: string): Promise<void> {
await this.sessionService.login(instanceUrl, apiKey);
}
}

View file

@ -1,9 +0,0 @@
import { BaseCommand } from '../../cli/base-command';
export class LoginKey extends BaseCommand {
public async run(instanceUrl: string, apiKey: string): Promise<void> {
console.log('Executing API key auth flow...');
await this.sessionService.keyLogin(instanceUrl, apiKey);
}
}

View file

@ -0,0 +1,8 @@
import { BaseCommand } from './base-command';
export class LogoutCommand extends BaseCommand {
public static readonly description = 'Logout and remove persisted credentials';
public async run(): Promise<void> {
await this.sessionService.logout();
}
}

View file

@ -1,13 +0,0 @@
import { BaseCommand } from '../cli/base-command';
export class Logout extends BaseCommand {
public static readonly description = 'Logout and remove persisted credentials';
public async run(): Promise<void> {
console.log('Executing logout flow...');
await this.sessionService.logout();
console.log('Successfully logged out');
}
}

View file

@ -0,0 +1,17 @@
import { BaseCommand } from './base-command';
export class ServerInfoCommand extends BaseCommand {
public async run() {
await this.connect();
const { data: versionInfo } = await this.immichApi.serverInfoApi.getServerVersion();
const { data: mediaTypes } = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
const { data: statistics } = await this.immichApi.assetApi.getAssetStatistics();
console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(
`Statistics:\n Images: ${statistics.images}\n Videos: ${statistics.videos}\n Total: ${statistics.total}`,
);
}
}

View file

@ -1,19 +0,0 @@
import { BaseCommand } from '../cli/base-command';
export class ServerInfo extends BaseCommand {
public async run() {
await this.connect();
const { data: versionInfo } = await this.immichApi.serverInfoApi.getServerVersion();
console.log(`Server is running version ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
const { data: supportedmedia } = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
console.log(`Supported image types: ${supportedmedia.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Supported video types: ${supportedmedia.video.map((extension) => extension.replace('.', ''))}`);
const { data: statistics } = await this.immichApi.assetApi.getAssetStatistics();
console.log(`Images: ${statistics.images}, Videos: ${statistics.videos}, Total: ${statistics.total}`);
}
}

View file

@ -1,15 +1,112 @@
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';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import byteSize from 'byte-size';
import cliProgress from 'cli-progress';
import FormData from 'form-data';
import fs, { ReadStream, createReadStream } from 'node:fs';
import { CrawlService } from '../services/crawl.service';
import { BaseCommand } from './base-command';
import { basename } from 'node:path';
import { access, constants, stat, unlink } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import Os from 'os';
export class Upload extends BaseCommand {
class Asset {
readonly path: string;
readonly deviceId!: string;
deviceAssetId?: string;
fileCreatedAt?: string;
fileModifiedAt?: string;
sidecarPath?: string;
fileSize!: number;
albumName?: string;
constructor(path: string) {
this.path = path;
}
async prepare() {
const stats = await stat(this.path);
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
this.fileCreatedAt = stats.mtime.toISOString();
this.fileModifiedAt = stats.mtime.toISOString();
this.fileSize = stats.size;
this.albumName = this.extractAlbumName();
}
async getUploadFormData(): Promise<FormData> {
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');
// TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`;
let sidecarData: ReadStream | undefined = undefined;
try {
await access(sideCarPath, constants.R_OK);
sidecarData = createReadStream(sideCarPath);
} catch (error) {}
const data: any = {
assetData: createReadStream(this.path),
deviceAssetId: this.deviceAssetId,
deviceId: 'CLI',
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: String(false),
};
const formData = new FormData();
for (const prop in data) {
formData.append(prop, data[prop]);
}
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
return formData;
}
async delete(): Promise<void> {
return unlink(this.path);
}
public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = createReadStream(filePath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
return await sha1(this.path);
}
private extractAlbumName(): string {
if (Os.platform() === 'win32') {
return this.path.split('\\').slice(-2)[0];
} else {
return this.path.split('/').slice(-2)[0];
}
}
}
export class UploadOptionsDto {
recursive? = false;
exclusionPatterns?: string[] = [];
dryRun? = false;
skipHash? = false;
delete? = false;
album? = false;
albumName? = '';
includeHidden? = false;
}
export class UploadCommand extends BaseCommand {
uploadLength!: number;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
@ -18,32 +115,29 @@ export class Upload extends BaseCommand {
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
const crawlOptions = new CrawlOptionsDto();
crawlOptions.pathsToCrawl = paths;
crawlOptions.recursive = options.recursive;
crawlOptions.exclusionPatterns = options.exclusionPatterns;
crawlOptions.includeHidden = options.includeHidden;
const files: string[] = [];
const inputFiles: string[] = [];
for (const pathArgument of paths) {
const fileStat = await fs.promises.lstat(pathArgument);
if (fileStat.isFile()) {
files.push(pathArgument);
inputFiles.push(pathArgument);
}
}
const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
const files: string[] = await crawlService.crawl({
pathsToCrawl: paths,
recursive: options.recursive,
exclusionPatterns: options.exclusionPatterns,
includeHidden: options.includeHidden,
});
crawledFiles.push(...files);
files.push(...inputFiles);
if (crawledFiles.length === 0) {
if (files.length === 0) {
console.log('No assets found, exiting');
return;
}
const assetsToUpload = crawledFiles.map((path) => new Asset(path));
const assetsToUpload = files.map((path) => new Asset(path));
const uploadProgress = new cliProgress.SingleBar(
{
@ -104,7 +198,7 @@ export class Upload extends BaseCommand {
if (!skipAsset) {
if (!options.dryRun) {
if (!skipUpload) {
const formData = asset.getUploadFormData();
const formData = await asset.getUploadFormData();
const res = await this.uploadAsset(formData);
existingAssetId = res.data.id;
uploadCounter++;
@ -157,7 +251,7 @@ export class Upload extends BaseCommand {
} else {
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
deletionProgress.start(crawledFiles.length, 0);
deletionProgress.start(files.length, 0);
for (const asset of assetsToUpload) {
if (!options.dryRun) {
@ -172,14 +266,14 @@ export class Upload extends BaseCommand {
}
private async uploadAsset(data: FormData): Promise<AxiosResponse> {
const url = this.immichApi.apiConfiguration.instanceUrl + '/asset/upload';
const url = this.immichApi.instanceUrl + '/asset/upload';
const config: AxiosRequestConfig = {
method: 'post',
maxRedirects: 0,
url,
headers: {
'x-api-key': this.immichApi.apiConfiguration.apiKey,
'x-api-key': this.immichApi.apiKey,
...data.getHeaders(),
},
maxContentLength: Infinity,

View file

@ -1,9 +0,0 @@
export class ApiConfiguration {
public readonly instanceUrl!: string;
public readonly apiKey!: string;
constructor(instanceUrl: string, apiKey: string) {
this.instanceUrl = instanceUrl;
this.apiKey = apiKey;
}
}

View file

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

View file

@ -1,6 +0,0 @@
export class CrawlOptionsDto {
pathsToCrawl!: string[];
recursive? = false;
includeHidden? = false;
exclusionPatterns?: string[];
}

View file

@ -1,10 +0,0 @@
export class UploadOptionsDto {
recursive? = false;
exclusionPatterns?: string[] = [];
dryRun? = false;
skipHash? = false;
delete? = false;
album? = false;
albumName? = '';
includeHidden? = false;
}

View file

@ -1,9 +0,0 @@
export class LoginError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}

View file

@ -1 +0,0 @@
export * from './models';

View file

@ -1,91 +0,0 @@
import crypto from 'crypto';
import FormData from 'form-data';
import * as fs from 'graceful-fs';
import { createReadStream } from 'node:fs';
import { basename } from 'node:path';
import Os from 'os';
export class Asset {
readonly path: string;
readonly deviceId!: string;
deviceAssetId?: string;
fileCreatedAt?: string;
fileModifiedAt?: string;
sidecarPath?: string;
fileSize!: number;
albumName?: string;
constructor(path: string) {
this.path = path;
}
async prepare() {
const stats = await fs.promises.stat(this.path);
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
this.fileCreatedAt = stats.mtime.toISOString();
this.fileModifiedAt = stats.mtime.toISOString();
this.fileSize = stats.size;
this.albumName = this.extractAlbumName();
}
getUploadFormData(): FormData {
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');
// TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`;
let sidecarData: fs.ReadStream | undefined = undefined;
try {
fs.accessSync(sideCarPath, fs.constants.R_OK);
sidecarData = createReadStream(sideCarPath);
} catch (error) {}
const data: any = {
assetData: createReadStream(this.path),
deviceAssetId: this.deviceAssetId,
deviceId: 'CLI',
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: String(false),
};
const formData = new FormData();
for (const prop in data) {
formData.append(prop, data[prop]);
}
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
return formData;
}
async delete(): Promise<void> {
return fs.promises.unlink(this.path);
}
public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = crypto.createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = fs.createReadStream(filePath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
return await sha1(this.path);
}
private extractAlbumName(): string {
if (Os.platform() === 'win32') {
return this.path.split('\\').slice(-2)[0];
} else {
return this.path.split('/').slice(-2)[0];
}
}
}

View file

@ -1 +0,0 @@
export * from './asset';

View file

@ -1,14 +1,12 @@
#! /usr/bin/env node
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';
import { Command, Option } from 'commander';
import path from 'node:path';
import os from 'os';
import { version } from '../package.json';
import { LoginCommand } from './commands/login';
import { LogoutCommand } from './commands/logout.command';
import { ServerInfoCommand } from './commands/server-info.command';
import { UploadCommand } from './commands/upload.command';
const userHomeDir = os.homedir();
const configDir = path.join(userHomeDir, '.config/immich/');
@ -46,14 +44,14 @@ program
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => {
options.exclusionPatterns = options.ignore;
await new Upload(program.opts()).run(paths, options);
await new UploadCommand(program.opts()).run(paths, options);
});
program
.command('server-info')
.description('Display server information')
.action(async () => {
await new ServerInfo(program.opts()).run();
await new ServerInfoCommand(program.opts()).run();
});
program
@ -62,14 +60,14 @@ program
.argument('[instanceUrl]')
.argument('[apiKey]')
.action(async (paths, options) => {
await new LoginKey(program.opts()).run(paths, options);
await new LoginCommand(program.opts()).run(paths, options);
});
program
.command('logout')
.description('Remove stored credentials')
.action(async () => {
await new Logout(program.opts()).run();
await new LogoutCommand(program.opts()).run();
});
program.parse(process.argv);

View file

@ -10,7 +10,6 @@ import {
SystemConfigApi,
UserApi,
} from '@immich/sdk';
import { ApiConfiguration } from '../cores/api-configuration';
import FormData from 'form-data';
export class ImmichApi {
@ -25,10 +24,11 @@ export class ImmichApi {
public systemConfigApi: SystemConfigApi;
private readonly config;
public readonly apiConfiguration: ApiConfiguration;
constructor(instanceUrl: string, apiKey: string) {
this.apiConfiguration = new ApiConfiguration(instanceUrl, apiKey);
constructor(
public instanceUrl: string,
public apiKey: string,
) {
this.config = new Configuration({
basePath: instanceUrl,
baseOptions: {
@ -49,4 +49,9 @@ export class ImmichApi {
this.keyApi = new APIKeyApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
}
setApiKey(apiKey: string) {
this.apiKey = apiKey;
this.config.baseOptions.headers['x-api-key'] = apiKey;
}
}

View file

@ -1,10 +1,9 @@
import mockfs from 'mock-fs';
import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto';
import { CrawlService } from '.';
import { CrawlService, CrawlOptions } from './crawl.service';
interface Test {
test: string;
options: CrawlOptionsDto;
options: CrawlOptions;
files: Record<string, boolean>;
}

View file

@ -1,7 +1,13 @@
import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto';
import { glob } from 'glob';
import * as fs from 'fs';
export class CrawlOptions {
pathsToCrawl!: string[];
recursive? = false;
includeHidden? = false;
exclusionPatterns?: string[];
}
export class CrawlService {
private readonly extensions!: string[];
@ -9,8 +15,9 @@ export class CrawlService {
this.extensions = image.concat(video).map((extension) => extension.replace('.', ''));
}
async crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
async crawl(options: CrawlOptions): Promise<string[]> {
const { recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
if (!pathsToCrawl) {
return Promise.resolve([]);
}
@ -44,7 +51,7 @@ export class CrawlService {
searchPattern = '{' + patterns.join(',') + '}';
}
if (crawlOptions.recursive) {
if (recursive) {
searchPattern = searchPattern + '/**/';
}

View file

@ -1 +0,0 @@
export * from './crawl.service';

View file

@ -1,7 +1,6 @@
import { SessionService } from './session.service';
import fs from 'node:fs';
import yaml from 'yaml';
import { LoginError } from '../cores/errors/login-error';
import {
TEST_AUTH_FILE,
TEST_CONFIG_DIR,
@ -70,7 +69,6 @@ describe('SessionService', () => {
}),
);
await sessionService.connect().catch((error) => {
expect(error).toBeInstanceOf(LoginError);
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
});
});
@ -82,13 +80,11 @@ describe('SessionService', () => {
}),
);
await expect(sessionService.connect()).rejects.toThrow(
new LoginError(`API key missing in auth config file ${TEST_AUTH_FILE}`),
);
await expect(sessionService.connect()).rejects.toThrow(`API key missing in auth config file ${TEST_AUTH_FILE}`);
});
it('should create auth file when logged in', async () => {
await sessionService.keyLogin(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
await sessionService.login(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
const data: string = await readTestAuthFile();
const authConfig = yaml.parse(data);
@ -109,6 +105,10 @@ describe('SessionService', () => {
expect(error.message).toContain('ENOENT');
});
expect(consoleSpy.mock.calls).toEqual([[`Removed auth file ${TEST_AUTH_FILE}`]]);
expect(consoleSpy.mock.calls).toEqual([
['Logging out...'],
[`Removed auth file ${TEST_AUTH_FILE}`],
['Successfully logged out'],
]);
});
});

View file

@ -1,31 +1,40 @@
import fs from 'node:fs';
import yaml from 'yaml';
import { existsSync } from 'fs';
import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { ImmichApi } from '../api/client';
import { LoginError } from '../cores/errors/login-error';
import yaml from 'yaml';
import { ImmichApi } from './api.service';
class LoginError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class SessionService {
readonly configDir!: string;
readonly authPath!: string;
private api!: ImmichApi;
constructor(configDir: string) {
this.configDir = configDir;
this.authPath = path.join(configDir, '/auth.yml');
}
public async connect(): Promise<ImmichApi> {
async connect(): Promise<ImmichApi> {
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) => {
await access(this.authPath, 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 data: string = await readFile(this.authPath, 'utf8');
const parsedConfig = yaml.parse(data);
instanceUrl = parsedConfig.instanceUrl;
@ -40,51 +49,54 @@ export class SessionService {
}
}
this.api = new ImmichApi(instanceUrl, apiKey);
const api = new ImmichApi(instanceUrl, apiKey);
await this.ping();
const { data: pingResponse } = await api.serverInfoApi.pingServer().catch((error) => {
throw new Error(`Failed to connect to server ${api.instanceUrl}: ${error.message}`);
});
return this.api;
if (pingResponse.res !== 'pong') {
throw new Error(`Could not parse response. Is Immich listening on ${api.instanceUrl}?`);
}
return api;
}
public async keyLogin(instanceUrl: string, apiKey: string): Promise<ImmichApi> {
this.api = new ImmichApi(instanceUrl, apiKey);
async login(instanceUrl: string, apiKey: string): Promise<ImmichApi> {
console.log('Logging in...');
const api = new ImmichApi(instanceUrl, apiKey);
// Check if server and api key are valid
const { data: userInfo } = await this.api.userApi.getMyUserInfo().catch((error) => {
const { data: userInfo } = await api.userApi.getMyUserInfo().catch((error) => {
throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`);
});
console.log(`Logged in as ${userInfo.email}`);
if (!fs.existsSync(this.configDir)) {
if (!existsSync(this.configDir)) {
// Create config folder if it doesn't exist
const created = await fs.promises.mkdir(this.configDir, { recursive: true });
const created = await mkdir(this.configDir, { recursive: true });
if (!created) {
throw new Error(`Failed to create config folder ${this.configDir}`);
}
}
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
await writeFile(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
console.log('Wrote auth info to ' + this.authPath);
return this.api;
return api;
}
public async logout(): Promise<void> {
if (fs.existsSync(this.authPath)) {
fs.unlinkSync(this.authPath);
async logout(): Promise<void> {
console.log('Logging out...');
if (existsSync(this.authPath)) {
await unlink(this.authPath);
console.log('Removed auth file ' + this.authPath);
}
}
private async ping(): Promise<void> {
const { data: pingResponse } = await this.api.serverInfoApi.pingServer().catch((error) => {
throw new Error(`Failed to connect to server ${this.api.apiConfiguration.instanceUrl}: ${error.message}`);
});
if (pingResponse.res !== 'pong') {
throw new Error(`Could not parse response. Is Immich listening on ${this.api.apiConfiguration.instanceUrl}?`);
}
console.log('Successfully logged out');
}
}

View file

@ -1,12 +1,12 @@
import pkg from '../package.json';
export interface ICLIVersion {
export interface ICliVersion {
major: number;
minor: number;
patch: number;
}
export class CLIVersion implements ICLIVersion {
export class CliVersion implements ICliVersion {
constructor(
public readonly major: number,
public readonly minor: number,
@ -22,16 +22,16 @@ export class CLIVersion implements ICLIVersion {
return { major, minor, patch };
}
static fromString(version: string): CLIVersion {
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);
return new CliVersion(major, minor, patch);
} else {
throw new Error(`Invalid version format: ${version}`);
}
}
}
export const cliVersion = CLIVersion.fromString(pkg.version);
export const cliVersion = CliVersion.fromString(pkg.version);

View file

@ -1,13 +1,31 @@
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
import fs from 'node:fs';
import path from 'node:path';
import { ImmichApi } from '../src/services/api.service';
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 CLI_BASE_OPTIONS = { config: TEST_CONFIG_DIR };
export const setup = async () => {
const api = new ImmichApi(process.env.IMMICH_INSTANCE_URL as string, '');
await api.authenticationApi.signUpAdmin({
signUpDto: { email: 'cli@immich.app', password: 'password', name: 'Administrator' },
});
const { data: admin } = await api.authenticationApi.login({
loginCredentialDto: { email: 'cli@immich.app', password: 'password' },
});
const { data: apiKey } = await api.keyApi.createApiKey(
{ aPIKeyCreateDto: { name: 'CLI Test' } },
{ headers: { Authorization: `Bearer ${admin.accessToken}` } },
);
api.setApiKey(apiKey.secret);
return api;
};
export const spyOnConsole = () => jest.spyOn(console, 'log').mockImplementation();

View file

@ -16,7 +16,6 @@
],
"coverageDirectory": "./coverage",
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>../server/e2e/client/$1",
"^@test(|/.*)$": "<rootDir>../server/test/$1",
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",

View file

@ -1,20 +1,15 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@api';
import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
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';
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { LoginCommand } from '../../src/commands/login';
describe(`login-key (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
let apiKey: string;
let instanceUrl: string;
spyOnConsole();
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.create();
if (!process.env.IMMICH_INSTANCE_URL) {
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
} else {
@ -30,19 +25,18 @@ describe(`login-key (e2e)`, () => {
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;
const api = await setup();
apiKey = api.apiKey;
});
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`),
await expect(new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
`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);
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
});
});

View file

@ -1,18 +1,12 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@api';
import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
import { ServerInfo } from 'src/commands/server-info';
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { ServerInfoCommand } from '../../src/commands/server-info.command';
describe(`server-info (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
const consoleSpy = spyOnConsole();
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.create();
});
afterAll(async () => {
@ -23,20 +17,18 @@ describe(`server-info (e2e)`, () => {
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;
const api = await setup();
process.env.IMMICH_API_KEY = api.apiKey;
});
it('should show server version', async () => {
await new ServerInfo(CLI_BASE_OPTIONS).run();
await new ServerInfoCommand(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'],
[expect.stringMatching(new RegExp('Server Version: \\d+.\\d+.\\d+'))],
[expect.stringMatching('Image Types: .*')],
[expect.stringMatching('Video Types: .*')],
['Statistics:\n Images: 0\n Videos: 0\n Total: 0'],
]);
});
});

View file

@ -1,18 +1,15 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@api';
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
import { Upload } from 'src/commands/upload';
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { UploadCommand } from '../../src/commands/upload.command';
import { ImmichApi } from '../../src/services/api.service';
describe(`upload (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
let api: ImmichApi;
spyOnConsole();
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.create();
});
afterAll(async () => {
@ -23,60 +20,58 @@ describe(`upload (e2e)`, () => {
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;
api = await setup();
process.env.IMMICH_API_KEY = api.apiKey;
});
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);
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const { data: assets } = await api.assetApi.getAllAssets({}, { headers: { 'x-api-key': api.apiKey } });
expect(assets.length).toBeGreaterThan(4);
});
it('should not create a new album', async () => {
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const { data: albums } = await api.albumApi.getAllAlbums({}, { headers: { 'x-api-key': api.apiKey } });
expect(albums.length).toEqual(0);
});
it('should create album from folder name', async () => {
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
album: true,
});
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
const { data: albums } = await api.albumApi.getAllAlbums({}, { headers: { 'x-api-key': api.apiKey } });
expect(albums.length).toEqual(1);
const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature');
});
it('should add existing assets to album', async () => {
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
});
// Upload again, but this time add to album
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
// upload again, but this time add to album
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
album: true,
});
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
const { data: albums } = await api.albumApi.getAllAlbums({}, { headers: { 'x-api-key': api.apiKey } });
expect(albums.length).toEqual(1);
const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature');
});
it('should upload to the specified album name', async () => {
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
albumName: 'testAlbum',
});
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
const { data: albums } = await api.albumApi.getAllAlbums({}, { headers: { 'x-api-key': api.apiKey } });
expect(albums.length).toEqual(1);
const testAlbum = albums[0];
expect(testAlbum.albumName).toEqual('testAlbum');

View file

@ -17,8 +17,6 @@
"rootDirs": ["src", "../server/src"],
"baseUrl": "./",
"paths": {
"@api": ["../server/e2e/client"],
"@api/*": ["../server/e2e/client/*"],
"@test": ["../server/test"],
"@test/*": ["../server/test/*"],
"@app/immich": ["../server/src/immich"],