UNPKG

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
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
};