@conneryn/immich
Version:
418 lines (417 loc) • 18.9 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const axios_1 = __importDefault(require("axios"));
const commander_1 = require("commander");
const fs = __importStar(require("fs"));
const fdir_1 = require("fdir");
const si = __importStar(require("systeminformation"));
const readline = __importStar(require("readline"));
const path = __importStar(require("path"));
const form_data_1 = __importDefault(require("form-data"));
const cliProgress = __importStar(require("cli-progress"));
const promises_1 = require("fs/promises");
const exifr = __importStar(require("exifr"));
// GLOBAL
const mime = __importStar(require("mime-types"));
const chalk_1 = __importDefault(require("chalk"));
const package_json_1 = __importDefault(require("../package.json"));
const p_limit_1 = __importDefault(require("p-limit"));
const log = console.log;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
let errorAssets = [];
const SUPPORTED_MIME = [
// IMAGES
"image/heif",
"image/heic",
"image/jpeg",
"image/png",
"image/jpg",
"image/gif",
"image/heic",
"image/heif",
"image/dng",
"image/x-adobe-dng",
"image/webp",
"image/tiff",
"image/nef",
"iamge/x-nikon-nef",
// VIDEO
"video/mp4",
"video/quicktime",
"video/x-msvideo",
"video/3gpp",
];
commander_1.program
.name("Immich CLI Utilities")
.description("Immich CLI Utilities toolset")
.version(package_json_1.default.version);
commander_1.program
.command("upload")
.description("Upload images and videos in a directory to Immich's server")
.addOption(new commander_1.Option("-e, --email <value>", "User's Email").env("IMMICH_USER_EMAIL"))
.addOption(new commander_1.Option("-pw, --password <value>", "User's Password").env("IMMICH_USER_PASSWORD"))
.addOption(new commander_1.Option("-s, --server <value>", "Server address (http://<your-ip>:2283/api or https://<your-domain>/api)").env("IMMICH_SERVER_ADDRESS"))
.addOption(new commander_1.Option("-d, --directory <value>", "Target Directory").env("IMMICH_TARGET_DIRECTORY"))
.addOption(new commander_1.Option("-y, --yes", "Assume yes on all interactive prompts").env("IMMICH_ASSUME_YES"))
.addOption(new commander_1.Option("-da, --delete", "Delete local assets after upload").env("IMMICH_DELETE_ASSETS"))
.addOption(new commander_1.Option("-t, --threads", "Amount of concurrent upload threads (default=5)").env("IMMICH_UPLOAD_THREADS"))
.addOption(new commander_1.Option("-al, --album [album]", "Create albums for assets based on the parent folder or a given name").env("IMMICH_CREATE_ALBUMS"))
.action(upload);
commander_1.program.parse(process.argv);
function upload({ email, password, server, directory, yes: assumeYes, delete: deleteAssets, uploadThreads, album: createAlbums, }) {
return __awaiter(this, void 0, void 0, function* () {
const endpoint = server;
const deviceId = (yield si.uuid()).os || "CLI";
const osInfo = (yield si.osInfo()).distro;
const localAssets = [];
// Ping server
log("[1] Pinging server...");
yield pingServer(endpoint);
// Login
log("[2] Logging in...");
const { accessToken, userId, userEmail } = yield login(endpoint, email, password);
log(chalk_1.default.yellow(`You are logged in as ${userEmail}`));
// Check if directory exist
log("[3] Checking directory...");
if (fs.existsSync(directory)) {
log(chalk_1.default.green("Directory status: OK"));
}
else {
log(chalk_1.default.red("Error navigating to directory - check directory path"));
process.exit(1);
}
// Index provided directory
log("[4] Indexing files...");
const api = new fdir_1.fdir().withFullPaths().crawl(directory);
const files = (yield api.withPromise());
for (const filePath of files) {
const mimeType = mime.lookup(filePath);
if (SUPPORTED_MIME.includes(mimeType)) {
const fileStat = fs.statSync(filePath);
localAssets.push({
id: `${path.basename(filePath)}-${fileStat.size}`.replace(/\s+/g, ""),
filePath,
});
}
}
log(chalk_1.default.green("Indexing file: OK"));
log(chalk_1.default.yellow(`Found ${localAssets.length} assets in specified directory`));
// Find assets that has not been backup
log("[5] Gathering device's asset info from server...");
const backupAsset = yield getAssetInfoFromServer(endpoint, accessToken, deviceId);
if (localAssets.length == 0) {
log(chalk_1.default.green("There are no assets to backup"));
process.exit(0);
}
else {
log(chalk_1.default.green(`A total of ${localAssets.length} assets will be uploaded to the server`));
}
// Ask user
try {
//There is a promise API for readline, but it's currently experimental
//https://nodejs.org/api/readline.html#promises-api
const answer = assumeYes
? "y"
: yield new Promise((resolve) => {
rl.question("Do you want to start upload now? (y/n) ", resolve);
});
const deleteLocalAsset = deleteAssets ? "y" : "n";
if (answer == "n") {
log(chalk_1.default.yellow("Abort Upload Process"));
process.exit(1);
}
if (answer == "y") {
log(chalk_1.default.green("Start uploading..."));
const progressBar = new cliProgress.SingleBar({
format: "Upload Progress | {bar} | {percentage}% || {value}/{total} || Current file [{filepath}]",
}, cliProgress.Presets.shades_classic);
progressBar.start(localAssets.length, 0, { filepath: "" });
const assetDirectoryMap = new Map();
const uploadQueue = [];
const limit = (0, p_limit_1.default)(uploadThreads !== null && uploadThreads !== void 0 ? uploadThreads : 5);
for (const asset of localAssets) {
const album = asset.filePath.split(path.sep).slice(-2)[0];
if (!assetDirectoryMap.has(album)) {
assetDirectoryMap.set(album, []);
}
if (!backupAsset.includes(asset.id)) {
// New file, lets upload it!
uploadQueue.push(limit(() => __awaiter(this, void 0, void 0, function* () {
try {
const res = yield startUpload(endpoint, accessToken, asset, deviceId);
progressBar.increment(1, { filepath: asset.filePath });
if (res && res.status == 201) {
if (deleteLocalAsset == "y") {
fs.unlink(asset.filePath, (err) => {
if (err) {
log(err);
return;
}
});
}
backupAsset.push(asset.id);
assetDirectoryMap.get(album).push(res.data.id);
}
}
catch (err) {
log(chalk_1.default.red(err.message));
}
})));
}
else if (createAlbums) {
// Existing file. No need to upload it BUT lets still add to Album.
uploadQueue.push(limit(() => __awaiter(this, void 0, void 0, function* () {
try {
// Fetch existing asset from server
const res = yield axios_1.default.post(`${endpoint}/asset/check`, {
deviceAssetId: asset.id,
deviceId,
}, {
headers: { Authorization: `Bearer ${accessToken} ` },
});
assetDirectoryMap.get(album).push(res.data.id);
}
catch (err) {
log(chalk_1.default.red(err.message));
}
})));
}
}
const uploads = yield Promise.all(uploadQueue);
progressBar.stop();
if (createAlbums) {
log(chalk_1.default.green("Creating albums..."));
const serverAlbums = yield getAlbumsFromServer(endpoint, accessToken);
if (typeof createAlbums === "boolean") {
progressBar.start(assetDirectoryMap.size, 0);
for (const localAlbum of assetDirectoryMap.keys()) {
const serverAlbumIndex = serverAlbums.findIndex((album) => album.albumName === localAlbum);
let albumId;
if (serverAlbumIndex > -1) {
albumId = serverAlbums[serverAlbumIndex].id;
}
else {
albumId = yield createAlbum(endpoint, accessToken, localAlbum);
}
if (albumId) {
yield addAssetsToAlbum(endpoint, accessToken, albumId, assetDirectoryMap.get(localAlbum));
}
progressBar.increment();
}
progressBar.stop();
}
else {
const serverAlbumIndex = serverAlbums.findIndex((album) => album.albumName === createAlbums);
let albumId;
if (serverAlbumIndex > -1) {
albumId = serverAlbums[serverAlbumIndex].id;
}
else {
albumId = yield createAlbum(endpoint, accessToken, createAlbums);
}
yield addAssetsToAlbum(endpoint, accessToken, albumId, Array.from(assetDirectoryMap.values()).flat());
}
}
log(chalk_1.default.yellow(`Failed to upload ${errorAssets.length} files `), errorAssets);
if (errorAssets.length > 0) {
process.exit(1);
}
process.exit(0);
}
}
catch (e) {
log(chalk_1.default.red("Error reading input from user "), e);
process.exit(1);
}
});
}
function startUpload(endpoint, accessToken, asset, deviceId) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
try {
const assetType = getAssetType(asset.filePath);
const fileStat = yield (0, promises_1.stat)(asset.filePath);
let exifData = null;
if (assetType != "VIDEO") {
try {
exifData = yield exifr.parse(asset.filePath, {
tiff: true,
ifd0: true,
ifd1: true,
exif: true,
gps: true,
interop: true,
xmp: true,
icc: true,
iptc: true,
jfif: true,
ihdr: true,
});
}
catch (e) { }
}
const createdAt = exifData && exifData.DateTimeOriginal != null
? new Date(exifData.DateTimeOriginal).toISOString()
: fileStat.mtime.toISOString();
const data = new form_data_1.default();
data.append("deviceAssetId", asset.id);
data.append("deviceId", deviceId);
data.append("assetType", assetType);
data.append("createdAt", createdAt);
data.append("modifiedAt", fileStat.mtime.toISOString());
data.append("isFavorite", JSON.stringify(false));
data.append("fileExtension", path.extname(asset.filePath));
data.append("duration", "0:00:00.000000");
data.append("assetData", fs.createReadStream(asset.filePath));
const config = {
method: "post",
maxRedirects: 0,
url: `${endpoint}/asset/upload`,
headers: Object.assign({ Authorization: `Bearer ${accessToken}` }, data.getHeaders()),
maxContentLength: Infinity,
maxBodyLength: Infinity,
data: data,
};
const res = yield (0, axios_1.default)(config);
return res;
}
catch (e) {
errorAssets.push({
file: asset.filePath,
reason: e,
response: (_a = e.response) === null || _a === void 0 ? void 0 : _a.data,
});
return null;
}
});
}
function getAlbumsFromServer(endpoint, accessToken) {
return __awaiter(this, void 0, void 0, function* () {
try {
const res = yield axios_1.default.get(`${endpoint}/album`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return res.data;
}
catch (e) {
log(chalk_1.default.red("Error getting albums"), e);
process.exit(1);
}
});
}
function createAlbum(endpoint, accessToken, albumName) {
return __awaiter(this, void 0, void 0, function* () {
try {
const res = yield axios_1.default.post(`${endpoint}/album`, { albumName }, {
headers: { Authorization: `Bearer ${accessToken} ` },
});
return res.data.id;
}
catch (e) {
log(chalk_1.default.red(`Error creating album '${albumName}'`), e);
}
});
}
function addAssetsToAlbum(endpoint, accessToken, albumId, assetIds) {
return __awaiter(this, void 0, void 0, function* () {
try {
yield axios_1.default.put(`${endpoint}/album/${albumId}/assets`, { assetIds: [...new Set(assetIds)] }, {
headers: { Authorization: `Bearer ${accessToken} ` },
});
}
catch (e) {
log(chalk_1.default.red("Error adding asset to album"), e);
}
});
}
function getAssetInfoFromServer(endpoint, accessToken, deviceId) {
return __awaiter(this, void 0, void 0, function* () {
try {
const res = yield axios_1.default.get(`${endpoint}/asset/${deviceId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return res.data;
}
catch (e) {
log(chalk_1.default.red("Error getting device's uploaded assets"));
process.exit(1);
}
});
}
function pingServer(endpoint) {
return __awaiter(this, void 0, void 0, function* () {
try {
const res = yield axios_1.default.get(`${endpoint}/server-info/ping`);
if (res.data["res"] == "pong") {
log(chalk_1.default.green("Server status: OK"));
}
}
catch (e) {
log(chalk_1.default.red("Error connecting to server - check server address and port"));
process.exit(1);
}
});
}
function login(endpoint, email, password) {
return __awaiter(this, void 0, void 0, function* () {
try {
const res = yield axios_1.default.post(`${endpoint}/auth/login`, {
email,
password,
});
if (res.status == 201) {
log(chalk_1.default.green("Login status: OK"));
return res.data;
}
}
catch (e) {
log(chalk_1.default.red("Error logging in - check email and password"));
process.exit(1);
}
});
}
function getAssetType(filePath) {
const mimeType = mime.lookup(filePath);
return mimeType.split("/")[0].toUpperCase();
}
// node bin/index.js upload --email testuser@email.com --password password --server http://10.1.15.216:2283/api -d /Users/alex/Documents/immich-cli-upload-test-location
// node bin/index.js upload --help