// ==UserScript==
// @name Douyin User Video Downloader
// @namespace https://github.com/CaoCuong2404
// @version 1.6
// @description Extract video links and metadata from Douyin user profiles
// @author CaoCuong2404
// @match https://www.douyin.com/user/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=douyin.com
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
"use strict";
// Add Tailwind CSS
const tailwindCDN = document.createElement("script");
tailwindCDN.src = "https://cdn.tailwindcss.com";
document.head.appendChild(tailwindCDN);
// Global state
const state = {
videos: [],
selectedVideos: new Set(),
isFetching: false,
fetchedCount: 0,
totalFound: 0,
isDialogOpen: false,
};
function createMainUI() {
// Create backdrop
const backdrop = document.createElement("div");
backdrop.className = "fixed inset-0 bg-black bg-opacity-50 z-[9999] hidden";
backdrop.id = "douyin-downloader-backdrop";
// Create dialog container
const container = document.createElement("div");
container.className =
"fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[900px] bg-white rounded-lg shadow-xl z-[10000] hidden";
container.id = "douyin-downloader";
container.innerHTML = `
<div class="flex flex-col max-h-[90vh]">
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-2">
<img src="https://www.douyin.com/favicon.ico" class="w-6 h-6" alt="Douyin">
<h2 class="text-xl font-bold text-gray-800">Douyin Downloader</h2>
</div>
<button id="close-dialog" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-4 flex-1 overflow-hidden flex flex-col min-h-[500px]">
<div id="fetch-status" class="text-sm text-gray-500 mb-4"></div>
<div class="border rounded-lg flex-1 flex flex-col overflow-hidden">
<div class="p-4 border-b bg-gray-50 flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<input type="checkbox" id="select-all" class="rounded text-[#FE2C55]">
<label for="select-all" class="text-sm font-medium text-gray-700">
Select All (<span id="selected-count">0</span>/<span id="total-count">0</span>)
</label>
</div>
<div class="h-4 border-l border-gray-300"></div>
<div class="flex items-center space-x-2" id="action-buttons">
<div class="relative inline-block text-left" id="download-dropdown">
<button disabled id="download-btn" class="px-3 py-1.5 text-sm font-medium text-white bg-[#FE2C55] rounded-md shadow-sm hover:bg-[#fe2c55]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FE2C55] disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
Download
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50" id="dropdown-menu">
<div class="py-1">
<button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="audio">
Download Audios (MP3)
</button>
<button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="video">
Download Videos (MP4)
</button>
<button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="json">
Download Metadata (JSON)
</button>
<button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="txt">
Download Links (TXT)
</button>
</div>
</div>
</div>
</div>
</div>
<button id="fetch-videos" class="px-3 py-1.5 text-sm font-medium text-white bg-[#FE2C55] rounded-md shadow-sm hover:bg-[#fe2c55]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FE2C55] inline-flex items-center">
<span>Fetch Videos</span>
</button>
</div>
<div class="overflow-auto flex-1">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th scope="col" class="w-12 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Select
</th>
<th scope="col" class="w-16 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
No.
</th>
<th scope="col" class="w-16 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cover
</th>
<th scope="col" class="w-[300px] px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Title
</th>
<th scope="col" class="w-32 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody id="videos-table-body" class="bg-white divide-y divide-gray-200">
<!-- Videos will be inserted here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(backdrop);
document.body.appendChild(container);
return { backdrop, container };
}
async function addDownloadButton() {
try {
// Wait initial 2s for UI to stabilize and translations to complete
await sleep(2000);
// Try to find the element multiple times
let attempts = 3;
let tabCountElement = null;
while (attempts > 0 && !tabCountElement) {
try {
tabCountElement = await waitForElement('[data-e2e="user-tab-count"]', 10000); // 10s timeout per attempt
break;
} catch (err) {
attempts--;
if (attempts > 0) {
console.log("Retrying to find tab count element...");
// Wait between attempts
await sleep(1000);
} else {
throw new Error(
"Could not find video count element after multiple attempts. This could be due to UI changes or page translation.",
);
}
}
}
// Extra check for parent element stability
const parentElement = tabCountElement.parentNode;
if (!parentElement || !parentElement.isConnected) {
throw new Error("Parent element of video count is not stable");
}
const downloadButton = document.createElement("button");
downloadButton.className = "ml-2 text-[#FE2C55] hover:text-[#fe2c55]/90 transition-colors";
downloadButton.innerHTML = `
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
`;
downloadButton.title = "Download all videos";
// Insert after the count with stability check
if (tabCountElement.nextSibling) {
parentElement.insertBefore(downloadButton, tabCountElement.nextSibling);
} else {
parentElement.appendChild(downloadButton);
}
// Add click handler
downloadButton.addEventListener("click", showDialog);
// Monitor for potential DOM changes that could affect the button
const observer = new MutationObserver((mutations) => {
if (!downloadButton.isConnected) {
// Button was removed, try to re-add it
if (tabCountElement.isConnected) {
if (tabCountElement.nextSibling) {
parentElement.insertBefore(downloadButton, tabCountElement.nextSibling);
} else {
parentElement.appendChild(downloadButton);
}
}
}
});
observer.observe(parentElement, {
childList: true,
subtree: true,
});
} catch (error) {
console.error("Failed to add download button:", error);
}
}
function showDialog() {
const backdrop = document.getElementById("douyin-downloader-backdrop");
const dialog = document.getElementById("douyin-downloader");
backdrop.classList.remove("hidden");
dialog.classList.remove("hidden");
// Add animation classes
dialog.classList.add("animate-fade-in");
backdrop.classList.add("animate-fade-in");
state.isDialogOpen = true;
}
function hideDialog() {
const backdrop = document.getElementById("douyin-downloader-backdrop");
const dialog = document.getElementById("douyin-downloader");
backdrop.classList.add("hidden");
dialog.classList.add("hidden");
state.isDialogOpen = false;
}
function setupDialogEventListeners() {
// Close button
document.getElementById("close-dialog")?.addEventListener("click", hideDialog);
// Close on backdrop click
document.getElementById("douyin-downloader-backdrop")?.addEventListener("click", hideDialog);
// Prevent dialog close when clicking inside
document.getElementById("douyin-downloader")?.addEventListener("click", (e) => {
e.stopPropagation();
});
// Close on Escape key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.isDialogOpen) {
hideDialog();
}
});
}
function createVideoRow(video, index) {
const row = document.createElement("tr");
row.className = "hover:bg-gray-50";
const date = new Date(video.createTime);
const formattedDate = date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
row.innerHTML = `
<td class="px-4 py-4 whitespace-nowrap">
<input type="checkbox" data-video-id="${video.id}" class="video-checkbox rounded text-[#FE2C55]">
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
${index + 1}
</td>
<td class="px-4 py-4 whitespace-nowrap">
<div class="w-12 h-12 rounded-lg overflow-hidden">
<img src="${video.dynamicCoverUrl || video.coverUrl}" class="w-full h-full object-cover" alt="${video.title}">
</div>
</td>
<td class="px-4 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 font-medium truncate max-w-[300px]" title="${video.title}">
${video.title}
</div>
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
${formattedDate}
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm">
<div class="flex items-center space-x-2">
<a href="${video.videoUrl}" target="_blank" class="text-[#FE2C55] hover:text-[#fe2c55]/90">
Video
</a>
${
video.audioUrl
? `
<span class="text-gray-300">|</span>
<a href="${video.audioUrl}" target="_blank" class="text-[#FE2C55] hover:text-[#fe2c55]/90">
Audio
</a>
`
: ""
}
</div>
</td>
`;
return row;
}
function updateUI() {
const selectedCount = state.selectedVideos.size;
const totalCount = state.videos.length;
// Update counts
document.getElementById("selected-count").textContent = selectedCount;
document.getElementById("total-count").textContent = totalCount;
// Update select all checkbox
const selectAllCheckbox = document.getElementById("select-all");
selectAllCheckbox.checked = selectedCount === totalCount && totalCount > 0;
// Update download button
const downloadBtn = document.getElementById("download-btn");
downloadBtn.disabled = selectedCount === 0;
}
function setupEventListeners() {
// Fetch videos button
document.getElementById("fetch-videos").addEventListener("click", async () => {
if (state.isFetching) return;
state.isFetching = true;
state.fetchedCount = 0;
state.videos = [];
state.selectedVideos.clear();
const button = document.getElementById("fetch-videos");
const statusEl = document.getElementById("fetch-status");
const tableBody = document.getElementById("videos-table-body");
tableBody.innerHTML = "";
button.disabled = true;
button.innerHTML = `
<svg class="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Fetching...
`;
try {
const downloader = new DouyinDownloader();
await downloader.fetchAllVideos((newVideos) => {
// Sort new videos by date (latest first)
newVideos.sort((a, b) => new Date(b.createTime) - new Date(a.createTime));
// Add new videos to state
state.videos.push(...newVideos);
state.fetchedCount += newVideos.length;
// Update table
state.videos.forEach((video, index) => {
const existingRow = document.querySelector(`[data-video-id="${video.id}"]`)?.closest("tr");
if (!existingRow) {
tableBody.appendChild(createVideoRow(video, index));
}
});
// Update status
statusEl.textContent = `Fetched ${state.fetchedCount} videos`;
updateUI();
});
setupTableEventListeners();
} catch (error) {
console.error("Error fetching videos:", error);
statusEl.textContent = "Error: " + error.message;
} finally {
state.isFetching = false;
button.disabled = false;
button.innerHTML = "<span>Fetch Videos</span>";
}
});
// Download dropdown
const downloadBtn = document.getElementById("download-btn");
const dropdownMenu = document.getElementById("dropdown-menu");
downloadBtn.addEventListener("click", () => {
dropdownMenu.classList.toggle("hidden");
});
// Close dropdown when clicking outside
document.addEventListener("click", (e) => {
if (!downloadBtn.contains(e.target)) {
dropdownMenu.classList.add("hidden");
}
});
// Download actions
dropdownMenu.addEventListener("click", async (e) => {
const action = e.target.dataset.action;
if (!action) return;
const selectedVideos = state.videos.filter((v) => state.selectedVideos.has(v.id));
if (selectedVideos.length === 0) return;
// Hide dropdown
dropdownMenu.classList.add("hidden");
switch (action) {
case "audio":
await downloadFiles(selectedVideos, "audio");
break;
case "video":
await downloadFiles(selectedVideos, "video");
break;
case "json":
FileHandler.saveVideoUrls(selectedVideos, { downloadJson: true, downloadTxt: false });
break;
case "txt":
FileHandler.saveVideoUrls(selectedVideos, { downloadJson: false, downloadTxt: true });
break;
}
});
}
function setupTableEventListeners() {
// Select all checkbox
document.getElementById("select-all").addEventListener("change", (e) => {
const checkboxes = document.querySelectorAll(".video-checkbox");
checkboxes.forEach((checkbox) => {
checkbox.checked = e.target.checked;
const videoId = checkbox.dataset.videoId;
if (e.target.checked) {
state.selectedVideos.add(videoId);
} else {
state.selectedVideos.delete(videoId);
}
});
updateUI();
});
// Individual video checkboxes
document.querySelectorAll(".video-checkbox").forEach((checkbox) => {
checkbox.addEventListener("change", (e) => {
const videoId = e.target.dataset.videoId;
if (e.target.checked) {
state.selectedVideos.add(videoId);
} else {
state.selectedVideos.delete(videoId);
}
updateUI();
});
});
}
// Configuration
const CONFIG = {
API_BASE_URL: "https://www.douyin.com/aweme/v1/web/aweme/post/",
DEFAULT_HEADERS: {
accept: "application/json, text/plain, */*",
"accept-language": "vi",
"sec-ch-ua": '"Not?A_Brand";v="8", "Chromium";v="118", "Microsoft Edge";v="118"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 Edg/118.0.0.0",
},
RETRY_DELAY_MS: 2000,
MAX_RETRIES: 5,
REQUEST_DELAY_MS: 1000,
};
// Utility functions
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const waitForElement = (selector, timeout = 30000, interval = 100) => {
return new Promise((resolve, reject) => {
// Check if element already exists
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
// Set up the timeout
const timeoutId = setTimeout(() => {
observer.disconnect();
clearInterval(checkInterval);
reject(new Error(`Timeout waiting for element: ${selector}`));
}, timeout);
// Set up the mutation observer
const observer = new MutationObserver((mutations, obs) => {
const element = document.querySelector(selector);
if (element) {
obs.disconnect();
clearInterval(checkInterval);
clearTimeout(timeoutId);
resolve(element);
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Also poll periodically as a backup
const checkInterval = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
clearInterval(checkInterval);
clearTimeout(timeoutId);
resolve(element);
}
}, interval);
});
};
const retryWithDelay = async (fn, retries = CONFIG.MAX_RETRIES) => {
let lastError;
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
console.log(`Attempt ${i + 1} failed:`, error);
await sleep(CONFIG.RETRY_DELAY_MS);
}
}
throw lastError;
};
// API Client
class DouyinApiClient {
constructor(secUserId) {
this.secUserId = secUserId;
}
async fetchVideos(maxCursor) {
const url = new URL(CONFIG.API_BASE_URL);
const params = {
device_platform: "webapp",
aid: "6383",
channel: "channel_pc_web",
sec_user_id: this.secUserId,
max_cursor: maxCursor,
count: "20",
version_code: "170400",
version_name: "17.4.0",
};
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
const response = await fetch(url, {
headers: {
...CONFIG.DEFAULT_HEADERS,
referrer: `https://www.douyin.com/user/${this.secUserId}`,
},
credentials: "include",
});
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response.json();
}
}
// Data Processing
class VideoDataProcessor {
static extractVideoMetadata(video) {
if (!video) return null;
// Initialize the metadata object
const metadata = {
id: video.aweme_id || "",
desc: video.desc || "",
title: video.desc || "", // Using desc as the title since title field isn't directly available
createTime: video.create_time ? new Date(video.create_time * 1000).toISOString() : "",
videoUrl: "",
audioUrl: "",
coverUrl: "",
dynamicCoverUrl: "",
};
// Extract video URL
if (video.video?.play_addr) {
metadata.videoUrl = video.video.play_addr.url_list[0];
if (metadata.videoUrl && !metadata.videoUrl.startsWith("https")) {
metadata.videoUrl = metadata.videoUrl.replace("http", "https");
}
} else if (video.video?.download_addr) {
metadata.videoUrl = video.video.download_addr.url_list[0];
if (metadata.videoUrl && !metadata.videoUrl.startsWith("https")) {
metadata.videoUrl = metadata.videoUrl.replace("http", "https");
}
}
// Extract audio URL
if (video.music?.play_url) {
metadata.audioUrl = video.music.play_url.url_list[0];
}
// Extract cover URL (static thumbnail)
if (video.video?.cover) {
metadata.coverUrl = video.video.cover.url_list[0];
} else if (video.cover) {
metadata.coverUrl = video.cover.url_list[0];
}
// Extract dynamic cover URL (animated thumbnail)
if (video.video?.dynamic_cover) {
metadata.dynamicCoverUrl = video.video.dynamic_cover.url_list[0];
} else if (video.dynamic_cover) {
metadata.dynamicCoverUrl = video.dynamic_cover.url_list[0];
}
return metadata;
}
static processVideoData(data) {
if (!data?.aweme_list) {
return { videoData: [], hasMore: false, maxCursor: 0 };
}
const videoData = data.aweme_list.map((video) => this.extractVideoMetadata(video)).filter((item) => item && item.videoUrl);
return {
videoData,
hasMore: data.has_more,
maxCursor: data.max_cursor,
};
}
}
// File Handler
class FileHandler {
static saveVideoUrls(videoData, options = { downloadJson: true, downloadTxt: true }) {
if (!videoData || videoData.length === 0) {
console.warn("No video data to save");
return { savedCount: 0 };
}
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, "-");
let savedCount = 0;
// Save complete JSON data if option is enabled
if (options.downloadJson) {
const jsonContent = JSON.stringify(videoData, null, 2);
const jsonBlob = new Blob([jsonContent], { type: "application/json" });
const jsonUrl = URL.createObjectURL(jsonBlob);
const jsonLink = document.createElement("a");
jsonLink.href = jsonUrl;
jsonLink.download = `douyin-video-data-${timestamp}.json`;
jsonLink.style.display = "none";
document.body.appendChild(jsonLink);
jsonLink.click();
document.body.removeChild(jsonLink);
console.log(`Saved ${videoData.length} videos with metadata to JSON file`);
}
// Save plain URLs list if option is enabled
if (options.downloadTxt) {
// Create a list of video URLs
const urlList = videoData.map((video) => video.videoUrl).join("\n");
const txtBlob = new Blob([urlList], { type: "text/plain" });
const txtUrl = URL.createObjectURL(txtBlob);
const txtLink = document.createElement("a");
txtLink.href = txtUrl;
txtLink.download = `douyin-video-links-${timestamp}.txt`;
txtLink.style.display = "none";
document.body.appendChild(txtLink);
txtLink.click();
document.body.removeChild(txtLink);
console.log(`Saved ${videoData.length} video URLs to text file`);
}
savedCount = videoData.length;
return { savedCount };
}
}
// Main Downloader
class DouyinDownloader {
constructor() {
this.validateEnvironment();
const secUserId = this.extractSecUserId();
this.apiClient = new DouyinApiClient(secUserId);
}
validateEnvironment() {
if (typeof window === "undefined" || !window.location) {
throw new Error("Script must be run in a browser environment");
}
}
extractSecUserId() {
const secUserId = location.pathname.replace("/user/", "");
if (!secUserId || location.pathname.indexOf("/user/") === -1) {
throw new Error("Please run this script on a DouYin user profile page!");
}
return secUserId;
}
async fetchAllVideos(onProgress) {
let hasMore = true;
let maxCursor = 0;
while (hasMore) {
const data = await retryWithDelay(() => this.apiClient.fetchVideos(maxCursor));
const { videoData, hasMore: more, maxCursor: newCursor } = VideoDataProcessor.processVideoData(data);
if (onProgress) {
onProgress(videoData);
}
hasMore = more;
maxCursor = newCursor;
await sleep(CONFIG.REQUEST_DELAY_MS);
}
}
}
// Initialize the UI
async function initializeUI() {
// Add custom styles for animations
const style = document.createElement("style");
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
`;
document.head.appendChild(style);
// Create UI elements (hidden initially)
createMainUI();
// Add download button to profile
await addDownloadButton();
// Setup all event listeners
setupEventListeners();
setupTableEventListeners();
setupDialogEventListeners();
}
// Start the script
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
initializeUI().catch((error) => {
console.error("Failed to initialize UI:", error);
});
});
} else {
initializeUI().catch((error) => {
console.error("Failed to initialize UI:", error);
});
}
async function downloadFile(url, filename) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
return true;
} catch (error) {
console.error(`Failed to download ${filename}:`, error);
return false;
}
}
async function downloadFiles(files, type = "video") {
const statusEl = document.getElementById("fetch-status");
const total = files.length;
let successful = 0;
let failed = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const url = type === "video" ? file.videoUrl : file.audioUrl;
if (!url) {
failed++;
continue;
}
// Update status
statusEl.textContent = `Downloading ${type} ${i + 1}/${total}...`;
// Generate filename
const timestamp = new Date(file.createTime).toISOString().split("T")[0];
const filename = `douyin_${type}_${timestamp}_${file.id}.${type === "video" ? "mp4" : "mp3"}`;
// Download file
const success = await downloadFile(url, filename);
if (success) {
successful++;
} else {
failed++;
}
// Small delay between downloads to prevent browser blocking
await sleep(500);
}
// Final status update
statusEl.textContent = `Download complete: ${successful} successful, ${failed} failed`;
setTimeout(() => {
statusEl.textContent = "";
}, 5000);
}
})();