cloudapp-dl
Version:
CloudApp/Zight API client and CLI. Use as a CLI tool to download videos or as a programmatic library to interact with the Zight API.
2,223 lines • 78.1 kB
JavaScript
import fs from 'fs';
import path from 'path';
import https from 'https';
import axios from 'axios';
import * as cheerio from 'cheerio';
import { login as authLogin, logout as authLogout } from './auth.js';
import { getAccountDetails, getItemsFromDashboard, getAllItems, getAllCollections, createVideoRequest, getVideoRequests, getVideoRequest, updateVideoRequest, deleteVideoRequest, getItem, uploadFile, deleteItem, restoreItem, emptyTrash, getTrashItems, getNotifications, markAllNotificationsViewed } from './api.js';
import { loadConfig, clearConfig, getConfigPath, isLoggedIn } from './config.js';
import { prompt, promptPassword, confirm } from './prompt.js';
/**
* Parse a user-friendly date string to ISO format
* Supports formats like:
* - "12/21/2025 3:00pm"
* - "12/21/2025 15:00"
* - "2025-12-21 3:00pm"
* - "12/21/2025" (assumes end of day)
* @param {string} dateStr - User input date string
* @returns {string|null} - ISO formatted date string or null if invalid
*/
const parseUserDate = (dateStr) => {
if (!dateStr || dateStr.trim() === '') return null;
dateStr = dateStr.trim();
// Try parsing various formats
let date;
// Check for MM/DD/YYYY or MM-DD-YYYY format with optional time
const usDateRegex = /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?)?$/i;
const usMatch = dateStr.match(usDateRegex);
if (usMatch) {
const [, month, day, year, hours = '23', minutes = '59', seconds = '59', ampm] = usMatch;
let hour = parseInt(hours);
if (ampm) {
if (ampm.toLowerCase() === 'pm' && hour !== 12) {
hour += 12;
} else if (ampm.toLowerCase() === 'am' && hour === 12) {
hour = 0;
}
}
date = new Date(year, parseInt(month) - 1, parseInt(day), hour, parseInt(minutes), parseInt(seconds || 0));
} else {
// Try ISO format YYYY-MM-DD with optional time
const isoDateRegex = /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?)?$/i;
const isoMatch = dateStr.match(isoDateRegex);
if (isoMatch) {
const [, year, month, day, hours = '23', minutes = '59', seconds = '59', ampm] = isoMatch;
let hour = parseInt(hours);
if (ampm) {
if (ampm.toLowerCase() === 'pm' && hour !== 12) {
hour += 12;
} else if (ampm.toLowerCase() === 'am' && hour === 12) {
hour = 0;
}
}
date = new Date(year, parseInt(month) - 1, parseInt(day), hour, parseInt(minutes), parseInt(seconds || 0));
} else {
// Try native Date parsing as fallback
date = new Date(dateStr);
}
}
// Validate the date
if (!date || isNaN(date.getTime())) {
return null;
}
return date.toISOString();
};
/**
* Delay helper
*/
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
/**
* Get random delay between min and max milliseconds
*/
const randomDelay = (min = 500, max = 2000) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
/**
* Ensure directory exists
*/
const ensureDirectoryExists = (directoryPath) => {
if (!fs.existsSync(directoryPath)) {
fs.mkdirSync(directoryPath, { recursive: true });
}
};
/**
* Fetch the actual download URL from a Zight share page
* @param {string} pageUrl - The share page URL
* @returns {Promise<Object>} - Object with title and mp4Url
*/
const fetchDownloadUrl = async (pageUrl) => {
try {
const response = await axios.get(pageUrl);
const pageData = response.data;
const $ = cheerio.load(pageData);
const twitterImageValue = $('meta[name="twitter:image"]').attr('value');
const videoTitle = $('meta[property="og:title"]').attr('content');
if (!twitterImageValue) {
throw new Error('Could not find media URL');
}
const firstSplit = twitterImageValue.split('.gif/');
if (firstSplit.length < 2) {
// Try to get direct download for non-video files
const ogUrl = $('meta[property="og:url"]').attr('content');
return { title: videoTitle, downloadUrl: null, isMedia: false, pageUrl: ogUrl || pageUrl };
}
const secondSplit = firstSplit[1].split('?source');
if (secondSplit.length === 0) {
throw new Error('Could not parse media URL');
}
const mp4Url = `https://${secondSplit[0]}`;
return { title: videoTitle, downloadUrl: mp4Url, isMedia: true };
} catch (error) {
throw error;
}
};
/**
* Download a file from URL to disk
* @param {string} url - The download URL
* @param {string} filename - The output filename
* @returns {Promise<boolean>}
*/
const downloadFile = (url, filename) => {
return new Promise((resolve, reject) => {
const directory = path.dirname(filename);
ensureDirectoryExists(directory);
const file = fs.createWriteStream(filename);
https.get(url, function (response) {
// Handle redirects
if (response.statusCode === 301 || response.statusCode === 302) {
file.close();
fs.unlinkSync(filename);
return downloadFile(response.headers.location, filename).then(resolve).catch(reject);
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve(true);
});
file.on('error', (err) => {
file.close();
fs.unlinkSync(filename);
reject(err);
});
}).on('error', (err) => {
file.close();
fs.unlinkSync(filename);
reject(err);
});
});
};
/**
* Handle login command
* @param {Object} argv - Command arguments
*/
export const handleLogin = async (argv) => {
try {
let email = argv.email;
let password = argv.password;
// Prompt for email if not provided
if (!email) {
email = await prompt('Email: ');
}
// Prompt for password if not provided
if (!password) {
password = await promptPassword('Password: ');
}
if (!email || !password) {
console.error('Email and password are required');
process.exit(1);
}
const result = await authLogin(email, password);
console.log('\n✓ Login successful!');
console.log(` Session expires: ${new Date(result.sessionExpiry).toLocaleString()}`);
console.log(` Config saved to: ${getConfigPath()}`);
// Fetch and display account info
try {
const accountData = await getAccountDetails();
const user = accountData.data?.user;
if (user) {
console.log(`\n Welcome, ${user.attributes.name}!`);
console.log(` Email: ${user.attributes.email}`);
console.log(` Items: ${user.attributes.item_count}`);
}
} catch (e) {
// Account fetch failed, but login was successful
}
} catch (error) {
console.error('\n✗ Login failed:', error.message);
process.exit(1);
}
};
/**
* Handle logout command
*/
export const handleLogout = async () => {
if (!isLoggedIn()) {
console.log('Not currently logged in.');
return;
}
authLogout();
console.log('✓ Logged out successfully');
console.log(` Session cleared from: ${getConfigPath()}`);
};
/**
* Handle account command - display account details
*/
export const handleAccount = async () => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
console.log('Fetching account details...\n');
const response = await getAccountDetails();
const user = response.data?.user;
if (!user) {
console.error('Failed to retrieve account details');
process.exit(1);
}
const attrs = user.attributes;
console.log('╔══════════════════════════════════════════╗');
console.log('║ ZIGHT ACCOUNT DETAILS ║');
console.log('╠══════════════════════════════════════════╣');
console.log(`║ Name: ${attrs.name.padEnd(28)}║`);
console.log(`║ Email: ${attrs.email.padEnd(28)}║`);
console.log(`║ User ID: ${user.id.padEnd(28)}║`);
console.log(`║ Items: ${String(attrs.item_count).padEnd(28)}║`);
console.log(`║ Plan: ${(attrs.is_free ? 'Free' : 'Paid').padEnd(28)}║`);
console.log(`║ Drop Limit: ${String(attrs.drop_limit).padEnd(28)}║`);
console.log(`║ Profile: ${(attrs.profile || 'N/A').padEnd(28)}║`);
console.log(`║ Created: ${new Date(attrs.created_at).toLocaleDateString().padEnd(28)}║`);
console.log('╚══════════════════════════════════════════╝');
// Show organizations
const orgs = user.relationships?.organizations?.data;
if (orgs && orgs.length > 0) {
console.log('\nOrganizations:');
orgs.forEach(org => {
console.log(` • ${org.id}`);
});
}
// Show session status
const config = loadConfig();
if (config.sessionExpiry) {
const expiry = new Date(config.sessionExpiry);
const now = new Date();
const hoursLeft = Math.max(0, (expiry - now) / (1000 * 60 * 60));
console.log(`\nSession Status: ${hoursLeft.toFixed(1)} hours remaining`);
}
} catch (error) {
console.error('Failed to fetch account details:', error.message);
process.exit(1);
}
};
/**
* Handle config command - show or clear config
* @param {Object} argv - Command arguments
*/
export const handleConfig = async (argv) => {
if (argv.clear) {
const confirmed = await confirm('Are you sure you want to clear all config data?');
if (confirmed) {
clearConfig();
console.log('✓ Config cleared');
} else {
console.log('Cancelled');
}
return;
}
if (argv.path) {
console.log(getConfigPath());
return;
}
// Show current config (hide sensitive data)
const config = loadConfig();
console.log('\nCurrent Configuration:');
console.log('─────────────────────────────────────');
console.log(`Config file: ${getConfigPath()}`);
console.log(`Email: ${config.email || '(not set)'}`);
console.log(`Password: ${config.password ? '********' : '(not set)'}`);
console.log(`Session ID: ${config.sessionId ? config.sessionId.substring(0, 20) + '...' : '(not set)'}`);
console.log(`Session Expiry: ${config.sessionExpiry ? new Date(config.sessionExpiry).toLocaleString() : '(not set)'}`);
console.log(`User ID: ${config.userId || '(not set)'}`);
console.log(`User Name: ${config.userName || '(not set)'}`);
};
/**
* Handle list command - list drops/files
* @param {Object} argv - Command arguments
*/
export const handleList = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const page = argv.page || 1;
let perPage = argv.perPage || 12;
// Ensure perPage is a multiple of 12 (round up)
if (perPage % 12 !== 0) {
perPage = Math.ceil(perPage / 12) * 12;
console.log(`Note: Items per page rounded up to ${perPage} (must be multiple of 12)\n`);
}
const pagesToFetch = perPage / 12;
if (pagesToFetch > 1) {
console.log(`Fetching ${pagesToFetch} pages (${perPage} items) with delays to avoid rate limiting...\n`);
} else {
console.log(`Fetching items (page ${page})...\n`);
}
// Progress callback
const onProgress = (currentFetch, totalFetches, realPage) => {
if (totalFetches > 1) {
process.stdout.write(`\r Fetching page ${currentFetch}/${totalFetches} (real page ${realPage})...`);
}
};
const { items, pagination } = await getItemsFromDashboard({
page,
perPage,
onProgress
});
// Clear the progress line if we fetched multiple pages
if (pagesToFetch > 1) {
process.stdout.write('\r' + ' '.repeat(60) + '\r');
}
if (!items || items.length === 0) {
console.log('No items found on this page.');
return;
}
// Calculate column widths
const maxTitleLen = 35;
const maxExtLen = 6;
const maxViewsLen = 8;
const maxDateLen = 20;
console.log('┌─────┬─────────────────────────────────────┬────────┬──────────┬──────────────────────┬────────────────────────────────────────────────┐');
console.log('│ # │ Title │ Type │ Views │ Date │ URL │');
console.log('├─────┼─────────────────────────────────────┼────────┼──────────┼──────────────────────┼────────────────────────────────────────────────┤');
// Calculate base item number for this page
const baseItemNum = (page - 1) * perPage;
items.forEach((item, index) => {
const num = String(baseItemNum + index + 1).padStart(3);
const title = item.title.substring(0, maxTitleLen).padEnd(maxTitleLen);
const ext = item.fileExt.substring(0, maxExtLen).padEnd(maxExtLen);
const views = String(item.viewCount).padEnd(maxViewsLen);
const date = item.createdAt.substring(0, maxDateLen).padEnd(maxDateLen);
const url = item.url.substring(0, 46).padEnd(46);
console.log(`│ ${num} │ ${title} │ ${ext} │ ${views} │ ${date} │ ${url} │`);
});
console.log('└─────┴─────────────────────────────────────┴────────┴──────────┴──────────────────────┴────────────────────────────────────────────────┘');
// Show pagination info
console.log(`\nPage ${pagination.currentPage} of ${pagination.totalPages} | Total items: ${pagination.totalItems} | Showing: ${items.length} items`);
if (pagination.realPagesPerRequest > 1) {
console.log(`(Fetched real pages ${pagination.startRealPage}-${pagination.endRealPage})`);
}
if (pagination.hasNextPage) {
console.log(`Next page: cloudapp-dl list --page ${pagination.currentPage + 1}${perPage !== 12 ? ` --per-page ${perPage}` : ''}`);
}
if (pagination.hasPrevPage) {
console.log(`Previous page: cloudapp-dl list --page ${pagination.currentPage - 1}${perPage !== 12 ? ` --per-page ${perPage}` : ''}`);
}
} catch (error) {
console.error('Failed to fetch items:', error.message);
process.exit(1);
}
};
/**
* Handle whoami command - quick status check
*/
export const handleWhoami = async () => {
const config = loadConfig();
if (!isLoggedIn()) {
console.log('Not logged in');
return;
}
console.log(`Logged in as: ${config.email}`);
if (config.userName) {
console.log(`Name: ${config.userName}`);
}
if (config.sessionExpiry) {
const expiry = new Date(config.sessionExpiry);
const now = new Date();
if (expiry > now) {
const hoursLeft = (expiry - now) / (1000 * 60 * 60);
console.log(`Session: Valid (${hoursLeft.toFixed(1)} hours remaining)`);
} else {
console.log('Session: Expired (will auto-refresh on next API call)');
}
}
};
/**
* Escape CSV field value
* @param {string} value - The value to escape
* @returns {string} - Escaped value
*/
const escapeCSV = (value) => {
if (value === null || value === undefined) return '';
const str = String(value);
// If contains comma, quote, or newline, wrap in quotes and escape internal quotes
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
/**
* Convert items to CSV string
* @param {Array} items - Array of item objects
* @returns {string} - CSV formatted string
*/
const itemsToCSV = (items) => {
const headers = ['#', 'ID', 'Title', 'Type', 'Views', 'Date', 'URL', 'Is Video', 'Thumbnail'];
const rows = [headers.join(',')];
items.forEach((item, index) => {
const row = [
index + 1,
escapeCSV(item.id),
escapeCSV(item.title),
escapeCSV(item.fileExt),
item.viewCount,
escapeCSV(item.createdAt),
escapeCSV(item.url),
item.isVideo ? 'Yes' : 'No',
escapeCSV(item.thumbnail)
];
rows.push(row.join(','));
});
return rows.join('\n');
};
/**
* Handle export command - export items to CSV
* @param {Object} argv - Command arguments
*/
export const handleExport = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const startPage = argv.startPage || null;
const endPage = argv.endPage || null;
const outputFile = argv.out || `zight-export-${new Date().toISOString().split('T')[0]}.csv`;
// Determine if we're exporting all or specific pages
const exportAll = !startPage && !endPage;
if (exportAll) {
console.log('Exporting ALL items to CSV...');
console.log('This may take a while depending on your total items.\n');
} else {
const pageInfo = startPage && endPage
? `pages ${startPage}-${endPage}`
: startPage
? `from page ${startPage}`
: `up to page ${endPage}`;
console.log(`Exporting ${pageInfo} to CSV...\n`);
}
// Progress callback
const onProgress = (currentFetch, totalFetches, itemsFetched, realPage) => {
const percent = ((currentFetch / totalFetches) * 100).toFixed(1);
process.stdout.write(`\r Progress: ${percent}% | Page ${currentFetch}/${totalFetches} (real page ${realPage}) | Items: ${itemsFetched} `);
};
const startTime = Date.now();
const { items, metadata } = await getAllItems({
startPage: startPage || 1,
endPage: endPage,
onProgress
});
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
// Clear progress line
process.stdout.write('\r' + ' '.repeat(80) + '\r');
if (!items || items.length === 0) {
console.log('No items found to export.');
return;
}
console.log(`✓ Fetched ${items.length} items in ${elapsed}s`);
console.log(` Pages fetched: ${metadata.fetchedPages} (${metadata.startPage}-${metadata.endPage})`);
// Convert to CSV
const csvContent = itemsToCSV(items);
// Write to file
const outputPath = path.resolve(outputFile);
fs.writeFileSync(outputPath, csvContent, 'utf8');
console.log(`\n✓ Exported to: ${outputPath}`);
console.log(` Total items: ${items.length}`);
console.log(` File size: ${(Buffer.byteLength(csvContent, 'utf8') / 1024).toFixed(1)} KB`);
} catch (error) {
console.error('\nFailed to export:', error.message);
process.exit(1);
}
};
/**
* Sanitize filename - remove invalid characters
* @param {string} filename - The filename to sanitize
* @returns {string} - Sanitized filename
*/
const sanitizeFilename = (filename) => {
return filename
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, '_')
.substring(0, 200); // Limit length
};
/**
* Get file extension from item
* @param {Object} item - The item object
* @returns {string} - File extension
*/
const getFileExtension = (item) => {
if (item.fileExt && item.fileExt.startsWith('.')) {
return item.fileExt;
}
if (item.isVideo) return '.mp4';
return item.fileExt || '.bin';
};
/**
* Handle download by ID command
* @param {Object} argv - Command arguments
*/
export const handleDownloadById = async (argv) => {
try {
const fileId = argv.id;
const outputDir = argv.out || '.';
const useTitle = argv.title !== false;
if (!fileId) {
console.error('Please provide a file ID with --id');
process.exit(1);
}
console.log(`Fetching download URL for ${fileId}...`);
// Construct the share URL
const shareUrl = `https://share.zight.com/${fileId}`;
try {
const urlInfo = await fetchDownloadUrl(shareUrl);
if (!urlInfo.downloadUrl) {
console.error(`Cannot download ${fileId} - not a downloadable media file`);
process.exit(1);
}
// Determine filename
let filename;
if (useTitle && urlInfo.title) {
filename = sanitizeFilename(urlInfo.title) + '.mp4';
} else {
filename = `${fileId}.mp4`;
}
const outputPath = path.join(outputDir, filename);
ensureDirectoryExists(outputDir);
console.log(`Downloading to ${outputPath}...`);
await downloadFile(urlInfo.downloadUrl, outputPath);
console.log(`✓ Downloaded: ${outputPath}`);
} catch (error) {
console.error(`Failed to download ${fileId}: ${error.message}`);
process.exit(1);
}
} catch (error) {
console.error('Download failed:', error.message);
process.exit(1);
}
};
/**
* Handle bulk download command
* @param {Object} argv - Command arguments
*/
export const handleBulkDownload = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const startPage = argv.startPage || null;
const endPage = argv.endPage || null;
const outputDir = argv.out || './downloads';
const useTitle = argv.title !== false;
const videosOnly = argv.videosOnly || false;
const timeout = argv.timeout || 2000;
const limit = argv.limit || null;
// Determine if we're downloading all or specific pages
const downloadAll = !startPage && !endPage && !limit;
if (downloadAll) {
console.log('Bulk downloading ALL items...');
console.log('This may take a very long time depending on your total items.\n');
} else if (limit) {
console.log(`Bulk downloading up to ${limit} items...\n`);
} else {
const pageInfo = startPage && endPage
? `pages ${startPage}-${endPage}`
: startPage
? `from page ${startPage}`
: `up to page ${endPage}`;
console.log(`Bulk downloading ${pageInfo}...\n`);
}
// Ensure output directory exists
ensureDirectoryExists(outputDir);
// Progress callback for fetching
const onFetchProgress = (currentFetch, totalFetches, itemsFetched, realPage) => {
const percent = ((currentFetch / totalFetches) * 100).toFixed(1);
process.stdout.write(`\r Fetching: ${percent}% | Page ${currentFetch}/${totalFetches} | Items found: ${itemsFetched} `);
};
console.log('Step 1: Fetching item list from dashboard...');
const startTime = Date.now();
let itemsToDownload;
if (limit) {
// Fetch just enough pages to get the limit
const pagesToFetch = Math.ceil(limit / 12);
const { items } = await getAllItems({
startPage: startPage || 1,
endPage: (startPage || 1) + pagesToFetch - 1,
onProgress: onFetchProgress
});
itemsToDownload = items.slice(0, limit);
} else {
const { items } = await getAllItems({
startPage: startPage || 1,
endPage: endPage,
onProgress: onFetchProgress
});
itemsToDownload = items;
}
// Clear progress line
process.stdout.write('\r' + ' '.repeat(80) + '\r');
if (!itemsToDownload || itemsToDownload.length === 0) {
console.log('No items found to download.');
return;
}
// Filter videos only if requested
if (videosOnly) {
itemsToDownload = itemsToDownload.filter(item => item.isVideo);
console.log(` Filtered to ${itemsToDownload.length} video items`);
}
const fetchTime = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`✓ Found ${itemsToDownload.length} items to download (${fetchTime}s)\n`);
console.log('Step 2: Downloading files...');
console.log(` Output directory: ${path.resolve(outputDir)}`);
console.log(` Delay between downloads: ${timeout}ms\n`);
let downloaded = 0;
let failed = 0;
let skipped = 0;
const failedItems = [];
for (let i = 0; i < itemsToDownload.length; i++) {
const item = itemsToDownload[i];
const percent = (((i + 1) / itemsToDownload.length) * 100).toFixed(1);
process.stdout.write(`\r [${percent}%] Downloading ${i + 1}/${itemsToDownload.length}: ${item.title.substring(0, 40)}... `);
try {
// Skip non-downloadable items
if (!item.url) {
skipped++;
continue;
}
// Fetch the actual download URL
const urlInfo = await fetchDownloadUrl(item.url);
if (!urlInfo.downloadUrl) {
skipped++;
continue;
}
// Determine filename
let filename;
const ext = getFileExtension(item);
if (useTitle && item.title) {
filename = sanitizeFilename(item.title) + ext;
} else {
filename = `${item.id}${ext}`;
}
const outputPath = path.join(outputDir, filename);
// Skip if file already exists
if (fs.existsSync(outputPath)) {
skipped++;
continue;
}
await downloadFile(urlInfo.downloadUrl, outputPath);
downloaded++;
} catch (error) {
failed++;
failedItems.push({ id: item.id, title: item.title, error: error.message });
}
// Add delay between downloads (except for the last one)
if (i < itemsToDownload.length - 1) {
await delay(timeout);
}
}
// Clear progress line
process.stdout.write('\r' + ' '.repeat(100) + '\r');
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('\n╔════════════════════════════════════════╗');
console.log('║ BULK DOWNLOAD COMPLETE ║');
console.log('╠════════════════════════════════════════╣');
console.log(`║ Downloaded: ${String(downloaded).padEnd(26)}║`);
console.log(`║ Skipped: ${String(skipped).padEnd(26)}║`);
console.log(`║ Failed: ${String(failed).padEnd(26)}║`);
console.log(`║ Total time: ${(totalTime + 's').padEnd(26)}║`);
console.log('╚════════════════════════════════════════╝');
console.log(`\nFiles saved to: ${path.resolve(outputDir)}`);
if (failedItems.length > 0 && failedItems.length <= 10) {
console.log('\nFailed items:');
failedItems.forEach(item => {
console.log(` • ${item.id}: ${item.error}`);
});
} else if (failedItems.length > 10) {
console.log(`\n${failedItems.length} items failed. First 5:`);
failedItems.slice(0, 5).forEach(item => {
console.log(` • ${item.id}: ${item.error}`);
});
}
} catch (error) {
console.error('\nBulk download failed:', error.message);
process.exit(1);
}
};
/**
* Extract file ID from a Zight URL
* @param {string} url - The URL to extract ID from
* @returns {string} - The file ID
*/
const extractIdFromUrl = (url) => {
url = url.split('?')[0];
return url.split('/').pop();
};
/**
* Handle download by URL command
* @param {Object} argv - Command arguments
*/
export const handleDownloadByUrl = async (argv) => {
try {
const url = argv.url;
const outputDir = argv.out || '.';
const useTitle = argv.title !== false;
if (!url) {
console.error('Please provide a URL');
process.exit(1);
}
const fileId = extractIdFromUrl(url);
console.log(`Fetching download URL for ${fileId}...`);
try {
const urlInfo = await fetchDownloadUrl(url);
if (!urlInfo.downloadUrl) {
console.error(`Cannot download ${fileId} - not a downloadable media file`);
process.exit(1);
}
// Determine filename
let filename;
if (useTitle && urlInfo.title) {
filename = sanitizeFilename(urlInfo.title) + '.mp4';
} else {
filename = `${fileId}.mp4`;
}
const outputPath = path.join(outputDir, filename);
ensureDirectoryExists(path.dirname(outputPath));
console.log(`Downloading to ${outputPath}...`);
await downloadFile(urlInfo.downloadUrl, outputPath);
console.log(`✓ Downloaded: ${outputPath}`);
} catch (error) {
console.error(`Failed to download ${fileId}: ${error.message}`);
process.exit(1);
}
} catch (error) {
console.error('Download failed:', error.message);
process.exit(1);
}
};
/**
* Handle download list command - download multiple files from a URL list file
* @param {Object} argv - Command arguments
*/
export const handleDownloadList = async (argv) => {
try {
const listFile = argv.file;
const outputDir = argv.out || './downloads';
const useTitle = argv.title !== false;
const timeout = argv.timeout || 2000;
const prefix = argv.prefix || null;
if (!listFile) {
console.error('Please provide a list file path');
process.exit(1);
}
// Read the list file
const filePath = path.resolve(listFile);
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
const fileContent = fs.readFileSync(filePath, 'utf8');
const urls = fileContent.split(/\r?\n/).filter(line => line.trim());
if (urls.length === 0) {
console.error('No URLs found in the list file');
process.exit(1);
}
console.log(`Found ${urls.length} URLs to download`);
console.log(`Output directory: ${path.resolve(outputDir)}`);
console.log(`Delay between downloads: ${timeout}ms\n`);
ensureDirectoryExists(outputDir);
let downloaded = 0;
let failed = 0;
let skipped = 0;
const failedItems = [];
const startTime = Date.now();
for (let i = 0; i < urls.length; i++) {
const url = urls[i].trim();
if (!url) continue;
const fileId = extractIdFromUrl(url);
const percent = (((i + 1) / urls.length) * 100).toFixed(1);
process.stdout.write(`\r [${percent}%] Downloading ${i + 1}/${urls.length}: ${fileId}... `);
try {
const urlInfo = await fetchDownloadUrl(url);
if (!urlInfo.downloadUrl) {
skipped++;
failedItems.push({ id: fileId, error: 'Not a downloadable media file' });
continue;
}
// Determine filename
let filename;
if (prefix) {
filename = `${prefix}-${i + 1}.mp4`;
} else if (useTitle && urlInfo.title) {
filename = sanitizeFilename(urlInfo.title) + '.mp4';
} else {
filename = `${fileId}.mp4`;
}
const outputPath = path.join(outputDir, filename);
// Skip if file already exists
if (fs.existsSync(outputPath)) {
skipped++;
continue;
}
await downloadFile(urlInfo.downloadUrl, outputPath);
downloaded++;
} catch (error) {
failed++;
failedItems.push({ id: fileId, error: error.message });
}
// Add delay between downloads (except for the last one)
if (i < urls.length - 1) {
await delay(timeout);
}
}
// Clear progress line
process.stdout.write('\r' + ' '.repeat(80) + '\r');
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('\n╔════════════════════════════════════════╗');
console.log('║ LIST DOWNLOAD COMPLETE ║');
console.log('╠════════════════════════════════════════╣');
console.log(`║ Downloaded: ${String(downloaded).padEnd(26)}║`);
console.log(`║ Skipped: ${String(skipped).padEnd(26)}║`);
console.log(`║ Failed: ${String(failed).padEnd(26)}║`);
console.log(`║ Total time: ${(totalTime + 's').padEnd(26)}║`);
console.log('╚════════════════════════════════════════╝');
console.log(`\nFiles saved to: ${path.resolve(outputDir)}`);
if (failedItems.length > 0 && failedItems.length <= 10) {
console.log('\nFailed items:');
failedItems.forEach(item => {
console.log(` • ${item.id}: ${item.error}`);
});
} else if (failedItems.length > 10) {
console.log(`\n${failedItems.length} items failed. First 5:`);
failedItems.slice(0, 5).forEach(item => {
console.log(` • ${item.id}: ${item.error}`);
});
}
} catch (error) {
console.error('\nDownload list failed:', error.message);
process.exit(1);
}
};
/**
* Handle video request command - create a link for others to record a video
* @param {Object} argv - Command arguments
*/
export const handleVideoRequest = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
console.log('\n📹 Create Video Recording Request\n');
console.log('This will generate a link that you can share with others');
console.log('so they can record a video for you.\n');
// Get title (required)
let title = argv.title;
if (!title) {
title = await prompt('Title (required): ');
if (!title || title.trim() === '') {
console.error('Title is required');
process.exit(1);
}
}
// Get message (required)
let message = argv.message;
if (!message) {
message = await prompt('Message/Instructions (required): ');
if (!message || message.trim() === '') {
console.error('Message is required');
process.exit(1);
}
}
// Get custom ID (optional)
let customId = argv.customId;
if (customId === undefined) {
customId = await prompt('Custom ID (optional, press Enter to skip): ');
if (customId.trim() === '') customId = null;
}
// Get expiration date (optional)
let expiresAt = null;
if (argv.expires) {
expiresAt = parseUserDate(argv.expires);
if (!expiresAt) {
console.error('Invalid date format. Use formats like: 12/21/2025 3:00pm');
process.exit(1);
}
} else {
const expiresInput = await prompt('Expiration date (optional, e.g., 12/31/2025 5:00pm): ');
if (expiresInput.trim() !== '') {
expiresAt = parseUserDate(expiresInput);
if (!expiresAt) {
console.error('Invalid date format. Use formats like: 12/21/2025 3:00pm');
process.exit(1);
}
}
}
// Get collection (optional)
let collectionId = argv.collection;
if (collectionId === undefined) {
const useCollection = await confirm('Add recordings to a collection?');
if (useCollection) {
console.log('\nFetching your collections...');
try {
const collections = await getAllCollections();
if (collections.length === 0) {
console.log('No collections found. Skipping collection assignment.');
} else {
console.log('\nAvailable Collections:\n');
console.log(' 0. None (skip)');
collections.forEach((col, index) => {
const name = col.attributes.name.substring(0, 50);
const itemCount = col.attributes.items_count || 0;
console.log(` ${index + 1}. ${name} (${itemCount} items)`);
});
const selection = await prompt(`\nSelect collection (0-${collections.length}): `);
const selIndex = parseInt(selection);
if (selIndex > 0 && selIndex <= collections.length) {
collectionId = collections[selIndex - 1].id;
console.log(` Selected: ${collections[selIndex - 1].attributes.name}`);
} else {
collectionId = null;
}
}
} catch (error) {
console.log(`Warning: Could not fetch collections: ${error.message}`);
collectionId = null;
}
}
}
// Summary before creation
console.log('\n─────────────────────────────────────────');
console.log('Summary:');
console.log(` Title: ${title}`);
console.log(` Message: ${message.substring(0, 50)}${message.length > 50 ? '...' : ''}`);
console.log(` Custom ID: ${customId || '(none)'}`);
console.log(` Expires: ${expiresAt ? new Date(expiresAt).toLocaleString() : 'Never'}`);
console.log(` Collection: ${collectionId || '(none)'}`);
console.log('─────────────────────────────────────────\n');
const proceed = await confirm('Create this video request?');
if (!proceed) {
console.log('Cancelled.');
return;
}
console.log('\nCreating video request...');
const response = await createVideoRequest({
name: title,
message,
customId,
expiresAt,
collectionId
});
const requestContent = response.data?.request_content;
if (!requestContent) {
console.error('Failed to create video request');
process.exit(1);
}
const requestLink = requestContent.links?.request_link;
const slug = requestContent.attributes?.slug;
console.log('\n✅ VIDEO REQUEST CREATED!\n');
console.log('─────────────────────────────────────────');
console.log(`ID: ${requestContent.id}`);
console.log('─────────────────────────────────────────');
console.log('\n📎 Share this link:\n');
console.log(` ${requestLink}`);
if (expiresAt) {
console.log(`\n⏰ Link expires: ${new Date(expiresAt).toLocaleString()}`);
}
console.log('\nAnyone with this link can record a video');
console.log('that will appear in your Zight account.');
} catch (error) {
console.error('\nFailed to create video request:', error.message);
process.exit(1);
}
};
/**
* Handle collections list command
* @param {Object} argv - Command arguments
*/
export const handleCollections = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
console.log('Fetching collections...\n');
const collections = await getAllCollections();
if (collections.length === 0) {
console.log('No collections found.');
return;
}
console.log('┌──────────┬────────────────────────────────────────────────────────┬───────────┬──────────┐');
console.log('│ ID │ Name │ Items │ Views │');
console.log('├──────────┼────────────────────────────────────────────────────────┼───────────┼──────────┤');
collections.forEach((col) => {
const id = col.id.padEnd(8);
const name = col.attributes.name.substring(0, 54).padEnd(54);
const items = String(col.attributes.items_count || 0).padEnd(9);
const views = String(col.attributes.view_counter || 0).padEnd(8);
console.log(`│ ${id} │ ${name} │ ${items} │ ${views} │`);
});
console.log('└──────────┴────────────────────────────────────────────────────────┴───────────┴──────────┘');
console.log(`\nTotal: ${collections.length} collections`);
} catch (error) {
console.error('Failed to fetch collections:', error.message);
process.exit(1);
}
};
/**
* Handle quick video request command - instant generation with minimal input
* @param {Object} argv - Command arguments
*/
export const handleVideoRequestQuick = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const config = loadConfig();
const email = config.email || 'User';
// Generate default title using email
const defaultTitle = `${email} needs your help with a short video.`;
const title = argv.title || defaultTitle;
// Default message if not provided
const defaultMessage = 'Please record a short video to help me out. Thanks!';
const message = argv.message || defaultMessage;
// Optional fields
const customId = argv.customId || null;
const expiresAt = argv.expires ? parseUserDate(argv.expires) : null;
const collectionId = argv.collection || null;
console.log('\n⚡ Quick Video Request\n');
console.log(`Title: ${title}`);
console.log(`Message: ${message}`);
if (customId) console.log(`Custom ID: ${customId}`);
if (expiresAt) console.log(`Expires: ${new Date(expiresAt).toLocaleString()}`);
if (collectionId) console.log(`Collection: ${collectionId}`);
console.log('\nCreating...');
const response = await createVideoRequest({
name: title,
message,
customId,
expiresAt,
collectionId
});
const requestContent = response.data?.request_content;
if (!requestContent) {
console.error('Failed to create video request');
process.exit(1);
}
const requestLink = requestContent.links?.request_link;
console.log('\n✅ VIDEO REQUEST CREATED!\n');
console.log('─────────────────────────────────────────');
console.log(`ID: ${requestContent.id}`);
console.log('─────────────────────────────────────────');
console.log('\n📎 Share this link:\n');
console.log(` ${requestLink}`);
if (expiresAt) {
console.log(`\n⏰ Link expires: ${new Date(expiresAt).toLocaleString()}`);
}
} catch (error) {
console.error('\nFailed to create video request:', error.message);
process.exit(1);
}
};
/**
* Handle list video requests command
* @param {Object} argv - Command arguments
*/
export const handleRequestsList = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
console.log('Fetching video requests...\n');
const response = await getVideoRequests({ page: 1, perPage: 50 });
const requests = response.data?.request_contents || [];
const totalCount = response.data?.total_count || 0;
if (requests.length === 0) {
console.log('No video requests found.');
return;
}
console.log('┌──────────┬──────────────────────────────────────────┬────────────┬───────┬─────────────────────┐');
console.log('│ ID │ Title │ Status │ Items │ Expires │');
console.log('├──────────┼──────────────────────────────────────────┼────────────┼───────┼─────────────────────┤');
requests.forEach((req) => {
const id = req.id.padEnd(8);
const name = (req.attributes.name || '').substring(0, 40).padEnd(40);
const status = (req.attributes.status || 'active').padEnd(10);
const itemCount = String(req.relationships?.items?.data?.length || 0).padEnd(5);
const expiresAt = req.attributes.expires_at
? new Date(req.attributes.expires_at).toLocaleDateString()
: 'Never';
const expires = expiresAt.padEnd(19);
console.log(`│ ${id} │ ${name} │ ${status} │ ${itemCount} │ ${expires} │`);
});
console.log('└──────────┴──────────────────────────────────────────┴────────────┴───────┴─────────────────────┘');
console.log(`\nTotal: ${totalCount} requests`);
// Show details if verbose
if (argv.verbose) {
console.log('\n── Request Links ──');
requests.forEach((req) => {
const link = req.links?.request_link || '';
console.log(`${req.id}: ${link}`);
});
}
} catch (error) {
console.error('Failed to fetch video requests:', error.message);
process.exit(1);
}
};
/**
* Handle edit video request command
* @param {Object} argv - Command arguments
*/
export const handleRequestEdit = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const requestId = argv.id;
if (!requestId) {
console.error('Please provide a request ID');
process.exit(1);
}
console.log(`\n✏️ Edit Video Request: ${requestId}\n`);
// Build update payload from provided options
const updateOptions = {};
let hasUpdates = false;
if (argv.title !== undefined) {
updateOptions.name = argv.title;
hasUpdates = true;
}
if (argv.message !== undefined) {
updateOptions.message = argv.message;
hasUpdates = true;
}
if (argv.customId !== undefined) {
updateOptions.customId = argv.customId;
hasUpdates = true;
}
if (argv.expires !== undefined) {
if (argv.expires === '' || argv.expires === 'never') {
updateOptions.expiresAt = null;
} else {
updateOptions.expiresAt = parseUserDate(argv.expires);
if (!updateOptions.expiresAt) {
console.error('Invalid date format. Use formats like: 12/21/2025 3:00pm');
process.exit(1);
}
}
hasUpdates = true;
}
if (argv.collection !== undefined) {
updateOptions.collectionId = argv.collection === '' ? null : argv.collection;
hasUpdates = true;
}
if (!hasUpdates) {
console.log('No updates provided. Use options like --title, --message, --expires, etc.');
console.log('\nExample: cloudapp-dl request-edit ABC123 --title "New Title" --expires "12/31/2025 5:00pm"');
process.exit(1);
}
console.log('Updating...');
const response = await updateVideoRequest(requestId, updateOptions);
const updated = response.data?.request_content;
if (!updated) {
console.error('Failed to update request');
process.exit(1);
}
console.log('\n✅ REQUEST UPDATED!\n');
console.log('─────────────────────────────────────────');
console.log(`ID: ${updated.id}`);
console.log(`Title: ${updated.attributes.name}`);
console.log(`Message: ${updated.attributes.message || '(none)'}`);
console.log(`Custom ID: ${updated.attributes.custom_id || '(none)'}`);
console.log(`Expires: ${updated.attributes.expires_at ? new Date(updated.attributes.expires_at).toLocaleString() : 'Never'}`);
console.log(`Collection: ${updated.attributes.collection_name || '(none)'}`);
console.log('─────────────────────────────────────────');
console.log(`\n📎 Link: ${updated.links?.request_link}`);
} catch (error) {
console.error('\nFailed to update request:', error.message);
process.exit(1);
}
};
/**
* Handle delete video request command
* @param {Object} argv - Command arguments
*/
export const handleRequestDelete = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const requestId = argv.id;
if (!requestId) {
console.error('Please provide a request ID');
process.exit(1);
}
// Confirm deletion unless --yes flag is provided
if (!argv.yes) {
const confirmed = await confirm(`Are you sure you want to delete request "${requestId}"?`);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
console.log(`\nDeleting request ${requestId}...`);
const response = await deleteVideoRequest(requestId);
const deleted = response.data?.request_content;
if (!deleted) {
console.error('Failed to delete request');
process.exit(1);
}
console.log(`\n✅ Request "${deleted.attributes.name}" has been deleted.`);
} catch (error) {
console.error('\nFailed to delete request:', error.message);
process.exit(1);
}
};
/**
* Handle request details command - show items submitted to a request
* @param {Object} argv - Command arguments
*/
export const handleRequestDetails = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const requestId = argv.id;
if (!requestId) {
console.error('Please provide a request ID');
process.exit(1);
}
console.log(`Fetching details for request ${requestId}...\n`);
// Get the request to find its items
const request = await getVideoRequest(requestId);
if (!request) {
console.error(`Request "${requestId}" not found`);
process.exit(1);
}
const itemIds = request.relationships?.items?.data || [];
// Display request info
console.log('═══════════════════════════════════════════════════════════════════');
console.log(`📋 REQUEST: ${request.attributes.name}`);
console.log('═══════════════════════════════════════════════════════════════════');
console.log(`ID: ${request.id}`);
console.log(`Custom ID: ${request.attributes.custom_id || '(none)'}`);
console.log(`Message: ${request.attributes.message || '(none)'}`);
console.log(`Status: ${request.attributes.status}`);
console.log(`Created: ${new Date(request.attributes.created_at).toLocaleString()}`);
console.log(`Expires: ${request.attributes.expires_at ? new Date(request.attributes.expires_at).toLocaleString() : 'Never'}`);
console.log(`Collection: ${request.attributes.collection_name || '(none)'}`);
console.log(`Link: ${request.links?.request_link}`);
console.log('───────────────────────────────────────────────────────────────────');
console.log(`\n📁 ITEMS: ${itemIds.length} submission(s)\n`);
if (itemIds.length === 0) {
console.log('No items have been submitted to this request yet.');
return;
}
// Fetch details for each item
const items = [];
for (let i = 0; i < itemIds.length; i++) {
const itemId = itemIds[i].id;
process.stdout.write(`\rFetching item ${i + 1}/${itemIds.length}...`);
try {
const item = await getItem(itemId);
items.push(item);
// Small delay between requests
if (i < itemIds.length - 1) {
await new Promise(r => setTimeout(r, 300));
}
} catch (err) {
console.error(`\nFailed to fetch item ${itemId}: ${err.message}`);
}
}
console.log('\r' + ' '.repeat(40) + '\r'); // Clear progress line
if (items.length === 0) {
console.log('Could not fetch any item details.');
return;
}
// Display items table
console.log('┌──────────┬──────────────────────────────────────────────┬────────┬───────┬─────────────────────┐');
console.log('│ ID │ Name │ Type │ Views │ Created │');
console.log('├──────────┼──────────────────────────────────────────────┼────────┼───────┼─────────────────────┤');
items.forEach((item) => {
const id = (item.id || '').padEnd(8);
const name = (item.name || '').substring(0, 44).padEnd(44);
const type = (item.item_type || item.type || '').substring(0, 6).padEnd(6);
const views = String(item.view_counter || 0).padEnd(5);
const created = item.created_at
? new Date(item.created_at).toLocaleDateString()
: '';
const createdStr = created.padEnd(19);
console.log(`│ ${id} │ ${name} │ ${type} │ ${views} │ ${createdStr} │`);
});
console.log('└──────────┴──────────────────────────────────────────────┴────────┴───────┴─────────────────────┘');
// Show download URLs if verbose
if (argv.verbose) {
console.log('\n── Download URLs ──');
items.forEach((item) => {
const url = item.download_url || item.content_url || item.share_url || '';
console.log(`${item.id}: ${url}`);
});
}
console.log(`\n💡 Tip: Run "cloudapp-dl request-download ${requestId}" to download all items`);
} catch (error) {
console.error('Failed to fetch request details:', error.message);
process.exit(1);
}
};
/**
* Handle request download command - download all items from a request
* @param {Object} argv - Command arguments
*/
export const handleRequestDownload = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const requestId = argv.id;
if (!requestId) {
console.error('Please provide a request ID');
process.exit(1);
}
const outputDir = argv.out || '.';
const timeout = argv.timeout || 2000;
console.log(`Fetching request ${requestId}...\n`);
// Get the request to find its items
const request = await getVideoRequest(requestId);
if (!request) {
console.error(`Request "${requestId}" not found`);
process.exit(1);
}
const itemIds = request.relationships?.items?.data || [];
if (itemIds.length === 0) {
console.log('No items have been submitted to this request yet.');
return;
}
console.log(`📋 Request: ${request.attributes.name}`);
console.log(`📁 Found ${itemIds.length} item(s) to download\n`);
// Create output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
let downloaded = 0;
let failed = 0;
for (let i = 0; i < itemIds.length; i++) {
const itemId = itemIds[i].id;
console.log(`\n[${i + 1}/${itemIds.length}] Fetching item ${itemId}...`);
try {
// Get item details
const item = await getItem(itemId);
if (!item) {
console.log(` ⚠️ Could not fetch item details`);
failed++;
continue;
}
// Get download URL
const downloadUrl = item.download_url || item.content_url;
if (!downloadUrl) {
console.log(` ⚠️ No download URL available`);
failed++;
continue;
}
// Determine filename
const itemName = item.name || item.file_name || itemId;
const ext = item.file_ext || path.extname(itemName) || '';
const baseName = path.basename(itemName, ext);
const filename = sanitizeFilename(`${baseName}${ext}`);
const outputPath = path.join(outputDir, filename);
console.log(` 📥 Downloading: ${filename}`);
// Download the file
const response = await axios({
method: 'GET',
url: downloadUrl,
responseType: 'stream',
maxRedirects: 5
});
const writer = fs.createWriteStream(outputPath);
response.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
console.log(` ✅ Saved: ${outputPath}`);
downloaded++;
// Delay between downloads
if (i < itemIds.length - 1) {
const delay = timeout + Math.floor(Math.random() * 500);
await new Promise(r => setTimeout(r, delay));
}
} catch (err) {
console.log(` ❌ Failed: ${err.message}`);
failed++;
}
}
console.log('\n═══════════════════════════════════════════════════════════════════');
console.log(`✅ Downloaded: ${downloaded}/${itemIds.length}`);
if (failed > 0) {
console.log(`❌ Failed: ${failed}`);
}
console.log('═══════════════════════════════════════════════════════════════════');
} catch (error) {
console.error('Failed to download request items:', error.message);
process.exit(1);
}
};
/**
* Format bytes to human readable size
* @param {number} bytes - Size in bytes
* @returns {string} - Human readable size
*/
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
/**
* Handle file upload command
* @param {Object} argv - Command arguments
*/
export const handleUpload = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const filePath = argv.file;
if (!filePath) {
console.error('Please provide a file path');
process.exit(1);
}
// Resolve to absolute path
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
console.error(`File not found: ${absolutePath}`);
process.exit(1);
}
const stats = fs.statSync(absolutePath);
const filename = path.basename(absolutePath);
console.log('\n📤 UPLOADING FILE\n');
console.log('─────────────────────────────────────────');
console.log(`File: ${filename}`);
console.log(`Size: ${formatBytes(stats.size)}`);
console.log(`Path: ${absolutePath}`);
console.log('─────────────────────────────────────────\n');
let lastProgress = 0;
const onProgress = (progressEvent) => {
if (progressEvent.total) {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
if (percent !== lastProgress && percent % 10 === 0) {
lastProgress = percent;
process.stdout.write(`\rUploading: ${percent}%`);
}
}
};
console.log('Starting upload...');
const result = await uploadFile(absolutePath, onProgress);
console.log('\r' + ' '.repeat(30) + '\r'); // Clear progress line
if (!result || !result.share_url) {
console.error('Upload failed - no share URL returned');
process.exit(1);
}
console.log('\n✅ UPLOAD COMPLETE!\n');
console.log('═══════════════════════════════════════════════════════════════════');
console.log(`ID: ${result.id || result.slug}`);
console.log(`Name: ${result.name}`);
console.log(`Type: ${result.item_type || result.type}`);
console.log(`Status: ${result.status}`);
console.log('═══════════════════════════════════════════════════════════════════');
console.log(`\n📎 Share URL: ${result.share_url}\n`);
if (result.download_url) {
console.log(`📥 Download: ${result.download_url}\n`);
}
} catch (error) {
console.error('\n❌ Upload failed:', error.message);
if (error.response?.data) {
console.error('Details:', JSON.stringify(error.response.data, null, 2));
}
process.exit(1);
}
};
/**
* Handle bulk upload command
* @param {Object} argv - Command arguments
*/
export const handleBulkUpload = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const inputPath = argv.path;
if (!inputPath) {
console.error('Please provide a file or directory path');
process.exit(1);
}
const absolutePath = path.resolve(inputPath);
if (!fs.existsSync(absolutePath)) {
console.error(`Path not found: ${absolutePath}`);
process.exit(1);
}
let files = [];
const stats = fs.statSync(absolutePath);
if (stats.isDirectory()) {
// Get all files in directory
const entries = fs.readdirSync(absolutePath);
files = entries
.filter(entry => {
const entryPath = path.join(absolutePath, entry);
return fs.statSync(entryPath).isFile() && !entry.startsWith('.');
})
.map(entry => path.join(absolutePath, entry));
} else {
files = [absolutePath];
}
if (files.length === 0) {
console.log('No files found to upload.');
return;
}
const timeout = argv.timeout || 2000;
console.log(`\n📤 BULK UPLOAD: ${files.length} file(s)\n`);
console.log('═══════════════════════════════════════════════════════════════════');
let uploaded = 0;
let failed = 0;
const results = [];
for (let i = 0; i < files.length; i++) {
const filePath = files[i];
const filename = path.basename(filePath);
const fileStats = fs.statSync(filePath);
console.log(`\n[${i + 1}/${files.length}] ${filename} (${formatBytes(fileStats.size)})`);
try {
const result = await uploadFile(filePath);
console.log(` ✅ Uploaded: ${result.share_url}`);
results.push({ file: filename, url: result.share_url, status: 'success' });
uploaded++;
// Delay between uploads
if (i < files.length - 1) {
const delay = timeout + Math.floor(Math.random() * 500);
await new Promise(r => setTimeout(r, delay));
}
} catch (err) {
console.log(` ❌ Failed: ${err.message}`);
results.push({ file: filename, url: null, status: 'failed', error: err.message });
failed++;
}
}
console.log('\n═══════════════════════════════════════════════════════════════════');
console.log(`✅ Uploaded: ${uploaded}/${files.length}`);
if (failed > 0) {
console.log(`❌ Failed: ${failed}`);
}
console.log('═══════════════════════════════════════════════════════════════════');
// Show all URLs
if (uploaded > 0) {
console.log('\n📎 Share URLs:');
results.filter(r => r.status === 'success').forEach(r => {
console.log(` ${r.file}: ${r.url}`);
});
}
} catch (error) {
console.error('Bulk upload failed:', error.message);
process.exit(1);
}
};
/**
* Handle delete file command
* @param {Object} argv - Command arguments
*/
export const handleDelete = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const itemId = argv.id;
if (!itemId) {
console.error('Please provide an item ID');
process.exit(1);
}
const permanent = argv.permanent || false;
// Confirm deletion
if (!argv.yes) {
const message = permanent
? `⚠️ PERMANENTLY delete "${itemId}"? This cannot be undone!`
: `Move "${itemId}" to trash? (It will be deleted after 30 days)`;
const confirmed = await confirm(message);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
console.log(permanent ? '\nPermanently deleting...' : '\nMoving to trash...');
const response = await deleteItem(itemId, permanent);
if (permanent) {
console.log(`\n✅ Item "${itemId}" has been permanently deleted.`);
} else {
console.log(`\n✅ Item "${itemId}" has been moved to trash.`);
console.log(' It will be automatically deleted in 30 days.');
console.log(` Use "cloudapp-dl restore ${itemId}" to recover it.`);
}
} catch (error) {
console.error('\nFailed to delete item:', error.message);
process.exit(1);
}
};
/**
* Handle restore file command
* @param {Object} argv - Command arguments
*/
export const handleRestore = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const itemId = argv.id;
if (!itemId) {
console.error('Please provide an item ID');
process.exit(1);
}
console.log(`\nRestoring item ${itemId}...`);
await restoreItem(itemId);
console.log(`\n✅ Item "${itemId}" has been restored from trash.`);
} catch (error) {
console.error('\nFailed to restore item:', error.message);
process.exit(1);
}
};
/**
* Handle list trash command
* @param {Object} argv - Command arguments
*/
export const handleTrash = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
console.log('Fetching trash...\n');
const items = await getTrashItems();
if (items.length === 0) {
console.log('🗑️ Trash is empty.');
return;
}
console.log('🗑️ TRASH\n');
console.log('┌──────────┬──────────────────────────────────────────────────┬─────────────────────┬─────────────────────┐');
console.log('│ ID │ Name │ Created │ Deleted │');
console.log('├──────────┼──────────────────────────────────────────────────┼─────────────────────┼─────────────────────┤');
items.forEach((item) => {
const id = item.id.padEnd(8);
const name = (item.name || '').substring(0, 48).padEnd(48);
const created = item.createdAt
? new Date(item.createdAt).toLocaleDateString()
: '';
const deleted = item.deletedAt
? new Date(item.deletedAt).toLocaleDateString()
: '';
const createdStr = created.padEnd(19);
const deletedStr = deleted.padEnd(19);
console.log(`│ ${id} │ ${name} │ ${createdStr} │ ${deletedStr} │`);
});
console.log('└──────────┴──────────────────────────────────────────────────┴─────────────────────┴─────────────────────┘');
console.log(`\nTotal: ${items.length} items in trash`);
console.log('\n💡 Commands:');
console.log(' cloudapp-dl restore <id> - Restore an item');
console.log(' cloudapp-dl empty-trash - Permanently delete all');
} catch (error) {
console.error('Failed to fetch trash:', error.message);
process.exit(1);
}
};
/**
* Handle empty trash command
* @param {Object} argv - Command arguments
*/
export const handleEmptyTrash = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
// Always confirm this destructive action
if (!argv.yes) {
const confirmed = await confirm('⚠️ PERMANENTLY delete ALL items in trash? This cannot be undone!');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
console.log('\nEmptying trash...\n');
const onProgress = (index, total, item) => {
process.stdout.write(`\rDeleting ${index}/${total}: ${item.name.substring(0, 40)}...`);
};
const result = await emptyTrash(onProgress);
console.log('\r' + ' '.repeat(60) + '\r'); // Clear progress line
if (result.total === 0) {
console.log('🗑️ Trash is already empty.');
return;
}
console.log('═══════════════════════════════════════════════════════════════════');
console.log(`✅ Deleted: ${result.deleted}/${result.total}`);
if (result.failed > 0) {
console.log(`❌ Failed: ${result.failed}`);
}
console.log('═══════════════════════════════════════════════════════════════════');
} catch (error) {
console.error('\nFailed to empty trash:', error.message);
process.exit(1);
}
};
/**
* Format relative time
* @param {string} dateStr - ISO date string
* @returns {string} - Relative time string
*/
const formatRelativeTime = (dateStr) => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
/**
* Handle notifications command
* @param {Object} argv - Command arguments
*/
export const handleNotifications = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
const limit = argv.limit || 20;
const all = argv.all || false;
const unreadOnly = argv.unread || false;
let viewed = 'no'; // Default to unread
if (all) {
viewed = 'all';
} else if (!unreadOnly) {
// If not specifically asking for unread, get both
viewed = 'all';
}
console.log('Fetching notifications...\n');
const response = await getNotifications({ viewed, limit });
const notifications = response.data?.client_notifications || [];
if (notifications.length === 0) {
console.log('🔔 No notifications.');
return;
}
// Count unread
const unreadCount = notifications.filter(n => !n.attributes.viewed_at).length;
console.log(`🔔 NOTIFICATIONS ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}\n`);
console.log('═══════════════════════════════════════════════════════════════════════════════');
notifications.forEach((notif, index) => {
const attrs = notif.attributes;
const isUnread = !attrs.viewed_at;
const indicator = isUnread ? '●' : '○';
const time = formatRelativeTime(attrs.created_at);
console.log(`${indicator} [${notif.id}] ${time}`);
console.log(` ${attrs.title}`);
if (attrs.body && attrs.body !== attrs.title) {
console.log(` ${attrs.body}`);
}
if (argv.verbose && attrs.action_link) {
console.log(` → ${attrs.action_link}`);
}
if (index < notifications.length - 1) {
console.log('───────────────────────────────────────────────────────────────────────────────');
}
});
console.log('═══════════════════════════════════════════════════════════════════════════════');
console.log(`\nTotal: ${notifications.length} notifications`);
if (unreadCount > 0) {
console.log(`\n💡 Run "cloudapp-dl notifications --mark-read" to mark all as read`);
}
} catch (error) {
console.error('Failed to fetch notifications:', error.message);
process.exit(1);
}
};
/**
* Handle mark notifications as read command
* @param {Object} argv - Command arguments
*/
export const handleMarkNotificationsRead = async (argv) => {
try {
if (!isLoggedIn()) {
console.error('Not logged in. Please run "cloudapp-dl login" first.');
process.exit(1);
}
console.log('Marking notifications as read...\n');
const onProgress = (index, total) => {
process.stdout.write(`\rMarking ${index}/${total}...`);
};
const result = await markAllNotificationsViewed(onProgress);
console.log('\r' + ' '.repeat(30) + '\r'); // Clear progress line
if (result.total === 0) {
console.log('✅ No unread notifications.');
return;
}
console.log(`✅ Marked ${result.marked}/${result.total} notifications as read.`);
if (result.failed > 0) {
console.log(`❌ Failed: ${result.failed}`);
}
} catch (error) {
console.error('Failed to mark notifications as read:', error.message);
process.exit(1);
}
};
export default {
handleLogin,
handleLogout,
handleAccount,
handleConfig,
handleList,
handleWhoami,
handleExport,
handleDownloadById,
handleDownloadByUrl,
handleDownloadList,
handleBulkDownload,
handleVideoRequest,
handleVideoRequestQuick,
handleCollections,
handleRequestsList,
handleRequestEdit,
handleRequestDelete,
handleRequestDetails,
handleRequestDownload,
handleUpload,
handleBulkUpload,
handleDelete,
handleRestore,
handleTrash,
handleEmptyTrash,
handleNotifications,
handleMarkNotificationsRead
};