UNPKG

@souptik/immich

Version:
705 lines (704 loc) 32.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 cliProgress = __importStar(require("cli-progress")); const commander_1 = require("commander"); const fdir_1 = require("fdir"); const form_data_1 = __importDefault(require("form-data")); const fs = __importStar(require("fs")); const promises_1 = require("fs/promises"); const path = __importStar(require("path")); const readline = __importStar(require("readline")); const si = __importStar(require("systeminformation")); // GLOBAL const chalk_1 = __importDefault(require("chalk")); const mime = __importStar(require("mime-types")); const moment_1 = __importDefault(require("moment")); const p_limit_1 = __importDefault(require("p-limit")); const package_json_1 = __importDefault(require("../package.json")); 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', 'image/x-nikon-nef', // VIDEO 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/3gpp', ]; commander_1.program.name('immich').description('Immich command line interface').version(package_json_1.default.version); commander_1.program .command('upload') .description('Upload assets to an Immich instance') .usage('upload [options] <paths...>') .addOption(new commander_1.Option('-k, --key <value>', 'API Key').env('IMMICH_API_KEY')) .addOption(new commander_1.Option('-s, --server <value>', 'Immich server address (http://<your-ip>:2283/api or https://<your-domain>/api)').env('IMMICH_SERVER_ADDRESS')) .addOption(new commander_1.Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .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 <num>', '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')) .addOption(new commander_1.Option('-i, --import', 'Import instead of upload').env('IMMICH_IMPORT').default(false)) .addOption(new commander_1.Option('-id, --device-uuid <value>', 'Set a device UUID').env('IMMICH_DEVICE_UUID')) .addOption(new commander_1.Option('-d, --directory <value>', 'Upload assets recursively from the specified directory (DEPRECATED, use path argument with --recursive instead)').env('IMMICH_TARGET_DIRECTORY')) .argument('[paths...]', 'One or more paths to assets to be uploaded') .action((paths, options) => { if (options.directory) { if (paths.length > 0) { log(chalk_1.default.red("Error: Can't use deprecated --directory option when specifying paths")); process.exit(1); } if (options.recursive) { log(chalk_1.default.red("Error: Can't use deprecated --directory option together with --recursive")); process.exit(1); } log(chalk_1.default.yellow('Warning: deprecated option --directory used, this will be removed in a future release. Please specify paths with --recursive instead')); paths.push(options.directory); options.recursive = true; } else { if (paths.length === 0) { // If no path argument is given, check if an env variable is set const envPath = process.env.IMMICH_ASSET_PATH; if (!envPath) { log(chalk_1.default.red('Error: Must specify at least one path')); process.exit(1); } else { paths = [envPath]; } } } upload(paths, options); }); commander_1.program .command('delete') .description('Delete assets from an Immich instance') .usage('delete [options] <paths...>') .addOption(new commander_1.Option('-k, --key <value>', 'API Key').env('IMMICH_API_KEY')) .addOption(new commander_1.Option('-s, --server <value>', 'Immich server address (http://<your-ip>:2283/api or https://<your-domain>/api)').env('IMMICH_SERVER_ADDRESS')) .addOption(new commander_1.Option('-y, --yes', 'Assume yes on all interactive prompts').env('IMMICH_ASSUME_YES')) .addOption(new commander_1.Option('-t, --threads <num>', 'Amount of concurrent upload threads (default=5)').env('IMMICH_UPLOAD_THREADS')) .addOption(new commander_1.Option('-al, --album [album]', 'Album\'s image to delete. This will delete images in the specified album only.').env('IMMICH_DELETE_ALBUMS_IMAGE')) .addOption(new commander_1.Option('-d, --date [date]', 'Images of this date will be deleted. If you provided time with the date then 24 hours from that time will be considered.').env('IMMICH_DELETE_DATE')) .addOption(new commander_1.Option('-dll, --date_lower_limit [date_lower_limit]', 'Time bucket\'s lower limit').env('IMMICH_DELETE_DATE_LOWER_LIMIT')) .addOption(new commander_1.Option('-dul, --date_upper_limit [date_upper_limit]', 'Time bucket\'s upper limit.').env('IMMICH_DELETE_DATE_UPPER_LIMIT')) .addOption(new commander_1.Option('-id, --device-uuid <value>', 'Set a device UUID').env('IMMICH_DEVICE_UUID')) .action((options) => { if ((options === null || options === void 0 ? void 0 : options.album) == undefined && (options === null || options === void 0 ? void 0 : options.date_lower_limit) == undefined && (options === null || options === void 0 ? void 0 : options.date_upper_limit) == undefined && (options === null || options === void 0 ? void 0 : options.date) == undefined) { log(chalk_1.default.red("Error: Either specify --all option to delete all assets, or provide one of the following asset: --album, -date, --date_lower_limit, --date_upper_limit")); process.exit(1); } delete_assets(options); }); commander_1.program.parse(process.argv); function delete_assets({ key, server, recursive, yes: assumeYes, threads: uploadThreads, album, date: deleteDate, date_lower_limit: dateLowerLimit, date_upper_limit: dateUpperLimit, deviceUuid: deviceUuid, }) { return __awaiter(this, void 0, void 0, function* () { const endpoint = server; const deviceId = deviceUuid || (yield si.uuid()).os || 'CLI'; const osInfo = (yield si.osInfo()).distro; let timeLowerLimitEpoch = (dateLowerLimit == undefined) ? undefined : Date.parse(dateLowerLimit); let timeUpperLimitEpoch = (dateUpperLimit == undefined) ? undefined : Date.parse(dateUpperLimit); if (deleteDate != undefined) { dateLowerLimit = deleteDate; dateUpperLimit = deleteDate; timeLowerLimitEpoch = Date.parse(deleteDate); timeUpperLimitEpoch = new Date(deleteDate); timeUpperLimitEpoch.setUTCHours(timeUpperLimitEpoch.getUTCHours() + 23, timeUpperLimitEpoch.getUTCMinutes() + 59, timeUpperLimitEpoch.getUTCSeconds() + 59); } if (Number.isNaN(timeLowerLimitEpoch) || Number.isNaN(timeUpperLimitEpoch)) { log(chalk_1.default.red(`Invalid date format.`)); process.exit(1); } // Ping server log('Checking connectivity with Immich instance...'); yield pingServer(endpoint); // Login log('Checking credentials...'); const user = yield validateConnection(endpoint, key); log(chalk_1.default.green(`Successful authentication for user ${user.email}`)); const assetsToDelete = []; // Get the assets to be deleted. if (album) { const album_info = yield get_album_info(key, server, album); album_info === null || album_info === void 0 ? void 0 : album_info.assets.forEach((el) => { // Check if the asset is within the desired time bucket. if (timeLowerLimitEpoch != undefined && timeLowerLimitEpoch > new Date(el.fileCreatedAt).getTime()) { return; } if (timeUpperLimitEpoch != undefined && timeUpperLimitEpoch < new Date(el.fileCreatedAt).getTime()) { return; } assetsToDelete.push(el.id); }); } else { const assets_by_time_bucket = yield get_assets_by_time_bucket(key, server, dateLowerLimit, dateUpperLimit); assets_by_time_bucket.forEach((el) => { // Check if the asset is within the desired time bucket. if (timeLowerLimitEpoch != undefined && timeLowerLimitEpoch > new Date(el.fileCreatedAt).getTime()) { return; } if (timeUpperLimitEpoch != undefined && timeUpperLimitEpoch < new Date(el.fileCreatedAt).getTime()) { return; } assetsToDelete.push(el.id); }); } log(chalk_1.default.green(`A total of ${assetsToDelete.length} assets found to be deleted.`)); if (assetsToDelete.length == 0) { process.exit(0); } // 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 deletion now? (y/n) ', resolve); }); if (answer == 'n') { log(chalk_1.default.yellow('Abort Delete Process')); process.exit(1); } if (answer == 'y') { log(chalk_1.default.green('Start deleting...')); const progressBar = new cliProgress.SingleBar({ format: 'Delete Progress | {bar} | {percentage}% || {value}/{total}', }, cliProgress.Presets.shades_classic); progressBar.start(Math.ceil(assetsToDelete.length / 20), 0); const deleteQueue = []; const limit = (0, p_limit_1.default)(uploadThreads !== null && uploadThreads !== void 0 ? uploadThreads : 5); var deletePayload = []; assetsToDelete.forEach((asset, index) => { deletePayload.push(asset); if (deletePayload.length >= 20 || index >= assetsToDelete.length - 1) { const deletePayloadBatch = deletePayload; deleteQueue.push(limit(() => __awaiter(this, void 0, void 0, function* () { try { const config = { method: 'delete', maxRedirects: 0, url: `${server}/asset`, headers: { 'x-api-key': key, 'Content-Type': 'application/json', 'Accept': 'application/json' }, data: JSON.stringify({ ids: deletePayloadBatch }), maxContentLength: Infinity, maxBodyLength: Infinity, }; yield (0, axios_1.default)(config); progressBar.increment(1); } catch (err) { log(chalk_1.default.red(err.message)); } }))); deletePayload = []; } }); yield Promise.all(deleteQueue); progressBar.stop(); for (const error of errorAssets) { log("Error asset: ", error); } 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 get_assets_by_time_bucket(key, server, timeLowerLimit, timeUpperLimit) { return __awaiter(this, void 0, void 0, function* () { const timeBucket = dateRange(timeLowerLimit, timeUpperLimit); let payload = { timeBucket, withoutThumbs: false }; try { const config = { method: 'post', maxRedirects: 0, url: `${server}/asset/time-bucket`, headers: { 'x-api-key': key, 'Content-Type': 'application/json', 'Accept': 'application/json' }, data: JSON.stringify(payload), maxContentLength: Infinity, maxBodyLength: Infinity, }; const res = yield (0, axios_1.default)(config); return res.data; } catch (error) { log(chalk_1.default.red('Error: Fetching assets by time bucket.')); process.exit(1); } }); } function get_album_info(key, server, album) { return __awaiter(this, void 0, void 0, function* () { try { const config = { method: 'get', maxRedirects: 0, url: `${server}/album/${album}`, headers: { 'x-api-key': key, 'Accept': 'application/json' }, maxContentLength: Infinity, maxBodyLength: Infinity, }; const res = yield (0, axios_1.default)(config); return res.data; } catch (error) { log(chalk_1.default.red('Error: Fetching assets by album.')); process.exit(1); } }); } function dateRange(startDate, endDate) { const startDateEpoch = (startDate == undefined) ? Date.now() : Date.parse(startDate); const endDateEpoch = (endDate == undefined) ? Date.now() : Date.parse(endDate); if (Number.isNaN(startDateEpoch) || Number.isNaN(endDateEpoch)) { log(chalk_1.default.red(`Invalid date format.`)); process.exit(1); } let startDateParsed = (0, moment_1.default)(startDateEpoch); let endDateParsed = (0, moment_1.default)(endDateEpoch); var dates = []; var month = (0, moment_1.default)(startDateParsed); if (month > endDateParsed) { log(chalk_1.default.red('Error: Upper limit can\'t be less than lower limit.')); process.exit(1); } while (month <= endDateParsed) { dates.push(new Date(month.format('YYYY-MM-01')).toISOString()); month.add(1, "month"); } return dates; } function upload(paths, { key, server, recursive, yes: assumeYes, delete: deleteAssets, uploadThreads, album: createAlbums, deviceUuid: deviceUuid, import: doImport, }) { var _a; return __awaiter(this, void 0, void 0, function* () { const endpoint = server; const deviceId = deviceUuid || (yield si.uuid()).os || 'CLI'; const osInfo = (yield si.osInfo()).distro; const localAssets = []; // Ping server log('Checking connectivity with Immich instance...'); yield pingServer(endpoint); // Login log('Checking credentials...'); const user = yield validateConnection(endpoint, key); log(chalk_1.default.green(`Successful authentication for user ${user.email}`)); // Index provided directory log('Indexing local assets...'); let crawler = new fdir_1.fdir().withFullPaths(); if (!recursive) { // Don't go into subfolders crawler = crawler.withMaxDepth(0); } const files = []; for (const newPath of paths) { try { // Check if the path can be accessed fs.accessSync(newPath); } catch (e) { log(chalk_1.default.red(e)); process.exit(1); } const stats = fs.lstatSync(newPath); if (stats.isDirectory()) { // Path is a directory so use the crawler to crawl it (potentially very large list) const children = crawler.crawl(newPath).sync(); for (const child of children) { files.push(child); } } else { // Path is a single file files.push(path.resolve(newPath)); } } // Ensure that list of files only has unique entries const uniqueFiles = new Set(files); for (const filePath of uniqueFiles) { const mimeType = mime.lookup(filePath); if (SUPPORTED_MIME.includes(mimeType)) { try { const fileStat = fs.statSync(filePath); localAssets.push({ id: `${path.basename(filePath)}-${fileStat.size}`.replace(/\s+/g, ''), filePath, }); } catch (e) { errorAssets.push({ file: filePath, reason: e, response: (_a = e.response) === null || _a === void 0 ? void 0 : _a.data, }); continue; } } } if (localAssets.length == 0) { log('No local assets found, exiting'); process.exit(0); } log(`Indexing complete, found ${localAssets.length} local assets`); log('Comparing local assets with those on the Immich instance...'); const backupAsset = yield getAssetInfoFromServer(endpoint, key, deviceId); const newAssets = localAssets.filter((a) => !backupAsset.includes(a.id)); if (localAssets.length == 0 || (newAssets.length == 0 && !createAlbums)) { log(chalk_1.default.green('All assets have been backed up to the server')); process.exit(0); } else { log(chalk_1.default.green(`A total of ${newAssets.length} assets will be uploaded to the server`)); } if (createAlbums) { log(chalk_1.default.green(`A total of ${localAssets.length} assets will be added to album(s).\n` + 'NOTE: some assets may already be associated with the album, this will not create duplicates.')); } // 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, key, asset, deviceId, doImport); progressBar.increment(1, { filepath: asset.filePath }); if (res && (res.status == 201 || res.status == 200)) { 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: { 'x-api-key': key }, }); 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, key); 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, key, localAlbum); } if (albumId) { yield addAssetsToAlbum(endpoint, key, 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, key, createAlbums); } yield addAssetsToAlbum(endpoint, key, albumId, Array.from(assetDirectoryMap.values()).flat()); } } // log(chalk.yellow(`Failed to upload ${errorAssets.length} files `), errorAssets); for (const error of errorAssets) { console.log("Error asset: ", error); } 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, key, asset, deviceId, doImport) { 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); const data = { deviceAssetId: asset.id, deviceId, assetType, fileCreatedAt: fileStat.mtime.toISOString(), fileModifiedAt: fileStat.mtime.toISOString(), isFavorite: String(false), fileExtension: path.extname(asset.filePath), duration: '0:00:00.000000', isReadOnly: doImport ? String(true) : String(false), }; const formData = new form_data_1.default(); if (!doImport) { for (const prop in data) { formData.append(prop, data[prop]); } formData.append("assetData", fs.createReadStream(asset.filePath)); } else { data.assetPath = asset.filePath; } try { yield fs.promises.access(`${asset.filePath}.xmp`, fs.constants.W_OK); if (doImport) { data.sidecarPath = path.resolve(`${asset.filePath}.xmp`); } else { formData.append("sidecarData", fs.createReadStream(path.resolve(`${asset.filePath}.xmp`)), { contentType: 'application/xml' }); } } catch (e) { } const config = { method: 'post', maxRedirects: 0, url: doImport == true ? `${endpoint}/asset/import` : `${endpoint}/asset/upload`, headers: Object.assign({ 'x-api-key': key }, (doImport == false && formData.getHeaders())), maxContentLength: Infinity, maxBodyLength: Infinity, data: doImport ? data : formData, }; 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, key) { return __awaiter(this, void 0, void 0, function* () { try { const res = yield axios_1.default.get(`${endpoint}/album`, { headers: { 'x-api-key': key }, }); return res.data; } catch (e) { log(chalk_1.default.red('Error getting albums'), e); process.exit(1); } }); } function createAlbum(endpoint, key, albumName) { return __awaiter(this, void 0, void 0, function* () { try { const res = yield axios_1.default.post(`${endpoint}/album`, { albumName }, { headers: { 'x-api-key': key }, }); return res.data.id; } catch (e) { log(chalk_1.default.red(`Error creating album '${albumName}'`), e); } }); } function addAssetsToAlbum(endpoint, key, 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: { 'x-api-key': key }, }); } catch (e) { log(chalk_1.default.red('Error adding asset to album'), e); } }); } function getAssetInfoFromServer(endpoint, key, deviceId) { return __awaiter(this, void 0, void 0, function* () { try { const res = yield axios_1.default.get(`${endpoint}/asset/${deviceId}`, { headers: { 'x-api-key': key }, }); 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 validateConnection(endpoint, key) { return __awaiter(this, void 0, void 0, function* () { try { const res = yield axios_1.default.get(`${endpoint}/user/me`, { headers: { 'x-api-key': key }, }); if (res.status == 200) { log(chalk_1.default.green('Login status: OK')); return res.data; } } catch (e) { log(chalk_1.default.red('Error logging in - check api key')); process.exit(1); } }); } function getAssetType(filePath) { const mimeType = mime.lookup(filePath); return mimeType.split('/')[0].toUpperCase(); }