mirror of
https://github.com/immich-app/immich.git
synced 2025-06-23 15:30:51 -04:00
refactor(cli): organize files, simplify types, use @immich/sdk (#6747)
This commit is contained in:
parent
64fad67713
commit
64da2c1698
7
cli/package-lock.json
generated
7
cli/package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
7
cli/src/commands/login.ts
Normal file
7
cli/src/commands/login.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
8
cli/src/commands/logout.command.ts
Normal file
8
cli/src/commands/logout.command.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
17
cli/src/commands/server-info.command.ts
Normal file
17
cli/src/commands/server-info.command.ts
Normal 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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
export class BaseOptionsDto {
|
|
||||||
config?: string;
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
export class CrawlOptionsDto {
|
|
||||||
pathsToCrawl!: string[];
|
|
||||||
recursive? = false;
|
|
||||||
includeHidden? = false;
|
|
||||||
exclusionPatterns?: string[];
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
export class UploadOptionsDto {
|
|
||||||
recursive? = false;
|
|
||||||
exclusionPatterns?: string[] = [];
|
|
||||||
dryRun? = false;
|
|
||||||
skipHash? = false;
|
|
||||||
delete? = false;
|
|
||||||
album? = false;
|
|
||||||
albumName? = '';
|
|
||||||
includeHidden? = false;
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
export class LoginError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
|
|
||||||
this.name = this.constructor.name;
|
|
||||||
|
|
||||||
Error.captureStackTrace(this, this.constructor);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export * from './models';
|
|
@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export * from './asset';
|
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 + '/**/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export * from './crawl.service';
|
|
@ -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'],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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}?`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
@ -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();
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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');
|
||||||
|
@ -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"],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user