mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
refactor(cli): organize files, simplify types, use @immich/sdk (#6747)
This commit is contained in:
parent
64fad67713
commit
64da2c1698
32 changed files with 308 additions and 350 deletions
21
cli/src/commands/base-command.ts
Normal file
21
cli/src/commands/base-command.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk';
|
||||
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: { config?: string }) {
|
||||
if (!options.config) {
|
||||
throw new Error('Config directory is required');
|
||||
}
|
||||
this.sessionService = new SessionService(options.config);
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
this.immichApi = await this.sessionService.connect();
|
||||
}
|
||||
}
|
||||
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 byteSize from 'byte-size';
|
||||
import cliProgress from 'cli-progress';
|
||||
import FormData from 'form-data';
|
||||
import fs, { ReadStream, createReadStream } from 'node:fs';
|
||||
import { CrawlService } from '../services/crawl.service';
|
||||
import { BaseCommand } from './base-command';
|
||||
import { basename } from 'node:path';
|
||||
import { access, constants, stat, unlink } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import Os from 'os';
|
||||
|
||||
export class Upload extends BaseCommand {
|
||||
class Asset {
|
||||
readonly path: string;
|
||||
readonly deviceId!: string;
|
||||
|
||||
deviceAssetId?: string;
|
||||
fileCreatedAt?: string;
|
||||
fileModifiedAt?: string;
|
||||
sidecarPath?: string;
|
||||
fileSize!: number;
|
||||
albumName?: string;
|
||||
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
async prepare() {
|
||||
const stats = await stat(this.path);
|
||||
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
|
||||
this.fileCreatedAt = stats.mtime.toISOString();
|
||||
this.fileModifiedAt = stats.mtime.toISOString();
|
||||
this.fileSize = stats.size;
|
||||
this.albumName = this.extractAlbumName();
|
||||
}
|
||||
|
||||
async getUploadFormData(): Promise<FormData> {
|
||||
if (!this.deviceAssetId) throw new Error('Device asset id not set');
|
||||
if (!this.fileCreatedAt) throw new Error('File created at not set');
|
||||
if (!this.fileModifiedAt) throw new Error('File modified at not set');
|
||||
|
||||
// TODO: doesn't xmp replace the file extension? Will need investigation
|
||||
const sideCarPath = `${this.path}.xmp`;
|
||||
let sidecarData: ReadStream | undefined = undefined;
|
||||
try {
|
||||
await access(sideCarPath, constants.R_OK);
|
||||
sidecarData = createReadStream(sideCarPath);
|
||||
} catch (error) {}
|
||||
|
||||
const data: any = {
|
||||
assetData: createReadStream(this.path),
|
||||
deviceAssetId: this.deviceAssetId,
|
||||
deviceId: 'CLI',
|
||||
fileCreatedAt: this.fileCreatedAt,
|
||||
fileModifiedAt: this.fileModifiedAt,
|
||||
isFavorite: String(false),
|
||||
};
|
||||
const formData = new FormData();
|
||||
|
||||
for (const prop in data) {
|
||||
formData.append(prop, data[prop]);
|
||||
}
|
||||
|
||||
if (sidecarData) {
|
||||
formData.append('sidecarData', sidecarData);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
return unlink(this.path);
|
||||
}
|
||||
|
||||
public async hash(): Promise<string> {
|
||||
const sha1 = (filePath: string) => {
|
||||
const hash = createHash('sha1');
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const rs = createReadStream(filePath);
|
||||
rs.on('error', reject);
|
||||
rs.on('data', (chunk) => hash.update(chunk));
|
||||
rs.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
};
|
||||
|
||||
return await sha1(this.path);
|
||||
}
|
||||
|
||||
private extractAlbumName(): string {
|
||||
if (Os.platform() === 'win32') {
|
||||
return this.path.split('\\').slice(-2)[0];
|
||||
} else {
|
||||
return this.path.split('/').slice(-2)[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UploadOptionsDto {
|
||||
recursive? = false;
|
||||
exclusionPatterns?: string[] = [];
|
||||
dryRun? = false;
|
||||
skipHash? = false;
|
||||
delete? = false;
|
||||
album? = false;
|
||||
albumName? = '';
|
||||
includeHidden? = false;
|
||||
}
|
||||
|
||||
export class UploadCommand extends BaseCommand {
|
||||
uploadLength!: number;
|
||||
|
||||
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
|
||||
|
|
@ -18,32 +115,29 @@ export class Upload extends BaseCommand {
|
|||
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
|
||||
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
|
||||
|
||||
const crawlOptions = new CrawlOptionsDto();
|
||||
crawlOptions.pathsToCrawl = paths;
|
||||
crawlOptions.recursive = options.recursive;
|
||||
crawlOptions.exclusionPatterns = options.exclusionPatterns;
|
||||
crawlOptions.includeHidden = options.includeHidden;
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
const inputFiles: string[] = [];
|
||||
for (const pathArgument of paths) {
|
||||
const fileStat = await fs.promises.lstat(pathArgument);
|
||||
|
||||
if (fileStat.isFile()) {
|
||||
files.push(pathArgument);
|
||||
inputFiles.push(pathArgument);
|
||||
}
|
||||
}
|
||||
|
||||
const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
|
||||
const files: string[] = await crawlService.crawl({
|
||||
pathsToCrawl: paths,
|
||||
recursive: options.recursive,
|
||||
exclusionPatterns: options.exclusionPatterns,
|
||||
includeHidden: options.includeHidden,
|
||||
});
|
||||
|
||||
crawledFiles.push(...files);
|
||||
files.push(...inputFiles);
|
||||
|
||||
if (crawledFiles.length === 0) {
|
||||
if (files.length === 0) {
|
||||
console.log('No assets found, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
const assetsToUpload = crawledFiles.map((path) => new Asset(path));
|
||||
const assetsToUpload = files.map((path) => new Asset(path));
|
||||
|
||||
const uploadProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
|
|
@ -104,7 +198,7 @@ export class Upload extends BaseCommand {
|
|||
if (!skipAsset) {
|
||||
if (!options.dryRun) {
|
||||
if (!skipUpload) {
|
||||
const formData = asset.getUploadFormData();
|
||||
const formData = await asset.getUploadFormData();
|
||||
const res = await this.uploadAsset(formData);
|
||||
existingAssetId = res.data.id;
|
||||
uploadCounter++;
|
||||
|
|
@ -157,7 +251,7 @@ export class Upload extends BaseCommand {
|
|||
} else {
|
||||
console.log('Deleting assets that have been uploaded...');
|
||||
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
|
||||
deletionProgress.start(crawledFiles.length, 0);
|
||||
deletionProgress.start(files.length, 0);
|
||||
|
||||
for (const asset of assetsToUpload) {
|
||||
if (!options.dryRun) {
|
||||
|
|
@ -172,14 +266,14 @@ export class Upload extends BaseCommand {
|
|||
}
|
||||
|
||||
private async uploadAsset(data: FormData): Promise<AxiosResponse> {
|
||||
const url = this.immichApi.apiConfiguration.instanceUrl + '/asset/upload';
|
||||
const url = this.immichApi.instanceUrl + '/asset/upload';
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'post',
|
||||
maxRedirects: 0,
|
||||
url,
|
||||
headers: {
|
||||
'x-api-key': this.immichApi.apiConfiguration.apiKey,
|
||||
'x-api-key': this.immichApi.apiKey,
|
||||
...data.getHeaders(),
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
Loading…
Add table
Add a link
Reference in a new issue