Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -74,7 +73,6 @@
"!**/open-api/**"
],
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>/src/api/$1",
"^@test(|/.*)$": "<rootDir>../server/test/$1",
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
Expand Down
20 changes: 4 additions & 16 deletions cli/src/cli/base-command.ts → cli/src/commands/base-command.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,21 @@
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;
protected immichApi!: ImmichApi;
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto;

constructor(options: BaseOptionsDto) {
constructor(options: { config?: string }) {
if (!options.config) {
throw new Error('Config directory is required');
}
this.sessionService = new SessionService(options.config);
}

public async connect(): Promise<void> {
try {
this.immichApi = await this.sessionService.connect();
} catch (error) {
if (error instanceof LoginError) {
console.log(error.message);
exit(1);
} else {
throw error;
}
}
this.immichApi = await this.sessionService.connect();
}
}
7 changes: 7 additions & 0 deletions cli/src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
9 changes: 0 additions & 9 deletions cli/src/commands/login/key.ts

This file was deleted.

8 changes: 8 additions & 0 deletions cli/src/commands/logout.command.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
13 changes: 0 additions & 13 deletions cli/src/commands/logout.ts

This file was deleted.

17 changes: 17 additions & 0 deletions cli/src/commands/server-info.command.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
19 changes: 0 additions & 19 deletions cli/src/commands/server-info.ts

This file was deleted.

148 changes: 121 additions & 27 deletions cli/src/commands/upload.ts → cli/src/commands/upload.command.ts
Original file line number Diff line number Diff line change
@@ -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';

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`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally you get a .xmp after the original extension, so foo.jpg.xmp

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually not standardized! I've used a bunch of photo tools in my workflow and some use foo.xmp and some use foo.jpg.xmp. This is something on my backlog to fix across all of Immich, currently we only support one format.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh really? I hate this.

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;
}

export class Upload extends BaseCommand {
async delete(): Promise<void> {
return unlink(this.path);
}

public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = createReadStream(filePath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};

return await sha1(this.path);
}

private extractAlbumName(): string {
if (Os.platform() === 'win32') {
return this.path.split('\\').slice(-2)[0];
} else {
return this.path.split('/').slice(-2)[0];
}
}
}

export class UploadOptionsDto {
recursive? = false;
exclusionPatterns?: string[] = [];
dryRun? = false;
skipHash? = false;
delete? = false;
album? = false;
albumName? = '';
includeHidden? = false;
}

export class UploadCommand extends BaseCommand {
uploadLength!: number;

public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
Expand All @@ -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(
{
Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -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) {
Expand All @@ -172,14 +266,14 @@ export class Upload extends BaseCommand {
}

private async uploadAsset(data: FormData): Promise<AxiosResponse> {
const url = this.immichApi.apiConfiguration.instanceUrl + '/asset/upload';
const url = this.immichApi.instanceUrl + '/asset/upload';

const config: AxiosRequestConfig = {
method: 'post',
maxRedirects: 0,
url,
headers: {
'x-api-key': this.immichApi.apiConfiguration.apiKey,
'x-api-key': this.immichApi.apiKey,
...data.getHeaders(),
},
maxContentLength: Infinity,
Expand Down
9 changes: 0 additions & 9 deletions cli/src/cores/api-configuration.ts

This file was deleted.

3 changes: 0 additions & 3 deletions cli/src/cores/dto/base-options-dto.ts

This file was deleted.

6 changes: 0 additions & 6 deletions cli/src/cores/dto/crawl-options-dto.ts

This file was deleted.

10 changes: 0 additions & 10 deletions cli/src/cores/dto/upload-options-dto.ts

This file was deleted.

Loading
Loading