Candfans Downloader

One-click scan & download videos from Candfans creators you subscribe to. Zero external dependencies.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Candfans Downloader
// @namespace    https://github.com/candfans-downloader
// @version      2.1.1
// @description  One-click scan & download videos from Candfans creators you subscribe to. Zero external dependencies.
// @author       candfans-downloader
// @match        https://candfans.jp/*
// @license      MIT
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── Styles ──
  const STYLES = `
    #cfd-fab {
      position: fixed; bottom: 24px; right: 24px; z-index: 99999;
      width: 52px; height: 52px; border-radius: 50%;
      background: linear-gradient(135deg, #6366f1, #8b5cf6);
      color: #fff; font-size: 22px; border: none; cursor: pointer;
      box-shadow: 0 4px 14px rgba(99,102,241,.45);
      display: flex; align-items: center; justify-content: center;
      transition: transform .15s, box-shadow .15s;
    }
    #cfd-fab:hover { transform: scale(1.08); box-shadow: 0 6px 20px rgba(99,102,241,.55); }

    #cfd-panel {
      position: fixed; bottom: 88px; right: 24px; z-index: 99998;
      width: 400px; max-height: 85vh; overflow-y: auto;
      background: #1e1e2e; color: #cdd6f4;
      border-radius: 16px; padding: 20px; display: none;
      box-shadow: 0 8px 32px rgba(0,0,0,.5);
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      font-size: 13px; line-height: 1.5;
    }
    #cfd-panel.open { display: block; }

    #cfd-panel h3 { margin: 0 0 14px; font-size: 16px; color: #cba6f7; font-weight: 700; }
    #cfd-panel label { display: block; margin-bottom: 4px; color: #a6adc8; font-size: 12px; }
    #cfd-panel select, #cfd-panel input[type=number], #cfd-panel input[type=text] {
      width: 100%; padding: 7px 10px; border-radius: 8px;
      border: 1px solid #45475a; background: #313244; color: #cdd6f4;
      font-size: 13px; margin-bottom: 10px; box-sizing: border-box;
    }
    #cfd-panel select:focus, #cfd-panel input:focus { outline: none; border-color: #6366f1; }

    .cfd-row { display: flex; gap: 8px; margin-bottom: 10px; }
    .cfd-row > * { flex: 1; }

    .cfd-btn {
      width: 100%; padding: 9px; border: none; border-radius: 8px;
      font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity .15s;
    }
    .cfd-btn:hover { opacity: .85; }
    .cfd-btn:disabled { opacity: .4; cursor: not-allowed; }
    .cfd-btn-primary { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; }
    .cfd-btn-green { background: #22c55e; color: #fff; }
    .cfd-btn-outline { background: transparent; border: 1px solid #45475a; color: #cdd6f4; }
    .cfd-btn-sm { padding: 5px 10px; font-size: 11px; width: auto; border-radius: 6px; }

    #cfd-progress-wrap {
      width: 100%; height: 6px; background: #313244; border-radius: 3px;
      margin: 10px 0; overflow: hidden; display: none;
    }
    #cfd-progress-bar {
      height: 100%; width: 0%; background: linear-gradient(90deg, #6366f1, #8b5cf6);
      border-radius: 3px; transition: width .2s;
    }
    #cfd-status { font-size: 12px; color: #a6adc8; margin-bottom: 6px; min-height: 16px; }
    #cfd-stats { font-size: 12px; color: #a6adc8; margin-top: 6px; }

    .cfd-export-group { display: none; flex-direction: column; gap: 8px; margin-top: 12px; }
    .cfd-export-group.show { display: flex; }

    .cfd-divider { border: none; border-top: 1px solid #313244; margin: 10px 0; }

    #cfd-video-list {
      max-height: 250px; overflow-y: auto; margin-top: 8px;
      border: 1px solid #313244; border-radius: 8px; font-size: 11px;
    }
    #cfd-video-list table { width: 100%; border-collapse: collapse; }
    #cfd-video-list th { position: sticky; top: 0; background: #313244; padding: 4px 6px; text-align: left; color: #a6adc8; }
    #cfd-video-list td { padding: 4px 6px; border-top: 1px solid #1e1e2e; }
    #cfd-video-list tr:hover { background: #313244; }
    #cfd-video-list input[type=checkbox] { accent-color: #6366f1; cursor: pointer; }
    .cfd-dl-status { font-size: 10px; }
    .cfd-dl-status.ok { color: #22c55e; }
    .cfd-dl-status.err { color: #f38ba8; }
    .cfd-dl-status.busy { color: #fab387; }

    .cfd-select-bar { display: flex; gap: 6px; margin-top: 8px; align-items: center; }
    .cfd-select-bar span { font-size: 11px; color: #a6adc8; margin-left: auto; }

    /* Single post download button - inline with video controls */
    .cfd-post-dl-btn {
      display: inline-flex; align-items: center; justify-content: center;
      padding: 6px; border-radius: 50%; border: none;
      background: rgba(99, 102, 241, 0.85);
      color: #fff; cursor: pointer; transition: opacity .15s, background .15s;
      aspect-square: 1;
    }
    .cfd-post-dl-btn:hover { background: rgba(139, 92, 246, 1); }
    .cfd-post-dl-btn:disabled { opacity: .4; cursor: not-allowed; }
    .cfd-post-dl-btn svg { width: 18px; height: 18px; }
    /* Progress tooltip shown during download */
    .cfd-post-dl-btn[data-status]:not([data-status=""]):after {
      content: attr(data-status);
      position: absolute; bottom: calc(100% + 6px); right: 0;
      background: #1e1e2e; color: #cdd6f4;
      padding: 4px 8px; border-radius: 6px; font-size: 11px;
      white-space: nowrap; pointer-events: none;
      box-shadow: 0 2px 8px rgba(0,0,0,.4);
    }
    .cfd-post-dl-btn { position: relative; }
  `;

  // ── State ──
  let scanning = false;
  let results = {};
  let downloading = false;
  let abortController = null;
  let selectedIndices = new Set();

  // ── Helpers ──
  const RESERVED_ROUTES = ['explore', 'search', 'settings', 'notifications', 'messages', 'mypage', 'login', 'register', 'ranking', 'posts', 'help', 'terms', 'privacy', 'law', 'inquiry'];

  function getUserCodeFromURL() {
    const m = location.pathname.match(/^\/([^/?#]+)/);
    if (!m) return null;
    const code = m[1];
    return RESERVED_ROUTES.includes(code) ? null : code;
  }

  function getURLParam(key) {
    return new URLSearchParams(location.search).get(key);
  }

  function getSinglePostId() {
    const m = location.pathname.match(/\/posts\/comment\/show\/(\d+)/);
    return m ? m[1] : null;
  }

  function sanitizeFilename(name) {
    return name.replace(/[\\/:*?"<>|]/g, '_').substring(0, 100);
  }

  function formatDuration(sec) {
    const m = Math.floor(sec / 60);
    const s = Math.round(sec % 60);
    return `${m}:${s.toString().padStart(2, '0')}`;
  }

  function formatBytes(bytes) {
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
    if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
    return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
  }

  async function apiFetch(path) {
    const resp = await fetch(`https://candfans.jp/api${path}`, { credentials: 'include' });
    if (!resp.ok) throw new Error(`API ${resp.status}: ${path}`);
    return resp.json();
  }

  const DL_ICON_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';

  // ── SPA URL Change Observer ──
  function observeURLChanges(callback) {
    let lastUrl = location.href;
    const check = () => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        callback();
      }
    };
    for (const method of ['pushState', 'replaceState']) {
      const orig = history[method];
      history[method] = function () {
        const result = orig.apply(this, arguments);
        check();
        return result;
      };
    }
    window.addEventListener('popstate', check);
  }

  // ── Core: Scan ──
  async function scanCreator(userCode, options, onProgress) {
    onProgress('Fetching creator info...');
    const userData = await apiFetch(`/v3/users/by-user-code/${userCode}`);
    const user = userData.user || userData.data;
    const userId = user.id || user.user_id;
    const username = user.name || user.username || user.code || userCode;

    let page = 1;
    let hasMore = true;
    const items = [];
    const planId = options.planId || '';

    while (hasMore) {
      const params = {
        user_id: userId,
        keyword: '',
        'post_type[]': '1',
        sort_order: '',
        page: page
      };
      if (planId) params.plan_id = planId;
      const qs = new URLSearchParams(params);

      onProgress(`Scanning page ${page}...`);
      const data = await apiFetch(`/contents/get-timeline?${qs}`);
      const posts = data.data || [];
      if (posts.length === 0) { hasMore = false; break; }

      for (const p of posts) {
        const isVideo = p.contents_type === 2;
        const isPhoto = p.contents_type === 1;
        const duration = p.attachment_length || 0;
        const att = (p.attachments && p.attachments[0]) || {};

        if (options.contentType === 'video' && !isVideo) continue;
        if (options.contentType === 'photo' && !isPhoto) continue;
        if (isVideo && duration < options.minDuration) continue;

        const url = options.quality === 'low' && att.low ? att.low : att.default || null;
        if (!url && isVideo) continue;

        items.push({
          post_id: p.post_id,
          title: sanitizeFilename(p.title || `post_${p.post_id}`),
          type: isVideo ? 'video' : 'photo',
          duration: isVideo ? duration : 0,
          url: isVideo ? url : null,
        });
      }

      page++;
      if (posts.length < 10) hasMore = false;
    }

    return { username, userId, items };
  }

  // ── Export: URL list (TSV) ──
  function generateTSV(data, indices) {
    const videos = data.items.filter(i => i.type === 'video');
    const selected = indices ? videos.filter((_, i) => indices.has(i)) : videos;
    const header = 'post_id\tduration_sec\ttitle\turl';
    const rows = selected.map(v => `${v.post_id}\t${Math.round(v.duration)}\t${v.title}\t${v.url}`);
    return [header, ...rows].join('\n');
  }

  function triggerDownloadFile(content, filename) {
    const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(a.href);
  }

  // ── In-browser HLS download ──
  async function parseM3U8(m3u8Url) {
    const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);
    const resp = await fetch(m3u8Url);
    const text = await resp.text();
    const lines = text.split('\n').map(l => l.trim()).filter(Boolean);

    const variantLine = lines.find(l => l.startsWith('#EXT-X-STREAM-INF'));
    if (variantLine) {
      let bestUrl = null;
      let bestBw = 0;
      for (let i = 0; i < lines.length; i++) {
        if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
          const bwMatch = lines[i].match(/BANDWIDTH=(\d+)/);
          const bw = bwMatch ? parseInt(bwMatch[1]) : 0;
          if (bw >= bestBw && i + 1 < lines.length) {
            bestBw = bw;
            bestUrl = lines[i + 1];
          }
        }
      }
      if (bestUrl) {
        const resolvedUrl = bestUrl.startsWith('http') ? bestUrl : baseUrl + bestUrl;
        return parseM3U8(resolvedUrl);
      }
    }

    const segments = [];
    for (const line of lines) {
      if (!line.startsWith('#')) {
        segments.push(line.startsWith('http') ? line : baseUrl + line);
      }
    }
    return segments;
  }

  async function downloadHLS(video, onStatus, signal) {
    const { post_id, title, url } = video;
    const filename = `${post_id}_${title}.mp4`;

    onStatus('busy', 'Parsing...');
    const segments = await parseM3U8(url);
    const total = segments.length;
    const chunks = [];
    let totalBytes = 0;

    for (let i = 0; i < total; i++) {
      if (signal && signal.aborted) {
        onStatus('err', 'Cancelled');
        return;
      }
      onStatus('busy', `${i + 1}/${total} segs (${formatBytes(totalBytes)})`);
      const resp = await fetch(segments[i]);
      const buf = await resp.arrayBuffer();
      chunks.push(buf);
      totalBytes += buf.byteLength;
    }

    onStatus('busy', `Merging ${formatBytes(totalBytes)}...`);
    const blob = new Blob(chunks, { type: 'video/mp2t' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(a.href), 10000);

    onStatus('ok', `Done (${formatBytes(totalBytes)})`);
  }

  async function downloadAllHLS(videos, indices, updateRow, onGlobalStatus) {
    downloading = true;
    abortController = new AbortController();
    const signal = abortController.signal;
    const selected = indices ? [...indices].sort((a, b) => a - b) : videos.map((_, i) => i);
    let done = 0;

    for (const idx of selected) {
      if (signal.aborted) break;
      done++;
      onGlobalStatus(`Downloading ${done}/${selected.length}...`);
      try {
        await downloadHLS(videos[idx], (cls, msg) => updateRow(idx, cls, msg), signal);
      } catch (e) {
        updateRow(idx, 'err', e.message.substring(0, 30));
      }
      if (done < selected.length && !signal.aborted) {
        await new Promise(r => setTimeout(r, 500));
      }
    }

    downloading = false;
    onGlobalStatus(signal.aborted ? 'Download cancelled.' : 'All downloads complete!');
  }

  // ── Single Post Download ──
  function findM3U8FromPage() {
    const M3U8_RE = /https?:\/\/video\.candfans\.jp\/[^"'\s<>]+\.m3u8/g;

    // Strategy 1: Video.js player instance (most reliable - gets full video, not sample)
    try {
      const vjsEl = document.querySelector('video-js');
      if (vjsEl && vjsEl.player) {
        const src = vjsEl.player.currentSrc();
        if (src && src.includes('.m3u8')) return src;
      }
    } catch (e) {}

    // Strategy 2: search all script tags (Next.js data, inline scripts)
    for (const script of document.querySelectorAll('script')) {
      const matches = [...script.textContent.matchAll(M3U8_RE)];
      // Prefer non-sample URLs
      const full = matches.find(m => !m[0].includes('sample'));
      if (full) return full[0];
      if (matches.length) return matches[0][0];
    }

    // Strategy 3: video/source elements
    for (const el of document.querySelectorAll('source[src*=".m3u8"], video[src*=".m3u8"]')) {
      const src = el.src || el.getAttribute('src');
      if (src) return src;
    }

    return null;
  }

  function injectSinglePostButton() {
    const postId = getSinglePostId();
    if (!postId) return;

    // Remove any previously injected button
    document.querySelectorAll('.cfd-post-dl-btn').forEach(el => el.remove());

    const tryInject = () => {
      const videoEl = document.querySelector('video');
      if (!videoEl) return false;

      const videoWrapper = videoEl.closest('[class*="aspect-video"]');
      if (!videoWrapper) return false;

      // Find the bottom control bar, then the right-side group with fullscreen button
      const controlBar = videoWrapper.querySelector('.flex.items-center.justify-between');
      const rightGroup = controlBar?.lastElementChild;
      const fsBtn = rightGroup?.querySelector('button');

      // Determine injection target
      const injectTarget = rightGroup && fsBtn ? 'controls' : 'fallback';

      // Don't double-inject
      if (document.querySelector('.cfd-post-dl-btn')) return true;

      const btn = document.createElement('button');
      btn.className = 'cfd-post-dl-btn';
      btn.style.pointerEvents = 'auto';
      btn.title = 'Download this video';
      btn.innerHTML = DL_ICON_SVG;
      btn.dataset.status = '';

      btn.addEventListener('click', async (e) => {
        e.stopPropagation();
        if (btn.disabled) return;
        btn.disabled = true;
        btn.dataset.status = 'Fetching URL...';

        try {
          const m3u8Url = findM3U8FromPage();
          if (!m3u8Url) {
            btn.dataset.status = 'URL not found';
            setTimeout(() => { btn.disabled = false; btn.dataset.status = ''; }, 3000);
            return;
          }

          const rawTitle = document.title
            .replace(/^CF\d+\s*/, '')
            .replace(/\s*\|.*$/, '')
            .replace(/[\\/:*?"<>|]/g, '_')
            .substring(0, 80) || `post_${postId}`;

          await downloadHLS(
            { post_id: postId, title: rawTitle, url: m3u8Url },
            (cls, msg) => { btn.dataset.status = msg; },
            { aborted: false }
          );
          btn.dataset.status = 'Done!';
        } catch (e) {
          btn.dataset.status = 'Error: ' + e.message.substring(0, 20);
        } finally {
          setTimeout(() => { btn.disabled = false; btn.dataset.status = ''; }, 5000);
        }
      });

      if (injectTarget === 'controls') {
        rightGroup.insertBefore(btn, fsBtn);
      } else {
        btn.style.margin = '8px';
        btn.style.padding = '8px 16px';
        btn.style.borderRadius = '8px';
        videoWrapper.parentElement.insertBefore(btn, videoWrapper.nextSibling);
      }
      return true;
    };

    if (!tryInject()) {
      const observer = new MutationObserver(() => {
        if (tryInject()) observer.disconnect();
      });
      observer.observe(document.body, { childList: true, subtree: true });
      setTimeout(() => observer.disconnect(), 15000);
    }
  }

  // ── Selection helpers ──
  function updateSelectionCount(panel, videos) {
    const counter = panel.querySelector('#cfd-sel-count');
    if (counter) counter.textContent = `${selectedIndices.size}/${videos.length} selected`;
  }

  function setAllCheckboxes(panel, videos, checked) {
    selectedIndices.clear();
    if (checked) videos.forEach((_, i) => selectedIndices.add(i));
    panel.querySelectorAll('#cfd-video-list input[type=checkbox]').forEach((cb, i) => {
      if (i === 0) return; // skip header "select all"
    });
    panel.querySelectorAll('.cfd-row-cb').forEach((cb, i) => { cb.checked = checked; });
    const masterCb = panel.querySelector('#cfd-select-all');
    if (masterCb) masterCb.checked = checked;
    updateSelectionCount(panel, videos);
  }

  // ── UI ──
  function init() {
    const style = document.createElement('style');
    style.textContent = STYLES;
    document.head.appendChild(style);

    const fab = document.createElement('button');
    fab.id = 'cfd-fab';
    fab.innerHTML = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
    document.body.appendChild(fab);

    const panel = document.createElement('div');
    panel.id = 'cfd-panel';

    const userCode = getUserCodeFromURL();
    const planId = getURLParam('postPlanId') || '';

    panel.innerHTML = `
      <h3>Candfans Downloader</h3>

      <label>Creator</label>
      <input type="text" id="cfd-user" value="${userCode || ''}" placeholder="user_code (from URL)" />

      <div class="cfd-row">
        <div>
          <label>Content</label>
          <select id="cfd-type">
            <option value="video">Videos only</option>
            <option value="all">All</option>
          </select>
        </div>
        <div>
          <label>Quality</label>
          <select id="cfd-quality">
            <option value="default">Default (Best)</option>
            <option value="low">Low</option>
          </select>
        </div>
      </div>

      <div class="cfd-row">
        <div>
          <label>Min duration (sec)</label>
          <input type="number" id="cfd-min-dur" value="0" min="0" step="10" />
        </div>
        <div>
          <label>Plan ID</label>
          <input type="text" id="cfd-plan" value="${planId}" placeholder="Optional" />
        </div>
      </div>

      <button class="cfd-btn cfd-btn-primary" id="cfd-scan">Scan</button>

      <div id="cfd-progress-wrap"><div id="cfd-progress-bar"></div></div>
      <div id="cfd-status"></div>
      <div id="cfd-stats"></div>

      <div class="cfd-export-group" id="cfd-exports">
        <hr class="cfd-divider"/>
        <div class="cfd-select-bar" id="cfd-select-bar">
          <button class="cfd-btn cfd-btn-outline cfd-btn-sm" id="cfd-sel-all">All</button>
          <button class="cfd-btn cfd-btn-outline cfd-btn-sm" id="cfd-sel-none">None</button>
          <button class="cfd-btn cfd-btn-outline cfd-btn-sm" id="cfd-sel-invert">Invert</button>
          <span id="cfd-sel-count"></span>
        </div>
        <div id="cfd-video-list"></div>
        <div class="cfd-row">
          <button class="cfd-btn cfd-btn-primary" id="cfd-dl-browser">Download selected</button>
          <button class="cfd-btn cfd-btn-outline" id="cfd-dl-stop" style="display:none;">Stop</button>
        </div>
        <button class="cfd-btn cfd-btn-outline" id="cfd-dl-tsv">Export selected (.tsv)</button>
      </div>
    `;
    document.body.appendChild(panel);

    // DOM refs
    const $status = panel.querySelector('#cfd-status');
    const $stats = panel.querySelector('#cfd-stats');
    const $progressWrap = panel.querySelector('#cfd-progress-wrap');
    const $progressBar = panel.querySelector('#cfd-progress-bar');
    const $exports = panel.querySelector('#cfd-exports');
    const $scan = panel.querySelector('#cfd-scan');
    const $videoList = panel.querySelector('#cfd-video-list');
    const $dlBrowser = panel.querySelector('#cfd-dl-browser');
    const $dlStop = panel.querySelector('#cfd-dl-stop');

    fab.addEventListener('click', () => panel.classList.toggle('open'));

    // ── SPA URL observer ──
    observeURLChanges(() => {
      const newCode = getUserCodeFromURL();
      const newPlan = getURLParam('postPlanId') || '';
      const $user = panel.querySelector('#cfd-user');
      const $plan = panel.querySelector('#cfd-plan');
      if (newCode && $user) $user.value = newCode;
      if ($plan) $plan.value = newPlan;

      // Handle single post pages
      injectSinglePostButton();
    });

    // ── Scan ──
    $scan.addEventListener('click', async () => {
      if (scanning) return;
      scanning = true;
      $scan.disabled = true;
      $scan.textContent = 'Scanning...';
      $exports.classList.remove('show');
      $progressWrap.style.display = 'block';
      $progressBar.style.width = '0%';
      $stats.textContent = '';
      $videoList.innerHTML = '';
      results = {};
      selectedIndices.clear();

      const userCodeInput = panel.querySelector('#cfd-user').value.trim();
      if (!userCodeInput) {
        $status.textContent = 'Please enter a user code.';
        scanning = false;
        $scan.disabled = false;
        $scan.textContent = 'Scan';
        return;
      }

      const options = {
        contentType: panel.querySelector('#cfd-type').value,
        quality: panel.querySelector('#cfd-quality').value,
        minDuration: parseInt(panel.querySelector('#cfd-min-dur').value) || 0,
        planId: panel.querySelector('#cfd-plan').value || '',
      };

      try {
        const data = await scanCreator(userCodeInput, options, (msg) => {
          $status.textContent = msg;
          const pct = Math.min(95, parseFloat($progressBar.style.width) + 2);
          $progressBar.style.width = `${pct}%`;
        });

        results = data;
        $progressBar.style.width = '100%';

        const videos = data.items.filter(i => i.type === 'video');
        const totalDur = videos.reduce((s, v) => s + v.duration, 0);

        $status.textContent = 'Scan complete!';
        $stats.innerHTML = `<b>${data.username}</b> &mdash; ${videos.length} videos (${formatDuration(totalDur)})`;

        // Build video list table with checkboxes
        if (videos.length > 0) {
          // Select all by default
          videos.forEach((_, i) => selectedIndices.add(i));

          let html = '<table><thead><tr>';
          html += '<th><input type="checkbox" id="cfd-select-all" checked /></th>';
          html += '<th>#</th><th>Title</th><th>Duration</th><th>Status</th>';
          html += '</tr></thead><tbody>';
          videos.forEach((v, i) => {
            html += `<tr id="cfd-row-${i}">`;
            html += `<td><input type="checkbox" class="cfd-row-cb" data-idx="${i}" checked /></td>`;
            html += `<td>${i + 1}</td>`;
            html += `<td title="${v.title}">${v.title.substring(0, 30)}${v.title.length > 30 ? '...' : ''}</td>`;
            html += `<td>${formatDuration(v.duration)}</td>`;
            html += `<td class="cfd-dl-status" id="cfd-st-${i}">-</td>`;
            html += '</tr>';
          });
          html += '</tbody></table>';
          $videoList.innerHTML = html;

          // Master checkbox
          panel.querySelector('#cfd-select-all').addEventListener('change', (e) => {
            setAllCheckboxes(panel, videos, e.target.checked);
          });

          // Individual checkboxes
          panel.querySelectorAll('.cfd-row-cb').forEach(cb => {
            cb.addEventListener('change', (e) => {
              const idx = parseInt(e.target.dataset.idx);
              if (e.target.checked) selectedIndices.add(idx);
              else selectedIndices.delete(idx);
              // Update master checkbox
              const master = panel.querySelector('#cfd-select-all');
              if (master) master.checked = selectedIndices.size === videos.length;
              updateSelectionCount(panel, videos);
            });
          });

          updateSelectionCount(panel, videos);
          $exports.classList.add('show');

          // Selection toolbar events
          panel.querySelector('#cfd-sel-all').onclick = () => setAllCheckboxes(panel, videos, true);
          panel.querySelector('#cfd-sel-none').onclick = () => setAllCheckboxes(panel, videos, false);
          panel.querySelector('#cfd-sel-invert').onclick = () => {
            videos.forEach((_, i) => {
              if (selectedIndices.has(i)) selectedIndices.delete(i);
              else selectedIndices.add(i);
            });
            panel.querySelectorAll('.cfd-row-cb').forEach((cb, i) => {
              cb.checked = selectedIndices.has(i);
            });
            const master = panel.querySelector('#cfd-select-all');
            if (master) master.checked = selectedIndices.size === videos.length;
            updateSelectionCount(panel, videos);
          };
        }
      } catch (err) {
        $status.textContent = `Error: ${err.message}`;
        console.error('[Candfans DL]', err);
      } finally {
        scanning = false;
        $scan.disabled = false;
        $scan.textContent = 'Scan';
      }
    });

    // ── Export TSV (selected only) ──
    panel.querySelector('#cfd-dl-tsv').addEventListener('click', () => {
      if (!results.items) return;
      const tsv = generateTSV(results, selectedIndices.size > 0 ? selectedIndices : null);
      triggerDownloadFile(tsv, `candfans_${results.username}_urls.tsv`);
    });

    // ── Download in browser (selected only) ──
    $dlBrowser.addEventListener('click', async () => {
      if (!results.items || downloading) return;
      const videos = results.items.filter(i => i.type === 'video');
      if (!videos.length || selectedIndices.size === 0) return;

      $dlBrowser.style.display = 'none';
      $dlStop.style.display = 'block';

      await downloadAllHLS(
        videos,
        selectedIndices,
        (idx, cls, msg) => {
          const el = panel.querySelector(`#cfd-st-${idx}`);
          if (el) { el.className = `cfd-dl-status ${cls}`; el.textContent = msg; }
          const row = panel.querySelector(`#cfd-row-${idx}`);
          if (row) row.scrollIntoView({ block: 'nearest' });
        },
        (msg) => { $status.textContent = msg; }
      );

      $dlBrowser.style.display = 'block';
      $dlStop.style.display = 'none';
    });

    // ── Stop download ──
    $dlStop.addEventListener('click', () => {
      if (abortController) abortController.abort();
    });

    // ── Initial single post check ──
    injectSinglePostButton();
  }

  // ── Bootstrap ──
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();