UNPKG

@conneryn/immich

Version:
418 lines (417 loc) 18.9 kB
#! /usr/bin/env node "use strict"; 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