1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-30 09:47:31 +02:00

feat(cli): refactor and add album support (#4434)

* Allow building and installing cli

* feat: add format fix

* docs: remove cli folder

* feat: use immich scoped package

* feat: rewrite cli readme

* docs: add info on running without building

* cleanup

* chore: remove import functionality from cli

* feat: add logout to cli

* docs: add todo for file format from server

* docs: add compilation step to cli

* fix: success message spacing

* feat: can create albums

* fix: add check step to cli

* fix: typos

* feat: pull file formats from server

* chore: use crawl service from server

* chore: fix lint

* docs: add cli documentation

* chore: rename ignore pattern

* chore: add version number to cli

* feat: use sdk

* fix: cleanup

* feat: album name on windows

* chore: remove skipped asset field

* feat: add more info to server-info command

* chore: cleanup

* chore: remove unneeded packages

* chore: fix docs links

* feat: add cli v2 milestone

* fix: set correct cli date

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Jonathan Jogenfors 2023-11-19 23:16:24 +01:00 committed by GitHub
parent 88b5f5b500
commit 7e38e7c949
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 637 additions and 800 deletions

View File

@ -97,6 +97,10 @@ jobs:
run: npm run format run: npm run format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: npm run test:cov run: npm run test:cov
if: ${{ !cancelled() }} if: ${{ !cancelled() }}

View File

@ -1,24 +1,28 @@
A command-line interface for interfacing with Immich A command-line interface for interfacing with the self-hosted photo manager [Immich](https://immich.app/).
# Getting started # Installing
$ ts-node cli/src To use the cli, run
To start using the CLI, you need to login with an API key first: $ npm install @immich/cli
$ ts-node cli/src login-key https://your-immich-instance/api your-api-key # Usage
NOTE: This will store your api key under ~/.config/immich/auth.yml To use the CLI, you need to login with an API key first:
Next, you can run commands: $ immich login-key https://your-immich-instance/api your-api-key
$ ts-node cli/src server-info NOTE: This will store your api key in cleartext under ~/.config/immich/auth.yml
Next, you can run commands, for example:
$ immich server-info
When you're done, log out to remove the credentials from your filesystem When you're done, log out to remove the credentials from your filesystem
$ ts-node cli/src logout $ immich logout
# Usage # Commands
``` ```
Usage: immich [options] [command] Usage: immich [options] [command]
@ -30,9 +34,9 @@ Options:
Commands: Commands:
upload [options] [paths...] Upload assets upload [options] [paths...] Upload assets
import [options] [paths...] Import existing assets
server-info Display server information server-info Display server information
login-key [instanceUrl] [apiKey] Login using an API key login-key [instanceUrl] [apiKey] Login using an API key
logout Remove stored credentials
help [command] display help for command help [command] display help for command
``` ```
@ -40,7 +44,21 @@ Commands:
- Sidecar should check both .jpg.xmp and .xmp - Sidecar should check both .jpg.xmp and .xmp
- Sidecar check could be case-insensitive - Sidecar check could be case-insensitive
- Use the SDK for all api calls. We currently use raw axos http calls
- Use list of supported files from server via api
# Known issues # For developers
- Upload can't use sdk due to multiple issues To run the Immich CLI from source, run the following in the cli folder:
$ npm run build
$ ts-node .
You'll need ts-node, the easiest way to install it is to use npm:
$ npm i -g ts-node
You can also build and install the CLI using
$ npm run build
$ npm install -g .

59
cli/package-lock.json generated
View File

@ -1,21 +1,24 @@
{ {
"name": "immich-cli", "name": "@immich/cli",
"version": "2.0.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-cli", "name": "@immich/cli",
"version": "2.0.0",
"dependencies": { "dependencies": {
"axios": "^1.4.0", "axios": "^1.6.2",
"byte-size": "^8.1.1", "byte-size": "^8.1.1",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"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",
"picomatch": "^2.3.1",
"systeminformation": "^5.18.4",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
"bin": {
"immich": "dist/index.js"
},
"devDependencies": { "devDependencies": {
"@types/byte-size": "^8.1.0", "@types/byte-size": "^8.1.0",
"@types/chai": "^4.3.5", "@types/chai": "^4.3.5",
@ -1959,9 +1962,9 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.5.1", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.0", "follow-redirects": "^1.15.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@ -4835,6 +4838,7 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -5621,31 +5625,6 @@
"integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==", "integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==",
"dev": true "dev": true
}, },
"node_modules/systeminformation": {
"version": "5.21.17",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.21.17.tgz",
"integrity": "sha512-JZYRCbIjk3WuBV59A9/rTla2rROX+aAJ9uo2Z1dI+bjieORcukClN8rlM1zE9NYKpULSbaGc+KKct/870lO0DA==",
"os": [
"darwin",
"linux",
"win32",
"freebsd",
"openbsd",
"netbsd",
"sunos",
"android"
],
"bin": {
"systeminformation": "lib/cli.js"
},
"engines": {
"node": ">=8.0.0"
},
"funding": {
"type": "Buy me a coffee",
"url": "https://www.buymeacoffee.com/systeminfo"
}
},
"node_modules/test-exclude": { "node_modules/test-exclude": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@ -7624,9 +7603,9 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"axios": { "axios": {
"version": "1.5.1", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
"requires": { "requires": {
"follow-redirects": "^1.15.0", "follow-redirects": "^1.15.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@ -9742,7 +9721,8 @@
"picomatch": { "picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
}, },
"pirates": { "pirates": {
"version": "4.0.6", "version": "4.0.6",
@ -10299,11 +10279,6 @@
"integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==", "integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==",
"dev": true "dev": true
}, },
"systeminformation": {
"version": "5.21.17",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.21.17.tgz",
"integrity": "sha512-JZYRCbIjk3WuBV59A9/rTla2rROX+aAJ9uo2Z1dI+bjieORcukClN8rlM1zE9NYKpULSbaGc+KKct/870lO0DA=="
},
"test-exclude": { "test-exclude": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",

View File

@ -1,14 +1,17 @@
{ {
"name": "immich-cli", "name": "@immich/cli",
"version": "2.0.0",
"main": "dist/index.js",
"bin": {
"immich": "./dist/index.js"
},
"dependencies": { "dependencies": {
"axios": "^1.4.0", "axios": "^1.6.2",
"byte-size": "^8.1.1", "byte-size": "^8.1.1",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"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",
"picomatch": "^2.3.1",
"systeminformation": "^5.18.4",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
"devDependencies": { "devDependencies": {
@ -45,7 +48,9 @@
"prepack": "yarn build ", "prepack": "yarn build ",
"test": "jest", "test": "jest",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"format": "prettier --check ." "format": "prettier --check .",
"format:fix": "prettier --write .",
"check": "tsc --noEmit"
}, },
"jest": { "jest": {
"clearMocks": true, "clearMocks": true,
@ -62,6 +67,9 @@
"collectCoverageFrom": [ "collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s" "<rootDir>/src/**/*.(t|j)s"
], ],
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>/src/api/$1"
},
"coverageDirectory": "./coverage", "coverageDirectory": "./coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }

View File

@ -1,3 +0,0 @@
// ./__mocks__/axios.js
import mockAxios from 'jest-mock-axios';
export default mockAxios;

View File

@ -9,7 +9,6 @@ import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
export abstract class BaseCommand { export abstract class BaseCommand {
protected sessionService!: SessionService; protected sessionService!: SessionService;
protected immichApi!: ImmichApi; protected immichApi!: ImmichApi;
protected deviceId!: string;
protected user!: UserResponseDto; protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto; protected serverVersion!: ServerVersionResponseDto;

View File

@ -1,15 +1,19 @@
import { BaseCommand } from '../cli/base-command'; import { BaseCommand } from '../cli/base-command';
export default class ServerInfo extends BaseCommand { export default class ServerInfo extends BaseCommand {
static description = 'Display server information';
static enableJsonFlag = true;
public async run() { public async run() {
console.log('Getting server information');
await this.connect(); await this.connect();
const { data: versionInfo } = await this.immichApi.serverInfoApi.getServerVersion(); const { data: versionInfo } = await this.immichApi.serverInfoApi.getServerVersion();
console.log(versionInfo); console.log(`Server is running version ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
const { data: supportedmedia } = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
console.log(`Supported image types: ${supportedmedia.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Supported video types: ${supportedmedia.video.map((extension) => extension.replace('.', ''))}`);
const { data: statistics } = await this.immichApi.assetApi.getAssetStatistics();
console.log(`Images: ${statistics.images}, Videos: ${statistics.videos}, Total: ${statistics.total}`);
} }
} }

View File

@ -1,43 +1,36 @@
import { BaseCommand } from '../cli/base-command'; import { Asset } from '../cores/models/asset';
import { CrawledAsset } from '../cores/models/crawled-asset'; import { CrawlService } from '../services';
import { CrawlService, UploadService } from '../services';
import * as si from 'systeminformation';
import FormData from 'form-data';
import { UploadOptionsDto } from '../cores/dto/upload-options-dto'; import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto'; import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
import cliProgress from 'cli-progress'; import cliProgress from 'cli-progress';
import byteSize from 'byte-size'; import byteSize from 'byte-size';
import { BaseCommand } from '../cli/base-command';
export default class Upload extends BaseCommand { export default class Upload extends BaseCommand {
private crawlService = new CrawlService();
private uploadService!: UploadService;
deviceId!: string;
uploadLength!: number; uploadLength!: number;
dryRun = false;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> { public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
await this.connect(); await this.connect();
const uuid = await si.uuid(); const deviceId = 'CLI';
this.deviceId = uuid.os || 'CLI';
this.uploadService = new UploadService(this.immichApi.apiConfiguration);
this.dryRun = options.dryRun; const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
const crawlOptions = new CrawlOptionsDto(); const crawlOptions = new CrawlOptionsDto();
crawlOptions.pathsToCrawl = paths; crawlOptions.pathsToCrawl = paths;
crawlOptions.recursive = options.recursive; crawlOptions.recursive = options.recursive;
crawlOptions.excludePatterns = options.excludePatterns; crawlOptions.exclusionPatterns = options.exclusionPatterns;
const crawledFiles: string[] = await this.crawlService.crawl(crawlOptions); const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
if (crawledFiles.length === 0) { if (crawledFiles.length === 0) {
console.log('No assets found, exiting'); console.log('No assets found, exiting');
return; return;
} }
const assetsToUpload = crawledFiles.map((path) => new CrawledAsset(path)); const assetsToUpload = crawledFiles.map((path) => new Asset(path, deviceId));
const uploadProgress = new cliProgress.SingleBar( const uploadProgress = new cliProgress.SingleBar(
{ {
@ -58,117 +51,86 @@ export default class Upload extends BaseCommand {
totalSize += asset.fileSize; totalSize += asset.fileSize;
} }
const existingAlbums = (await this.immichApi.albumApi.getAllAlbums()).data;
uploadProgress.start(totalSize, 0); uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
for (const asset of assetsToUpload) { try {
uploadProgress.update({ for (const asset of assetsToUpload) {
filename: asset.path, uploadProgress.update({
}); filename: asset.path,
});
try { let skipUpload = false;
if (options.import) { if (!options.skipHash) {
const importData = { const assetBulkUploadCheckDto = { assets: [{ id: asset.path, checksum: await asset.hash() }] };
assetPath: asset.path,
sidecarPath: asset.sidecarPath,
deviceAssetId: asset.deviceAssetId,
deviceId: this.deviceId,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: false,
isReadOnly: options.readOnly,
};
if (!this.dryRun) { const checkResponse = await this.immichApi.assetApi.checkBulkUpload({
await this.uploadService.import(importData); assetBulkUploadCheckDto,
} });
} else {
await this.uploadAsset(asset, options.skipHash); skipUpload = checkResponse.data.results[0].action === 'reject';
} }
} catch (error) {
uploadProgress.stop();
throw error;
}
sizeSoFar += asset.fileSize; if (!skipUpload) {
if (!asset.skipped) { if (!options.dryRun) {
totalSizeUploaded += asset.fileSize; const res = await this.immichApi.assetApi.uploadFile(asset.getUploadFileRequest());
uploadCounter++;
}
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) }); if (options.album && asset.albumName) {
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
if (!album) {
const res = await this.immichApi.albumApi.createAlbum({
createAlbumDto: { albumName: asset.albumName },
});
album = res.data;
existingAlbums.push(album);
}
await this.immichApi.albumApi.addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: [res.data.id] } });
}
}
totalSizeUploaded += asset.fileSize;
uploadCounter++;
}
sizeSoFar += asset.fileSize;
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
}
} finally {
uploadProgress.stop();
} }
uploadProgress.stop();
let messageStart; let messageStart;
if (this.dryRun) { if (options.dryRun) {
messageStart = 'Would have '; messageStart = 'Would have';
} else { } else {
messageStart = 'Successfully '; messageStart = 'Successfully';
} }
if (options.import) { if (uploadCounter === 0) {
console.log(`${messageStart} imported ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`); console.log('All assets were already uploaded, nothing to do.');
} else { } else {
if (uploadCounter === 0) { console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`);
console.log('All assets were already uploaded, nothing to do.'); }
if (options.delete) {
if (options.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
} else { } else {
console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`); console.log('Deleting assets that have been uploaded...');
} const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
if (options.delete) { deletionProgress.start(crawledFiles.length, 0);
if (this.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
} else {
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
deletionProgress.start(crawledFiles.length, 0);
for (const asset of assetsToUpload) { for (const asset of assetsToUpload) {
if (!this.dryRun) { if (!options.dryRun) {
await asset.delete(); await asset.delete();
}
deletionProgress.increment();
} }
deletionProgress.stop(); deletionProgress.increment();
console.log('Deletion complete');
} }
} deletionProgress.stop();
} console.log('Deletion complete');
}
private async uploadAsset(asset: CrawledAsset, skipHash = false) {
await asset.readData();
let skipUpload = false;
if (!skipHash) {
const checksum = await asset.hash();
const checkResponse = await this.uploadService.checkIfAssetAlreadyExists(asset.path, checksum);
skipUpload = checkResponse.data.results[0].action === 'reject';
}
if (skipUpload) {
asset.skipped = true;
} else {
const uploadFormData = new FormData();
uploadFormData.append('deviceAssetId', asset.deviceAssetId);
uploadFormData.append('deviceId', this.deviceId);
uploadFormData.append('fileCreatedAt', asset.fileCreatedAt);
uploadFormData.append('fileModifiedAt', asset.fileModifiedAt);
uploadFormData.append('isFavorite', String(false));
uploadFormData.append('assetData', asset.assetData, { filename: asset.path });
if (asset.sidecarData) {
uploadFormData.append('sidecarData', asset.sidecarData, {
filename: asset.sidecarPath,
contentType: 'application/xml',
});
}
if (!this.dryRun) {
await this.uploadService.upload(uploadFormData);
} }
} }
} }

View File

@ -1,59 +0,0 @@
// Check asset-upload.config.spec.ts for complete list
// TODO: we should get this list from the server via API in the future
// Videos
const videos = ['mp4', 'webm', 'mov', '3gp', 'avi', 'm2ts', 'mts', 'mpg', 'flv', 'mkv', 'wmv'];
// Images
const heic = ['heic', 'heif'];
const jpeg = ['jpg', 'jpeg'];
const png = ['png'];
const gif = ['gif'];
const tiff = ['tif', 'tiff'];
const webp = ['webp'];
const dng = ['dng'];
const other = [
'3fr',
'ari',
'arw',
'avif',
'cap',
'cin',
'cr2',
'cr3',
'crw',
'dcr',
'nef',
'erf',
'fff',
'iiq',
'jxl',
'k25',
'kdc',
'mrw',
'orf',
'ori',
'pef',
'psd',
'raf',
'raw',
'rwl',
'sr2',
'srf',
'srw',
'orf',
'ori',
'x3f',
];
export const ACCEPTED_FILE_EXTENSIONS = [
...videos,
...jpeg,
...png,
...heic,
...gif,
...tiff,
...webp,
...dng,
...other,
];

View File

@ -1,6 +1,6 @@
export class CrawlOptionsDto { export class CrawlOptionsDto {
pathsToCrawl!: string[]; pathsToCrawl!: string[];
recursive = false; recursive? = false;
includeHidden = false; includeHidden? = false;
excludePatterns!: string[]; exclusionPatterns?: string[];
} }

View File

@ -1,9 +1,9 @@
export class UploadOptionsDto { export class UploadOptionsDto {
recursive = false; recursive = false;
excludePatterns!: string[]; exclusionPatterns!: string[];
dryRun = false; dryRun = false;
skipHash = false; skipHash = false;
delete = false; delete = false;
import = false;
readOnly = true; readOnly = true;
album = false;
} }

View File

@ -1,2 +1 @@
export * from './constants';
export * from './models'; export * from './models';

View File

@ -0,0 +1,91 @@
import * as fs from 'fs';
import { basename } from 'node:path';
import crypto from 'crypto';
import { AssetApiUploadFileRequest } from 'src/api/open-api';
import Os from 'os';
export class Asset {
readonly path: string;
readonly deviceId!: string;
assetData?: File;
deviceAssetId?: string;
fileCreatedAt?: string;
fileModifiedAt?: string;
sidecarData?: File;
sidecarPath?: string;
fileSize!: number;
albumName?: string;
constructor(path: string, deviceId: string) {
this.path = path;
this.deviceId = deviceId;
}
async process() {
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();
this.assetData = await this.getFileObject(this.path);
// TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`;
try {
fs.accessSync(sideCarPath, fs.constants.R_OK);
this.sidecarData = await this.getFileObject(sideCarPath);
} catch (error) {}
}
getUploadFileRequest(): AssetApiUploadFileRequest {
if (!this.assetData) throw new Error('Asset data not set');
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');
if (!this.deviceId) throw new Error('Device id not set');
return {
assetData: this.assetData,
deviceAssetId: this.deviceAssetId,
deviceId: this.deviceId,
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: false,
sidecarData: this.sidecarData,
};
}
private async getFileObject(path: string): Promise<File> {
const buffer = await fs.promises.readFile(path);
return new File([buffer], basename(path));
}
async delete(): Promise<void> {
return fs.promises.unlink(this.path);
}
public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = crypto.createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = fs.createReadStream(filePath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
return await sha1(this.path);
}
private extractAlbumName(): string {
if (Os.platform() === 'win32') {
return this.path.split('\\').slice(-2)[0];
} else {
return this.path.split('/').slice(-2)[0];
}
}
}

View File

@ -1,58 +0,0 @@
import * as fs from 'fs';
import { basename } from 'node:path';
import crypto from 'crypto';
export class CrawledAsset {
public path: string;
public assetData?: fs.ReadStream;
public deviceAssetId?: string;
public fileCreatedAt?: string;
public fileModifiedAt?: string;
public sidecarData?: Buffer;
public sidecarPath?: string;
public fileSize!: number;
public skipped = false;
constructor(path: string) {
this.path = path;
}
async readData() {
this.assetData = fs.createReadStream(this.path);
}
async process() {
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;
// TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`;
try {
fs.accessSync(sideCarPath, fs.constants.R_OK);
this.sidecarData = await fs.promises.readFile(sideCarPath);
this.sidecarPath = sideCarPath;
} catch (error) {}
}
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);
}
}

View File

@ -1 +1 @@
export * from './crawled-asset'; export * from './asset';

View File

@ -1,7 +1,10 @@
#! /usr/bin/env node
import { program, Option } from 'commander'; import { program, Option } from 'commander';
import Upload from './commands/upload'; import Upload from './commands/upload';
import ServerInfo from './commands/server-info'; import ServerInfo from './commands/server-info';
import LoginKey from './commands/login/key'; import LoginKey from './commands/login/key';
import Logout from './commands/logout';
program.name('immich').description('Immich command line interface'); program.name('immich').description('Immich command line interface');
@ -12,6 +15,11 @@ program
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS')) .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS'))
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(
new Option('-a, --album', 'Automatically create albums based on folder name')
.env('IMMICH_AUTO_CREATE_ALBUM')
.default(false),
)
.addOption( .addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done") new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN') .env('IMMICH_DRY_RUN')
@ -20,33 +28,13 @@ program
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.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.excludePatterns = options.ignore; options.exclusionPatterns = options.ignore;
await new Upload().run(paths, options);
});
program
.command('import')
.description('Import existing assets')
.usage('[options] [paths...]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN')
.default(false),
)
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
.addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
.argument('[paths...]', 'One or more paths to assets to be imported')
.action(async (paths, options) => {
options.import = true;
options.excludePatterns = options.ignore;
await new Upload().run(paths, options); await new Upload().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().run(); await new ServerInfo().run();
}); });
@ -60,4 +48,11 @@ program
await new LoginKey().run(paths, options); await new LoginKey().run(paths, options);
}); });
program
.command('logout')
.description('Remove stored credentials')
.action(async () => {
await new Logout().run();
});
program.parse(process.argv); program.parse(process.argv);

View File

@ -1,235 +1,206 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { CrawlService } from './crawl.service';
import mockfs from 'mock-fs'; import mockfs from 'mock-fs';
import { toIncludeSameMembers } from 'jest-extended'; import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto';
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto'; import { CrawlService } from '.';
const matchers = require('jest-extended'); interface Test {
expect.extend(matchers); test: string;
options: CrawlOptionsDto;
files: Record<string, boolean>;
}
const crawlService = new CrawlService(); const cwd = process.cwd();
describe('CrawlService', () => { const tests: Test[] = [
beforeAll(() => { {
// Write a dummy output before mock-fs to prevent some annoying errors test: 'should return empty when crawling an empty path list',
console.log(); options: {
}); pathsToCrawl: [],
},
files: {},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should exclude by file extension',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/*.tif'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by file extension without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/*.TIF'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by folder',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/raw/**'],
},
files: {
'/photos/image.jpg': true,
'/photos/raw/image.jpg': false,
'/photos/raw2/image.jpg': true,
'/photos/folder/raw/image.jpg': false,
'/photos/crawl/image.jpg': true,
},
},
{
test: 'should crawl multiple paths',
options: {
pathsToCrawl: ['/photos/', '/images/', '/albums/'],
},
files: {
'/photos/image1.jpg': true,
'/images/image2.jpg': true,
'/albums/image3.jpg': true,
},
},
{
test: 'should support globbing paths',
options: {
pathsToCrawl: ['/photos*'],
},
files: {
'/photos1/image1.jpg': true,
'/photos2/image2.jpg': true,
'/images/image3.jpg': false,
},
},
{
test: 'should crawl a single path without trailing slash',
options: {
pathsToCrawl: ['/photos'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/subfolder/image1.jpg': true,
'/photos/subfolder/image2.jpg': true,
'/image1.jpg': false,
},
},
{
test: 'should filter file extensions',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.txt': false,
'/photos/1': false,
},
},
{
test: 'should include photo and video extensions',
options: {
pathsToCrawl: ['/photos/', '/videos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.jpeg': true,
'/photos/image.heic': true,
'/photos/image.heif': true,
'/photos/image.png': true,
'/photos/image.gif': true,
'/photos/image.tif': true,
'/photos/image.tiff': true,
'/photos/image.webp': true,
'/photos/image.dng': true,
'/photos/image.nef': true,
'/videos/video.mp4': true,
'/videos/video.mov': true,
'/videos/video.webm': true,
},
},
{
test: 'should check file extensions without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.Jpg': true,
'/photos/image.jpG': true,
'/photos/image.JPG': true,
'/photos/image.jpEg': true,
'/photos/image.TIFF': true,
'/photos/image.tif': true,
'/photos/image.dng': true,
'/photos/image.NEF': true,
},
},
{
test: 'should normalize the path',
options: {
pathsToCrawl: ['/photos/1/../2'],
},
files: {
'/photos/1/image.jpg': false,
'/photos/2/image.jpg': true,
},
},
{
test: 'should return absolute paths',
options: {
pathsToCrawl: ['photos'],
},
files: {
[`${cwd}/photos/1.jpg`]: true,
[`${cwd}/photos/2.jpg`]: true,
[`/photos/3.jpg`]: false,
},
},
];
it('should crawl a single directory', async () => { describe(CrawlService.name, () => {
mockfs({ const sut = new CrawlService(
'/photos/image.jpg': '', ['.jpg', '.jpeg', '.png', '.heif', '.heic', '.tif', '.nef', '.webp', '.tiff', '.dng', '.gif'],
}); ['.mov', '.mp4', '.webm'],
);
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should crawl a single file', async () => {
mockfs({
'/photos/image.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/image.jpg'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should crawl a file and a directory', async () => {
mockfs({
'/photos/image.jpg': '',
'/images/photo.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/image.jpg', '/images/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg', '/images/photo.jpg']);
});
it('should exclude by file extension', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.tif': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
options.excludePatterns = ['**/*.tif'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should exclude by file extension without case sensitivity', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.tif': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
options.excludePatterns = ['**/*.TIF'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should exclude by folder', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/raw/image.jpg': '',
'/photos/raw2/image.jpg': '',
'/photos/folder/raw/image.jpg': '',
'/photos/crawl/image.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
options.excludePatterns = ['**/raw/**'];
options.recursive = true;
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg', '/photos/raw2/image.jpg', '/photos/crawl/image.jpg']);
});
it('should crawl multiple paths', async () => {
mockfs({
'/photos/image1.jpg': '',
'/images/image2.jpg': '',
'/albums/image3.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/', '/images/', '/albums/'];
options.recursive = false;
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image1.jpg', '/images/image2.jpg', '/albums/image3.jpg']);
});
it('should crawl a single path without trailing slash', async () => {
mockfs({
'/photos/image.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should crawl a single path without recursion', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/subfolder/image1.jpg': '',
'/photos/subfolder/image2.jpg': '',
'/image1.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should crawl a single path with recursion', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/subfolder/image1.jpg': '',
'/photos/subfolder/image2.jpg': '',
'/image1.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
options.recursive = true;
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers([
'/photos/image.jpg',
'/photos/subfolder/image1.jpg',
'/photos/subfolder/image2.jpg',
]);
});
it('should filter file extensions', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.txt': '',
'/photos/1': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should include photo and video extensions', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.jpeg': '',
'/photos/image.heic': '',
'/photos/image.heif': '',
'/photos/image.png': '',
'/photos/image.gif': '',
'/photos/image.tif': '',
'/photos/image.tiff': '',
'/photos/image.webp': '',
'/photos/image.dng': '',
'/photos/image.nef': '',
'/videos/video.mp4': '',
'/videos/video.mov': '',
'/videos/video.webm': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/', '/videos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers([
'/photos/image.jpg',
'/photos/image.jpeg',
'/photos/image.heic',
'/photos/image.heif',
'/photos/image.png',
'/photos/image.gif',
'/photos/image.tif',
'/photos/image.tiff',
'/photos/image.webp',
'/photos/image.dng',
'/photos/image.nef',
'/videos/video.mp4',
'/videos/video.mov',
'/videos/video.webm',
]);
});
it('should check file extensions without case sensitivity', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.Jpg': '',
'/photos/image.jpG': '',
'/photos/image.JPG': '',
'/photos/image.jpEg': '',
'/photos/image.TIFF': '',
'/photos/image.tif': '',
'/photos/image.dng': '',
'/photos/image.NEF': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers([
'/photos/image.jpg',
'/photos/image.Jpg',
'/photos/image.jpG',
'/photos/image.JPG',
'/photos/image.jpEg',
'/photos/image.TIFF',
'/photos/image.tif',
'/photos/image.dng',
'/photos/image.NEF',
]);
});
afterEach(() => { afterEach(() => {
mockfs.restore(); mockfs.restore();
}); });
describe('crawl', () => {
for (const { test, options, files } of tests) {
it(test, async () => {
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
const actual = await sut.crawl(options);
const expected = Object.entries(files)
.filter((entry) => entry[1])
.map(([file]) => file);
expect(actual.sort()).toEqual(expected.sort());
});
}
});
}); });

View File

@ -1,47 +1,28 @@
import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto'; import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto';
import { ACCEPTED_FILE_EXTENSIONS } from '../cores';
import { glob } from 'glob'; import { glob } from 'glob';
import * as fs from 'fs';
export class CrawlService { export class CrawlService {
public async crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> { private readonly extensions!: string[];
const pathsToCrawl: string[] = crawlOptions.pathsToCrawl;
const directories: string[] = []; constructor(image: string[], video: string[]) {
const crawledFiles: string[] = []; this.extensions = image.concat(video).map((extension) => extension.replace('.', ''));
}
for await (const currentPath of pathsToCrawl) { crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
const stats = await fs.promises.stat(currentPath); const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
if (stats.isFile() || stats.isSymbolicLink()) { if (!pathsToCrawl) {
crawledFiles.push(currentPath); return Promise.resolve([]);
} else {
directories.push(currentPath);
}
} }
let searchPattern: string; const base = pathsToCrawl.length === 1 ? pathsToCrawl[0] : `{${pathsToCrawl.join(',')}}`;
if (directories.length === 1) { const extensions = `*{${this.extensions}}`;
searchPattern = directories[0];
} else if (directories.length === 0) {
return crawledFiles;
} else {
searchPattern = '{' + directories.join(',') + '}';
}
if (crawlOptions.recursive) { return glob(`${base}/**/${extensions}`, {
searchPattern = searchPattern + '/**/'; absolute: true,
}
searchPattern = `${searchPattern}/*.{${ACCEPTED_FILE_EXTENSIONS.join(',')}}`;
const globbedFiles = await glob(searchPattern, {
nocase: true, nocase: true,
nodir: true, nodir: true,
ignore: crawlOptions.excludePatterns, dot: includeHidden,
ignore: exclusionPatterns,
}); });
const returnedFiles = crawledFiles.concat(globbedFiles);
returnedFiles.sort();
return returnedFiles;
} }
} }

View File

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

View File

@ -46,7 +46,7 @@ export class SessionService {
// 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 this.api.userApi.getMyUserInfo().catch((error) => {
throw new LoginError(`Failed to connect to the server: ${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}`);
@ -78,7 +78,7 @@ export class SessionService {
private async ping(): Promise<void> { private async ping(): Promise<void> {
const { data: pingResponse } = await this.api.serverInfoApi.pingServer().catch((error) => { const { data: pingResponse } = await this.api.serverInfoApi.pingServer().catch((error) => {
throw new Error(`Failed to connect to the server: ${error.message}`); throw new Error(`Failed to connect to server ${this.api.apiConfiguration.instanceUrl}: ${error.message}`);
}); });
if (pingResponse.res !== 'pong') { if (pingResponse.res !== 'pong') {

View File

@ -1,24 +0,0 @@
import { UploadService } from './upload.service';
import axios from 'axios';
import FormData from 'form-data';
import { ApiConfiguration } from '../cores/api-configuration';
jest.mock('axios', () => jest.fn());
describe('UploadService', () => {
let uploadService: UploadService;
beforeEach(() => {
const apiConfiguration = new ApiConfiguration('https://example.com/api', 'key');
uploadService = new UploadService(apiConfiguration);
});
it('should call axios', async () => {
const data = new FormData();
await uploadService.upload(data);
expect(axios).toHaveBeenCalled();
});
});

View File

@ -1,65 +0,0 @@
import axios, { AxiosRequestConfig } from 'axios';
import FormData from 'form-data';
import { ApiConfiguration } from '../cores/api-configuration';
export class UploadService {
private readonly uploadConfig: AxiosRequestConfig<any>;
private readonly checkAssetExistenceConfig: AxiosRequestConfig<any>;
private readonly importConfig: AxiosRequestConfig<any>;
constructor(apiConfiguration: ApiConfiguration) {
this.uploadConfig = {
method: 'post',
maxRedirects: 0,
url: `${apiConfiguration.instanceUrl}/asset/upload`,
headers: {
'x-api-key': apiConfiguration.apiKey,
},
maxContentLength: Number.POSITIVE_INFINITY,
maxBodyLength: Number.POSITIVE_INFINITY,
};
this.importConfig = {
method: 'post',
maxRedirects: 0,
url: `${apiConfiguration.instanceUrl}/asset/import`,
headers: {
'x-api-key': apiConfiguration.apiKey,
'Content-Type': 'application/json',
},
maxContentLength: Number.POSITIVE_INFINITY,
maxBodyLength: Number.POSITIVE_INFINITY,
};
this.checkAssetExistenceConfig = {
method: 'post',
maxRedirects: 0,
url: `${apiConfiguration.instanceUrl}/asset/bulk-upload-check`,
headers: {
'x-api-key': apiConfiguration.apiKey,
'Content-Type': 'application/json',
},
};
}
public checkIfAssetAlreadyExists(path: string, checksum: string) {
this.checkAssetExistenceConfig.data = JSON.stringify({ assets: [{ id: path, checksum: checksum }] });
// TODO: retry on 500 errors?
return axios(this.checkAssetExistenceConfig);
}
public upload(data: FormData) {
this.uploadConfig.data = data;
// TODO: retry on 500 errors?
return axios(this.uploadConfig);
}
public import(data: any) {
this.importConfig.data = data;
// TODO: retry on 500 errors?
return axios(this.importConfig);
}
}

View File

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "Node16",
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,
@ -8,7 +8,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"target": "es2017", "target": "es2022",
"moduleResolution": "node16", "moduleResolution": "node16",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",

View File

@ -12,9 +12,9 @@ sidebar_position: 7
| ![cloud-cross](/img/cloud-off.svg) | Asset is only available locally and has not yet been backed up | | ![cloud-cross](/img/cloud-off.svg) | Asset is only available locally and has not yet been backed up |
| ![cloud-done](/img/cloud-done.svg) | Asset was uploaded from this device and is now backed up in the cloud/server and still available in original on the device | | ![cloud-done](/img/cloud-done.svg) | Asset was uploaded from this device and is now backed up in the cloud/server and still available in original on the device |
### How can I sync an existing directory with Immich's server? ### Can I add my existing photo library?
Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app/immich/discussions/1006)), but the [command line tool](/docs/features/bulk-upload.md) can bulk upload items from a directory to Immich. Yes, with an [external library](/docs/features/libraries.md).
### Why are only photos and not videos being uploaded to Immich? ### Why are only photos and not videos being uploaded to Immich?

View File

@ -34,7 +34,7 @@ The web app is a [TypeScript](https://www.typescriptlang.org/) project that uses
### CLI ### CLI
The CLI is a [TypeScript](https://www.typescriptlang.org/) project that parses command line arguments to programmatically upload/import assets to an Immich server. See [Bulk Upload](/docs/features/bulk-upload.md) for more information about its usage. The Immich CLI is an [npm](https://www.npmjs.com/) package that lets users control their Immich instance from the command line. It uses the API to perform various tasks, especially uploading assets. See the [CLI documentation](/docs/features/command-line-interface.md) for more information.
## Server ## Server

View File

@ -1,113 +0,0 @@
# Bulk Upload (Using the CLI)
You can use the CLI to upload an existing gallery to the Immich server
[Immich CLI Repository](https://github.com/immich-app/CLI)
:::tip Google Photos Takeout
If you are looking to import your Google Photos takeout, we recommed this community maintained tool [immich-go](https://github.com/simulot/immich-go)
:::
## Requirements
- Node.js 16 or above
- Npm
## Installation
```bash
npm i -g immich
```
Pre-installed on the `immich-server` container and can be easily accessed through
```
immich
```
### Options
| Parameter | Description |
| ---------------- | ------------------------------------------------------------------- |
| --yes / -y | Assume yes on all interactive prompts |
| --recursive / -r | Include subfolders |
| --delete / -da | Delete local assets after upload |
| --key / -k | User's API key |
| --server / -s | Immich's server address |
| --threads / -t | Number of threads to use (Default 5) |
| --album/ -al | Create albums for assets based on the parent folder or a given name |
## Quick Start
Specify user's credential, Immich's server address and port and the directory you would like to upload videos/photos from.
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg
```
By default, subfolders are not included. To upload a directory including subfolder, use the --recursive option:
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/
```
### Obtain the API Key
The API key can be obtained in the user setting panel on the web interface.
![Obtain Api Key](./img/obtain-api-key.png)
---
### Run via Docker
You can run the CLI inside of a docker container to avoid needing to install anything.
:::caution Running inside Docker
Be aware that as this runs inside a container, you need to mount the folder from which you want to import into the container.
:::
```bash title="Upload current directory"
cd /DIRECTORY/WITH/IMAGES
docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
```bash title="Upload target directory"
docker run -it --rm -v "/DIRECTORY/WITH/IMAGES:/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
```bash title="Create an alias"
alias immich='docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest'
immich upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
:::tip Internal networking
If you are running the CLI container on the same machine as your Immich server, you may not be able to reach the external address. In that case, try the following steps:
1. Find the internal Docker network used by Immich via `docker network ls`.
2. Adapt the above command to pass the `--network <immich_network>` argument to `docker run`, substituting `<immich_network>` with the result from step 1.
3. Use `--server http://immich-server:3001` for the upload command instead of the external address.
```bash title="Upload to internal address"
docker run --network immich_default -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://immich-server:3001
```
:::
### Run from source
```bash title="Clone Repository"
git clone https://github.com/immich-app/CLI
```
```bash title="Install dependencies"
npm install
```
```bash title="Build the project"
npm run build
```
```bash title="Run the command"
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
```

View File

@ -0,0 +1,139 @@
# The Immich CLI
Immich has a CLI that allows you to perform certain actions from the command line. This CLI replaces the [legacy CLI](https://github.com/immich-app/CLI) that was previously available. The CLI is hosted in the [cli folder of the the main Immich github repository](https://github.com/immich-app/immich/tree/main/cli).
## Features
- Upload photos and videos to Immich
- Check server version
More features are planned for the future.
:::tip Google Photos Takeout
If you are looking to import your Google Photos takeout, we recommed this community maintained tool [immich-go](https://github.com/simulot/immich-go)
:::
## Requirements
- Node.js 18 or above
- Npm
## Installation
```bash
npm i -g @immich/cli
```
NOTE: if you previously installed the legacy CLI, you will need to uninstall it first:
```bash
npm uninstall -g immich
```
## Usage
```
immich
```
```
Usage: immich [options] [command]
Immich command line interface
Options:
-h, --help display help for command
Commands:
upload [options] [paths...] Upload assets
server-info Display server information
login-key [instanceUrl] [apiKey] Login using an API key
logout Remove stored credentials
help [command] display help for command
```
## Commands
The upload command supports the following options:
```
Usage: immich upload [options] [paths...]
Upload assets
Arguments:
paths One or more paths to assets to be uploaded
Options:
-r, --recursive Recursive (default: false, env: IMMICH_RECURSIVE)
-i, --ignore [paths...] Paths to ignore (env: IMMICH_IGNORE_PATHS)
-h, --skip-hash Don't hash files before upload (default: false, env: IMMICH_SKIP_HASH)
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
--help display help for command
```
Note that the above options can read from environment variables as well.
## Quick Start
You begin by authenticating to your Immich server.
```bash
immich login-key [instanceUrl] [apiKey]
```
For instance,
```bash
immich login-key http://192.168.1.216:2283/api HFEJ38DNSDUEG
```
This will store your credentials in a file in your home directory. Please keep the file secure, either by performing the logout command after you are done, or deleting it manually.
Once you are authenticated, you can upload assets to your Immich server.
```bash
immich upload file1.jpg file2.jpg
```
By default, subfolders are not included. To upload a directory including subfolder, use the --recursive option:
```bash
immich upload --recursive directory/
```
If you are unsure what will happen, you can use the `--dry-run` option to see what would happen without actually performing any actions.
```bash
immich upload --dry-run --recursive directory/
```
By default, the upload command will hash the files before uploading them. This is to avoid uploading the same file multiple times. If you are sure that the files are unique, you can skip this step by passing the `--skip-hash` option. Note that Immich always performs its own deduplication through hashing, so this is merely a performance consideration. If you have good bandwidth it might be faster to skip hashing.
```bash
immich upload --skip-hash --recursive directory/
```
You can automatically create albums based on the folder name by passing the `--album` option. This will automatically create albums for each uploaded asset based on the name of the folder they are in.
```bash
immich upload --album --recursive directory/
```
It is possible to skip assets matching a glob pattern by passing the `--ignore` option. See [the library documentation](docs/features/libraries.md) on how to use glob patterns. You can add several exclusion patterns if needed.
```bash
immich upload --ignore **/Raw/** --recursive directory/
```
```bash
immich upload --ignore **/Raw/** **/*.tif --recursive directory/
```
### Obtain the API Key
The API key can be obtained in the user setting panel on the web interface.
![Obtain Api Key](./img/obtain-api-key.png)

View File

@ -4,10 +4,6 @@ import MobileAppBackup from '../partials/_mobile-app-backup.md';
# Mobile App # Mobile App
:::tip
To upload from other devices, try using the [Bulk Upload CLI](/docs/features/bulk-upload.md).
:::
## Download ## Download
<MobileAppDownload /> <MobileAppDownload />

View File

@ -3,6 +3,7 @@ import {
mdiAndroid, mdiAndroid,
mdiAppleIos, mdiAppleIos,
mdiArchiveOutline, mdiArchiveOutline,
mdiBash,
mdiBookSearchOutline, mdiBookSearchOutline,
mdiCakeVariant, mdiCakeVariant,
mdiCheckAll, mdiCheckAll,
@ -49,6 +50,15 @@ import React from 'react';
import Timeline, { DateType, Item } from '../components/timeline'; import Timeline, { DateType, Item } from '../components/timeline';
const items: Item[] = [ const items: Item[] = [
{
icon: mdiBash,
description: 'Version 2 of the Immich CLI is released, replacing the legacy v1 CLI.',
title: 'CLI v2',
release: 'v1.88.0',
tag: 'v1.88.0',
date: new Date(2023, 10, 19),
dateType: DateType.RELEASE,
},
{ {
icon: mdiStar, icon: mdiStar,
description: 'Reach 20K Stars on GitHub!', description: 'Reach 20K Stars on GitHub!',

View File

@ -87,7 +87,7 @@ export class AlbumService {
); );
} }
async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto) { async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets); return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets);

View File

@ -31,23 +31,31 @@ export class AlbumController {
} }
@Get() @Get()
getAllAlbums(@AuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto) { getAllAlbums(@AuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
return this.service.getAll(authUser, query); return this.service.getAll(authUser, query);
} }
@Post() @Post()
createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto) { createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto): Promise<AlbumResponseDto> {
return this.service.create(authUser, dto); return this.service.create(authUser, dto);
} }
@SharedLinkRoute() @SharedLinkRoute()
@Get(':id') @Get(':id')
getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Query() dto: AlbumInfoDto) { getAlbumInfo(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Query() dto: AlbumInfoDto,
): Promise<AlbumResponseDto> {
return this.service.get(authUser, id, dto); return this.service.get(authUser, id, dto);
} }
@Patch(':id') @Patch(':id')
updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto) { updateAlbumInfo(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateDto,
): Promise<AlbumResponseDto> {
return this.service.update(authUser, id, dto); return this.service.update(authUser, id, dto);
} }