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", "commander": "^11.0.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"glob": "^10.3.1", "glob": "^10.3.1",
"graceful-fs": "^4.2.11",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
"bin": { "bin": {
@ -4184,7 +4183,8 @@
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "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": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
@ -10436,7 +10436,8 @@
"graceful-fs": { "graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "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": { "graphemer": {
"version": "1.4.0", "version": "1.4.0",

View File

@ -19,7 +19,6 @@
"commander": "^11.0.0", "commander": "^11.0.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"glob": "^10.3.1", "glob": "^10.3.1",
"graceful-fs": "^4.2.11",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
"devDependencies": { "devDependencies": {
@ -74,7 +73,6 @@
"!**/open-api/**" "!**/open-api/**"
], ],
"moduleNameMapper": { "moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>/src/api/$1",
"^@test(|/.*)$": "<rootDir>../server/test/$1", "^@test(|/.*)$": "<rootDir>../server/test/$1",
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1", "^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$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 { 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 { export abstract class BaseCommand {
protected sessionService!: SessionService; protected sessionService!: SessionService;
@ -11,7 +8,7 @@ export abstract class BaseCommand {
protected user!: UserResponseDto; protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto; protected serverVersion!: ServerVersionResponseDto;
constructor(options: BaseOptionsDto) { constructor(options: { config?: string }) {
if (!options.config) { if (!options.config) {
throw new Error('Config directory is required'); throw new Error('Config directory is required');
} }
@ -19,15 +16,6 @@ export abstract class BaseCommand {
} }
public async connect(): Promise<void> { public async connect(): Promise<void> {
try {
this.immichApi = await this.sessionService.connect(); this.immichApi = await this.sessionService.connect();
} catch (error) {
if (error instanceof LoginError) {
console.log(error.message);
exit(1);
} else {
throw error;
}
}
} }
} }

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 axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import byteSize from 'byte-size';
import cliProgress from 'cli-progress';
import FormData from 'form-data'; 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; uploadLength!: number;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> { 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 formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video); const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
const crawlOptions = new CrawlOptionsDto(); const inputFiles: string[] = [];
crawlOptions.pathsToCrawl = paths;
crawlOptions.recursive = options.recursive;
crawlOptions.exclusionPatterns = options.exclusionPatterns;
crawlOptions.includeHidden = options.includeHidden;
const files: string[] = [];
for (const pathArgument of paths) { for (const pathArgument of paths) {
const fileStat = await fs.promises.lstat(pathArgument); const fileStat = await fs.promises.lstat(pathArgument);
if (fileStat.isFile()) { 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'); console.log('No assets found, exiting');
return; return;
} }
const assetsToUpload = crawledFiles.map((path) => new Asset(path)); const assetsToUpload = files.map((path) => new Asset(path));
const uploadProgress = new cliProgress.SingleBar( const uploadProgress = new cliProgress.SingleBar(
{ {
@ -104,7 +198,7 @@ export class Upload extends BaseCommand {
if (!skipAsset) { if (!skipAsset) {
if (!options.dryRun) { if (!options.dryRun) {
if (!skipUpload) { if (!skipUpload) {
const formData = asset.getUploadFormData(); const formData = await asset.getUploadFormData();
const res = await this.uploadAsset(formData); const res = await this.uploadAsset(formData);
existingAssetId = res.data.id; existingAssetId = res.data.id;
uploadCounter++; uploadCounter++;
@ -157,7 +251,7 @@ export class Upload extends BaseCommand {
} else { } else {
console.log('Deleting assets that have been uploaded...'); console.log('Deleting assets that have been uploaded...');
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic); const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
deletionProgress.start(crawledFiles.length, 0); deletionProgress.start(files.length, 0);
for (const asset of assetsToUpload) { for (const asset of assetsToUpload) {
if (!options.dryRun) { if (!options.dryRun) {
@ -172,14 +266,14 @@ export class Upload extends BaseCommand {
} }
private async uploadAsset(data: FormData): Promise<AxiosResponse> { 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 = { const config: AxiosRequestConfig = {
method: 'post', method: 'post',
maxRedirects: 0, maxRedirects: 0,
url, url,
headers: { headers: {
'x-api-key': this.immichApi.apiConfiguration.apiKey, 'x-api-key': this.immichApi.apiKey,
...data.getHeaders(), ...data.getHeaders(),
}, },
maxContentLength: Infinity, 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 #! /usr/bin/env node
import { Command, 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';
import path from 'node:path'; import path from 'node:path';
import os from 'os'; 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 userHomeDir = os.homedir();
const configDir = path.join(userHomeDir, '.config/immich/'); const configDir = path.join(userHomeDir, '.config/immich/');
@ -46,14 +44,14 @@ program
.argument('[paths...]', 'One or more paths to assets to be uploaded') .argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => { .action(async (paths, options) => {
options.exclusionPatterns = options.ignore; options.exclusionPatterns = options.ignore;
await new Upload(program.opts()).run(paths, options); await new UploadCommand(program.opts()).run(paths, options);
}); });
program program
.command('server-info') .command('server-info')
.description('Display server information') .description('Display server information')
.action(async () => { .action(async () => {
await new ServerInfo(program.opts()).run(); await new ServerInfoCommand(program.opts()).run();
}); });
program program
@ -62,14 +60,14 @@ program
.argument('[instanceUrl]') .argument('[instanceUrl]')
.argument('[apiKey]') .argument('[apiKey]')
.action(async (paths, options) => { .action(async (paths, options) => {
await new LoginKey(program.opts()).run(paths, options); await new LoginCommand(program.opts()).run(paths, options);
}); });
program program
.command('logout') .command('logout')
.description('Remove stored credentials') .description('Remove stored credentials')
.action(async () => { .action(async () => {
await new Logout(program.opts()).run(); await new LogoutCommand(program.opts()).run();
}); });
program.parse(process.argv); program.parse(process.argv);

View File

@ -10,7 +10,6 @@ import {
SystemConfigApi, SystemConfigApi,
UserApi, UserApi,
} from '@immich/sdk'; } from '@immich/sdk';
import { ApiConfiguration } from '../cores/api-configuration';
import FormData from 'form-data'; import FormData from 'form-data';
export class ImmichApi { export class ImmichApi {
@ -25,10 +24,11 @@ export class ImmichApi {
public systemConfigApi: SystemConfigApi; public systemConfigApi: SystemConfigApi;
private readonly config; private readonly config;
public readonly apiConfiguration: ApiConfiguration;
constructor(instanceUrl: string, apiKey: string) { constructor(
this.apiConfiguration = new ApiConfiguration(instanceUrl, apiKey); public instanceUrl: string,
public apiKey: string,
) {
this.config = new Configuration({ this.config = new Configuration({
basePath: instanceUrl, basePath: instanceUrl,
baseOptions: { baseOptions: {
@ -49,4 +49,9 @@ export class ImmichApi {
this.keyApi = new APIKeyApi(this.config); this.keyApi = new APIKeyApi(this.config);
this.systemConfigApi = new SystemConfigApi(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 mockfs from 'mock-fs';
import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto'; import { CrawlService, CrawlOptions } from './crawl.service';
import { CrawlService } from '.';
interface Test { interface Test {
test: string; test: string;
options: CrawlOptionsDto; options: CrawlOptions;
files: Record<string, boolean>; files: Record<string, boolean>;
} }

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import { SessionService } from './session.service'; import { SessionService } from './session.service';
import fs from 'node:fs'; import fs from 'node:fs';
import yaml from 'yaml'; import yaml from 'yaml';
import { LoginError } from '../cores/errors/login-error';
import { import {
TEST_AUTH_FILE, TEST_AUTH_FILE,
TEST_CONFIG_DIR, TEST_CONFIG_DIR,
@ -70,7 +69,6 @@ describe('SessionService', () => {
}), }),
); );
await sessionService.connect().catch((error) => { await sessionService.connect().catch((error) => {
expect(error).toBeInstanceOf(LoginError);
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`); 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( await expect(sessionService.connect()).rejects.toThrow(`API key missing in auth config file ${TEST_AUTH_FILE}`);
new LoginError(`API key missing in auth config file ${TEST_AUTH_FILE}`),
);
}); });
it('should create auth file when logged in', async () => { 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 data: string = await readTestAuthFile();
const authConfig = yaml.parse(data); const authConfig = yaml.parse(data);
@ -109,6 +105,10 @@ describe('SessionService', () => {
expect(error.message).toContain('ENOENT'); 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 { existsSync } from 'fs';
import yaml from 'yaml'; import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { ImmichApi } from '../api/client'; import yaml from 'yaml';
import { LoginError } from '../cores/errors/login-error'; 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 { export class SessionService {
readonly configDir!: string; readonly configDir!: string;
readonly authPath!: string; readonly authPath!: string;
private api!: ImmichApi;
constructor(configDir: string) { constructor(configDir: string) {
this.configDir = configDir; this.configDir = configDir;
this.authPath = path.join(configDir, '/auth.yml'); this.authPath = path.join(configDir, '/auth.yml');
} }
public async connect(): Promise<ImmichApi> { async connect(): Promise<ImmichApi> {
let instanceUrl = process.env.IMMICH_INSTANCE_URL; let instanceUrl = process.env.IMMICH_INSTANCE_URL;
let apiKey = process.env.IMMICH_API_KEY; let apiKey = process.env.IMMICH_API_KEY;
if (!instanceUrl || !apiKey) { 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') { if (error.code === 'ENOENT') {
throw new LoginError('No auth file exist. Please login first'); 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); const parsedConfig = yaml.parse(data);
instanceUrl = parsedConfig.instanceUrl; 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}?`);
} }
public async keyLogin(instanceUrl: string, apiKey: string): Promise<ImmichApi> { return api;
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 // 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}`); throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`);
}); });
console.log(`Logged in as ${userInfo.email}`); console.log(`Logged in as ${userInfo.email}`);
if (!fs.existsSync(this.configDir)) { if (!existsSync(this.configDir)) {
// Create config folder if it doesn't exist // 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) { if (!created) {
throw new Error(`Failed to create config folder ${this.configDir}`); 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); console.log('Wrote auth info to ' + this.authPath);
return this.api;
return api;
} }
public async logout(): Promise<void> { async logout(): Promise<void> {
if (fs.existsSync(this.authPath)) { console.log('Logging out...');
fs.unlinkSync(this.authPath);
if (existsSync(this.authPath)) {
await unlink(this.authPath);
console.log('Removed auth file ' + this.authPath); console.log('Removed auth file ' + this.authPath);
} }
}
private async ping(): Promise<void> { console.log('Successfully logged out');
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}?`);
}
} }
} }

View File

@ -1,12 +1,12 @@
import pkg from '../package.json'; import pkg from '../package.json';
export interface ICLIVersion { export interface ICliVersion {
major: number; major: number;
minor: number; minor: number;
patch: number; patch: number;
} }
export class CLIVersion implements ICLIVersion { export class CliVersion implements ICliVersion {
constructor( constructor(
public readonly major: number, public readonly major: number,
public readonly minor: number, public readonly minor: number,
@ -22,16 +22,16 @@ export class CLIVersion implements ICLIVersion {
return { major, minor, patch }; 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 regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
const matchResult = version.match(regex); const matchResult = version.match(regex);
if (matchResult) { if (matchResult) {
const [, major, minor, patch] = matchResult.map(Number); const [, major, minor, patch] = matchResult.map(Number);
return new CLIVersion(major, minor, patch); return new CliVersion(major, minor, patch);
} else { } else {
throw new Error(`Invalid version format: ${version}`); 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { ImmichApi } from '../src/services/api.service';
export const TEST_CONFIG_DIR = '/tmp/immich/'; export const TEST_CONFIG_DIR = '/tmp/immich/';
export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml'); 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_INSTANCE_URL = 'https://test/api';
export const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg'; 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(); export const spyOnConsole = () => jest.spyOn(console, 'log').mockImplementation();

View File

@ -16,7 +16,6 @@
], ],
"coverageDirectory": "./coverage", "coverageDirectory": "./coverage",
"moduleNameMapper": { "moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>../server/e2e/client/$1",
"^@test(|/.*)$": "<rootDir>../server/test/$1", "^@test(|/.*)$": "<rootDir>../server/test/$1",
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1", "^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$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 { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk'; import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { LoginKey } from 'src/commands/login/key'; import { LoginCommand } from '../../src/commands/login';
import { LoginError } from 'src/cores/errors/login-error';
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
describe(`login-key (e2e)`, () => { describe(`login-key (e2e)`, () => {
let server: any; let apiKey: string;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
let instanceUrl: string; let instanceUrl: string;
spyOnConsole(); spyOnConsole();
beforeAll(async () => { beforeAll(async () => {
server = (await testApp.create()).getHttpServer(); await testApp.create();
if (!process.env.IMMICH_INSTANCE_URL) { if (!process.env.IMMICH_INSTANCE_URL) {
throw new Error('IMMICH_INSTANCE_URL environment variable not set'); throw new Error('IMMICH_INSTANCE_URL environment variable not set');
} else { } else {
@ -30,19 +25,18 @@ describe(`login-key (e2e)`, () => {
beforeEach(async () => { beforeEach(async () => {
await testApp.reset(); await testApp.reset();
await restoreTempFolder(); await restoreTempFolder();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server); const api = await setup();
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken); apiKey = api.apiKey;
process.env.IMMICH_API_KEY = apiKey.secret;
}); });
it('should error when providing an invalid API key', async () => { it('should error when providing an invalid API key', async () => {
await expect(async () => await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow( await expect(new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
new LoginError(`Failed to connect to server ${instanceUrl}: Request failed with status code 401`), `Failed to connect to server ${instanceUrl}: Request failed with status code 401`,
); );
}); });
it('should log in when providing the correct API key', async () => { 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 { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk'; import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { ServerInfo } from 'src/commands/server-info'; import { ServerInfoCommand } from '../../src/commands/server-info.command';
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
describe(`server-info (e2e)`, () => { describe(`server-info (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
const consoleSpy = spyOnConsole(); const consoleSpy = spyOnConsole();
beforeAll(async () => { beforeAll(async () => {
server = (await testApp.create()).getHttpServer(); await testApp.create();
}); });
afterAll(async () => { afterAll(async () => {
@ -23,20 +17,18 @@ describe(`server-info (e2e)`, () => {
beforeEach(async () => { beforeEach(async () => {
await testApp.reset(); await testApp.reset();
await restoreTempFolder(); await restoreTempFolder();
await api.authApi.adminSignUp(server); const api = await setup();
admin = await api.authApi.adminLogin(server); process.env.IMMICH_API_KEY = api.apiKey;
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
process.env.IMMICH_API_KEY = apiKey.secret;
}); });
it('should show server version', async () => { 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(consoleSpy.mock.calls).toEqual([
[expect.stringMatching(new RegExp('Server is running version \\d+.\\d+.\\d+'))], [expect.stringMatching(new RegExp('Server Version: \\d+.\\d+.\\d+'))],
[expect.stringMatching('Supported image types: .*')], [expect.stringMatching('Image Types: .*')],
[expect.stringMatching('Supported video types: .*')], [expect.stringMatching('Video Types: .*')],
['Images: 0, Videos: 0, Total: 0'], ['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 { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk'; import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { Upload } from 'src/commands/upload'; import { UploadCommand } from '../../src/commands/upload.command';
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils'; import { ImmichApi } from '../../src/services/api.service';
describe(`upload (e2e)`, () => { describe(`upload (e2e)`, () => {
let server: any; let api: ImmichApi;
let admin: LoginResponseDto;
let apiKey: APIKeyCreateResponseDto;
spyOnConsole(); spyOnConsole();
beforeAll(async () => { beforeAll(async () => {
server = (await testApp.create()).getHttpServer(); await testApp.create();
}); });
afterAll(async () => { afterAll(async () => {
@ -23,60 +20,58 @@ describe(`upload (e2e)`, () => {
beforeEach(async () => { beforeEach(async () => {
await testApp.reset(); await testApp.reset();
await restoreTempFolder(); await restoreTempFolder();
await api.authApi.adminSignUp(server); api = await setup();
admin = await api.authApi.adminLogin(server); process.env.IMMICH_API_KEY = api.apiKey;
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
process.env.IMMICH_API_KEY = apiKey.secret;
}); });
it('should upload a folder recursively', async () => { it('should upload a folder recursively', async () => {
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true }); await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const assets = await api.assetApi.getAllAssets(server, admin.accessToken); const { data: assets } = await api.assetApi.getAllAssets({}, { headers: { 'x-api-key': api.apiKey } });
expect(assets.length).toBeGreaterThan(4); expect(assets.length).toBeGreaterThan(4);
}); });
it('should not create a new album', async () => { it('should not create a new album', async () => {
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true }); await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: 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(0); expect(albums.length).toEqual(0);
}); });
it('should create album from folder name', async () => { 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, recursive: true,
album: 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); expect(albums.length).toEqual(1);
const natureAlbum = albums[0]; const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature'); expect(natureAlbum.albumName).toEqual('nature');
}); });
it('should add existing assets to album', async () => { 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, recursive: true,
}); });
// Upload again, but this time add to album // upload again, but this time add to album
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, recursive: true,
album: 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); expect(albums.length).toEqual(1);
const natureAlbum = albums[0]; const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature'); expect(natureAlbum.albumName).toEqual('nature');
}); });
it('should upload to the specified album name', async () => { 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, recursive: true,
albumName: 'testAlbum', 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); expect(albums.length).toEqual(1);
const testAlbum = albums[0]; const testAlbum = albums[0];
expect(testAlbum.albumName).toEqual('testAlbum'); expect(testAlbum.albumName).toEqual('testAlbum');

View File

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