One-click scan & download videos from Candfans creators you subscribe to. Zero external dependencies.
// ==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> — ${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();
}
})();