diff --git a/cli/package-lock.json b/cli/package-lock.json index b066783e6d..5a622f4402 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -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", diff --git a/cli/package.json b/cli/package.json index 5dfba6d758..8d7f791df8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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(|/.*)$": "/src/api/$1", "^@test(|/.*)$": "../server/test/$1", "^@app/immich(|/.*)$": "../server/src/immich/$1", "^@app/infra(|/.*)$": "../server/src/infra/$1", diff --git a/cli/src/cli/base-command.ts b/cli/src/commands/base-command.ts similarity index 53% rename from cli/src/cli/base-command.ts rename to cli/src/commands/base-command.ts index c42519ac8f..9218da6026 100644 --- a/cli/src/cli/base-command.ts +++ b/cli/src/commands/base-command.ts @@ -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 { - 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(); } } diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts new file mode 100644 index 0000000000..863c287016 --- /dev/null +++ b/cli/src/commands/login.ts @@ -0,0 +1,7 @@ +import { BaseCommand } from './base-command'; + +export class LoginCommand extends BaseCommand { + public async run(instanceUrl: string, apiKey: string): Promise { + await this.sessionService.login(instanceUrl, apiKey); + } +} diff --git a/cli/src/commands/login/key.ts b/cli/src/commands/login/key.ts deleted file mode 100644 index 7867996af8..0000000000 --- a/cli/src/commands/login/key.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BaseCommand } from '../../cli/base-command'; - -export class LoginKey extends BaseCommand { - public async run(instanceUrl: string, apiKey: string): Promise { - console.log('Executing API key auth flow...'); - - await this.sessionService.keyLogin(instanceUrl, apiKey); - } -} diff --git a/cli/src/commands/logout.command.ts b/cli/src/commands/logout.command.ts new file mode 100644 index 0000000000..736f774247 --- /dev/null +++ b/cli/src/commands/logout.command.ts @@ -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 { + await this.sessionService.logout(); + } +} diff --git a/cli/src/commands/logout.ts b/cli/src/commands/logout.ts deleted file mode 100644 index 30568b7fbe..0000000000 --- a/cli/src/commands/logout.ts +++ /dev/null @@ -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 { - console.log('Executing logout flow...'); - - await this.sessionService.logout(); - - console.log('Successfully logged out'); - } -} diff --git a/cli/src/commands/server-info.command.ts b/cli/src/commands/server-info.command.ts new file mode 100644 index 0000000000..c9d454d59e --- /dev/null +++ b/cli/src/commands/server-info.command.ts @@ -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}`, + ); + } +} diff --git a/cli/src/commands/server-info.ts b/cli/src/commands/server-info.ts deleted file mode 100644 index 366ba44bfe..0000000000 --- a/cli/src/commands/server-info.ts +++ /dev/null @@ -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}`); - } -} diff --git a/cli/src/commands/upload.ts b/cli/src/commands/upload.command.ts similarity index 57% rename from cli/src/commands/upload.ts rename to cli/src/commands/upload.command.ts index e647d38153..17fc6541bc 100644 --- a/cli/src/commands/upload.ts +++ b/cli/src/commands/upload.command.ts @@ -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 { + 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 { + return unlink(this.path); + } + + public async hash(): Promise { + const sha1 = (filePath: string) => { + const hash = createHash('sha1'); + return new Promise((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 { @@ -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 { - 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, diff --git a/cli/src/cores/api-configuration.ts b/cli/src/cores/api-configuration.ts deleted file mode 100644 index 5eeb166a64..0000000000 --- a/cli/src/cores/api-configuration.ts +++ /dev/null @@ -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; - } -} diff --git a/cli/src/cores/dto/base-options-dto.ts b/cli/src/cores/dto/base-options-dto.ts deleted file mode 100644 index 56e351731c..0000000000 --- a/cli/src/cores/dto/base-options-dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class BaseOptionsDto { - config?: string; -} diff --git a/cli/src/cores/dto/crawl-options-dto.ts b/cli/src/cores/dto/crawl-options-dto.ts deleted file mode 100644 index bf80b46391..0000000000 --- a/cli/src/cores/dto/crawl-options-dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class CrawlOptionsDto { - pathsToCrawl!: string[]; - recursive? = false; - includeHidden? = false; - exclusionPatterns?: string[]; -} diff --git a/cli/src/cores/dto/upload-options-dto.ts b/cli/src/cores/dto/upload-options-dto.ts deleted file mode 100644 index 943f321a24..0000000000 --- a/cli/src/cores/dto/upload-options-dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class UploadOptionsDto { - recursive? = false; - exclusionPatterns?: string[] = []; - dryRun? = false; - skipHash? = false; - delete? = false; - album? = false; - albumName? = ''; - includeHidden? = false; -} diff --git a/cli/src/cores/errors/login-error.ts b/cli/src/cores/errors/login-error.ts deleted file mode 100644 index e00b997f1f..0000000000 --- a/cli/src/cores/errors/login-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class LoginError extends Error { - constructor(message: string) { - super(message); - - this.name = this.constructor.name; - - Error.captureStackTrace(this, this.constructor); - } -} diff --git a/cli/src/cores/index.ts b/cli/src/cores/index.ts deleted file mode 100644 index e9644dae47..0000000000 --- a/cli/src/cores/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './models'; diff --git a/cli/src/cores/models/asset.ts b/cli/src/cores/models/asset.ts deleted file mode 100644 index a3e9b8f8e7..0000000000 --- a/cli/src/cores/models/asset.ts +++ /dev/null @@ -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 { - return fs.promises.unlink(this.path); - } - - public async hash(): Promise { - const sha1 = (filePath: string) => { - const hash = crypto.createHash('sha1'); - return new Promise((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]; - } - } -} diff --git a/cli/src/cores/models/index.ts b/cli/src/cores/models/index.ts deleted file mode 100644 index ea2719dd83..0000000000 --- a/cli/src/cores/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './asset'; diff --git a/cli/src/index.ts b/cli/src/index.ts index 1c0d283663..8369bff934 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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); diff --git a/cli/src/api/client.ts b/cli/src/services/api.service.ts similarity index 83% rename from cli/src/api/client.ts rename to cli/src/services/api.service.ts index 7577122846..8110d92d29 100644 --- a/cli/src/api/client.ts +++ b/cli/src/services/api.service.ts @@ -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; + } } diff --git a/cli/src/services/crawl.service.spec.ts b/cli/src/services/crawl.service.spec.ts index 447b6edc9d..003935944a 100644 --- a/cli/src/services/crawl.service.spec.ts +++ b/cli/src/services/crawl.service.spec.ts @@ -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; } diff --git a/cli/src/services/crawl.service.ts b/cli/src/services/crawl.service.ts index ff0f5e3dfc..bfe94a8992 100644 --- a/cli/src/services/crawl.service.ts +++ b/cli/src/services/crawl.service.ts @@ -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 { - const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; + async crawl(options: CrawlOptions): Promise { + 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 + '/**/'; } diff --git a/cli/src/services/index.ts b/cli/src/services/index.ts deleted file mode 100644 index 9f0d5f958a..0000000000 --- a/cli/src/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './crawl.service'; diff --git a/cli/src/services/session.service.spec.ts b/cli/src/services/session.service.spec.ts index b12c4ad051..47c7d5d749 100644 --- a/cli/src/services/session.service.spec.ts +++ b/cli/src/services/session.service.spec.ts @@ -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'], + ]); }); }); diff --git a/cli/src/services/session.service.ts b/cli/src/services/session.service.ts index 95cad8476f..ee49a7074a 100644 --- a/cli/src/services/session.service.ts +++ b/cli/src/services/session.service.ts @@ -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 { + async connect(): Promise { 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 { - this.api = new ImmichApi(instanceUrl, apiKey); + async login(instanceUrl: string, apiKey: string): Promise { + 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 { - if (fs.existsSync(this.authPath)) { - fs.unlinkSync(this.authPath); + async logout(): Promise { + console.log('Logging out...'); + + if (existsSync(this.authPath)) { + await unlink(this.authPath); console.log('Removed auth file ' + this.authPath); } - } - private async ping(): Promise { - 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'); } } diff --git a/cli/src/constants.ts b/cli/src/version.ts similarity index 73% rename from cli/src/constants.ts rename to cli/src/version.ts index 63cb116bbc..e33764693a 100644 --- a/cli/src/constants.ts +++ b/cli/src/version.ts @@ -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)?(?\d+)\.(?\d+)\.(?\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); diff --git a/cli/test/cli-test-utils.ts b/cli/test/cli-test-utils.ts index f2f6ee1fe5..f5e3d50932 100644 --- a/cli/test/cli-test-utils.ts +++ b/cli/test/cli-test-utils.ts @@ -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(); diff --git a/cli/test/e2e/jest-e2e.json b/cli/test/e2e/jest-e2e.json index aee8fb458d..6146c09876 100644 --- a/cli/test/e2e/jest-e2e.json +++ b/cli/test/e2e/jest-e2e.json @@ -16,7 +16,6 @@ ], "coverageDirectory": "./coverage", "moduleNameMapper": { - "^@api(|/.*)$": "../server/e2e/client/$1", "^@test(|/.*)$": "../server/test/$1", "^@app/immich(|/.*)$": "../server/src/immich/$1", "^@app/infra(|/.*)$": "../server/src/infra/$1", diff --git a/cli/test/e2e/login-key.e2e-spec.ts b/cli/test/e2e/login-key.e2e-spec.ts index 30e12616f6..1f28a27698 100644 --- a/cli/test/e2e/login-key.e2e-spec.ts +++ b/cli/test/e2e/login-key.e2e-spec.ts @@ -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); }); }); diff --git a/cli/test/e2e/server-info.e2e-spec.ts b/cli/test/e2e/server-info.e2e-spec.ts index ed2e8d0483..b640bb5fb1 100644 --- a/cli/test/e2e/server-info.e2e-spec.ts +++ b/cli/test/e2e/server-info.e2e-spec.ts @@ -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'], ]); }); }); diff --git a/cli/test/e2e/upload.e2e-spec.ts b/cli/test/e2e/upload.e2e-spec.ts index aef6a6f7f1..c72f4fd22e 100644 --- a/cli/test/e2e/upload.e2e-spec.ts +++ b/cli/test/e2e/upload.e2e-spec.ts @@ -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'); diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 085c7596e4..b570c1157b 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -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"],