Skip to content

Batch URL Import #404

@codeCraft-Ritik

Description

@codeCraft-Ritik

Issue Title

Add Batch URL Import - Import multiple URLs from file or text area

Summary

Currently, ytDownloader only supports downloading one video at a time by pasting a single URL from the clipboard. Users who want to download multiple videos must paste each URL individually, which is time-consuming.

This feature would allow users to:

  1. Paste multiple URLs at once (one per line) via a dedicated text area
  2. Import URLs from a .txt file containing a list of video links
  3. Queue all URLs for sequential downloading

Use Case

  1. Users who have a list of videos to download (e.g., from a course, tutorial series, or curated collection)
  2. Users who save video links in a text file for later downloading
  3. Power users who want to batch process downloads without manual intervention

Proposed Implementation

  1. UI Changes (html/index.html)

Add a new "Batch Import" button next to the paste button, and a modal dialog for batch input:

<!-- Add next to existing pasteUrl button -->
<button id="batchImportBtn" class="blueBtn" style="margin-left: 10px;">
    <span data-translate="batchImport">Batch Import</span>
</button>

<!-- Batch Import Modal -->
<div id="batchModal" class="modal">
    <div class="modal-content">
        <img src="../assets/images/close.png" alt="close" id="closeBatchModal" class="modal-close">
        <h2 data-translate="batchImportTitle">Import Multiple URLs</h2>
        
        <div class="batch-options">
            <textarea id="batchUrlInput" 
                      placeholder="Paste URLs here (one per line)&#10;https://youtube.com/watch?v=...&#10;https://youtube.com/watch?v=..."
                      rows="8"></textarea>
            
            <div class="batch-divider">
                <span data-translate="or">OR</span>
            </div>
            
            <button id="importFromFile" class="blueBtn">
                <span data-translate="importFromFile">Import from File (.txt)</span>
            </button>
            <p id="importedFileInfo"></p>
        </div>
        
        <div class="batch-footer">
            <span id="urlCount">0 URLs detected</span>
            <button id="startBatchDownload" class="submitBtn" data-translate="addToQueue">Add to Queue</button>
        </div>
    </div>
</div>

2. CSS Styles (assets/css/index.css)

/* Batch Import Modal */
.modal {
    display: none;
    position: fixed;
    z-index: 10;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(17, 25, 40, 0.85);
    backdrop-filter: blur(4px);
}

.modal-content {
    position: relative;
    background-color: var(--box-main);
    margin: 10% auto;
    padding: 25px;
    border-radius: 16px;
    width: 500px;
    max-width: 90%;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}

.modal-close {
    position: absolute;
    top: 15px;
    right: 15px;
    width: 20px;
    height: 20px;
    cursor: pointer;
}

#batchUrlInput {
    width: 100%;
    padding: 12px;
    border: 2px solid var(--box-separation);
    border-radius: 8px;
    background-color: var(--box-toggle);
    color: var(--text);
    font-family: "JetBrains", monospace;
    font-size: 14px;
    resize: vertical;
    min-height: 150px;
}

#batchUrlInput:focus {
    border-color: var(--blueBtn);
    outline: none;
}

.batch-divider {
    display: flex;
    align-items: center;
    margin: 20px 0;
    color: var(--text);
    opacity: 0.6;
}

.batch-divider::before,
.batch-divider::after {
    content: "";
    flex: 1;
    height: 1px;
    background-color: var(--box-separation);
}

.batch-divider span {
    padding: 0 15px;
}

.batch-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 20px;
    padding-top: 15px;
    border-top: 1px solid var(--box-separation);
}

#urlCount {
    color: var(--text);
    opacity: 0.8;
}

3. JavaScript Logic (src/renderer.js)

// Add to CONSTANTS.DOM_IDS
BATCH_IMPORT_BTN: "batchImportBtn",
BATCH_MODAL: "batchModal",
CLOSE_BATCH_MODAL: "closeBatchModal",
BATCH_URL_INPUT: "batchUrlInput",
IMPORT_FROM_FILE: "importFromFile",
START_BATCH_DOWNLOAD: "startBatchDownload",
URL_COUNT: "urlCount",
IMPORTED_FILE_INFO: "importedFileInfo",

// Add to _addEventListeners()
$(CONSTANTS.DOM_IDS.BATCH_IMPORT_BTN).addEventListener("click", () => 
    this._openBatchModal()
);
$(CONSTANTS.DOM_IDS.CLOSE_BATCH_MODAL).addEventListener("click", () => 
    this._closeBatchModal()
);
$(CONSTANTS.DOM_IDS.BATCH_URL_INPUT).addEventListener("input", () => 
    this._updateUrlCount()
);
$(CONSTANTS.DOM_IDS.IMPORT_FROM_FILE).addEventListener("click", () => 
    this._importUrlsFromFile()
);
$(CONSTANTS.DOM_IDS.START_BATCH_DOWNLOAD).addEventListener("click", () => 
    this._processBatchUrls()
);

// New methods
_openBatchModal() {
    $(CONSTANTS.DOM_IDS.BATCH_MODAL).style.display = "block";
    $(CONSTANTS.DOM_IDS.BATCH_URL_INPUT).value = "";
    this._updateUrlCount();
}

_closeBatchModal() {
    $(CONSTANTS.DOM_IDS.BATCH_MODAL).style.display = "none";
}

_parseUrls(text) {
    return text
        .split(/[\n\r]+/)
        .map(line => line.trim())
        .filter(line => {
            // Basic URL validation
            try {
                new URL(line);
                return true;
            } catch {
                return false;
            }
        });
}

_updateUrlCount() {
    const urls = this._parseUrls($(CONSTANTS.DOM_IDS.BATCH_URL_INPUT).value);
    const count = urls.length;
    $(CONSTANTS.DOM_IDS.URL_COUNT).textContent = 
        `${count} ${count === 1 ? 'URL' : 'URLs'} ${i18n.__("detected") || "detected"}`;
}

async _importUrlsFromFile() {
    ipcRenderer.send("select-text-file");
}

// Add IPC listener in _addEventListeners()
ipcRenderer.on("text-file-selected", (event, result) => {
    if (result.success) {
        $(CONSTANTS.DOM_IDS.BATCH_URL_INPUT).value = result.content;
        $(CONSTANTS.DOM_IDS.IMPORTED_FILE_INFO).textContent = 
            `Imported: ${result.filename}`;
        this._updateUrlCount();
    }
});

async _processBatchUrls() {
    const urls = this._parseUrls($(CONSTANTS.DOM_IDS.BATCH_URL_INPUT).value);
    
    if (urls.length === 0) {
        this._showPopup(i18n.__("noValidUrls") || "No valid URLs found", true);
        return;
    }
    
    this._closeBatchModal();
    this._showPopup(`${urls.length} ${i18n.__("urlsAddedToQueue") || "URLs added to queue"}`, false);
    
    // Process each URL sequentially
    for (const url of urls) {
        // Add a small delay between fetching info to avoid rate limiting
        await this._addUrlToQueue(url);
        await new Promise(resolve => setTimeout(resolve, 500));
    }
}

async _addUrlToQueue(url) {
    try {
        const metadata = await this._fetchVideoMetadata(url);
        
        // Create a download job with default settings
        const downloadJob = {
            type: "video", // Default to video, could be made configurable
            url: url,
            title: metadata.title,
            thumbnail: metadata.thumbnail,
            options: {...this.state.downloadOptions},
            uiSnapshot: {
                videoFormat: this._getBestVideoFormat(metadata.formats),
                audioForVideoFormat: this._getBestAudioFormat(metadata.formats),
                audioFormat: "",
                extractFormat: "mp3",
                extractQuality: "5",
            },
        };
        
        if (this.state.currentDownloads < this.state.maxActiveDownloads) {
            this._startDownload(downloadJob);
        } else {
            this._queueDownload(downloadJob);
        }
    } catch (error) {
        console.error(`Failed to fetch info for ${url}:`, error);
        // Optionally show a notification for failed URLs
    }
}

_getBestVideoFormat(formats) {
    // Return best format matching user preferences
    const preferredQuality = this.state.preferences.videoQuality;
    const videoFormats = formats.filter(f => f.vcodec !== "none" && f.height);
    const sorted = videoFormats.sort((a, b) => (b.height || 0) - (a.height || 0));
    const best = sorted.find(f => f.height <= preferredQuality) || sorted[0];
    return best ? `${best.format_id}|${best.ext}|${best.height}|${best.vcodec}` : "";
}

_getBestAudioFormat(formats) {
    const audioFormats = formats.filter(f => f.acodec !== "none" && f.vcodec === "none");
    const best = audioFormats.sort((a, b) => (b.abr || 0) - (a.abr || 0))[0];
    return best ? `${best.format_id}|${best.ext}` : "none|none";
}

4. Main Process Handler (main.js)

// Add file dialog handler for text files
ipcMain.on("select-text-file", (event) => {
    dialog.showOpenDialog({
        properties: ["openFile"],
        filters: [
            { name: "Text Files", extensions: ["txt"] },
            { name: "All Files", extensions: ["*"] }
        ]
    }).then(result => {
        if (!result.canceled && result.filePaths.length > 0) {
            const filePath = result.filePaths[0];
            const content = fs.readFileSync(filePath, "utf-8");
            const filename = path.basename(filePath);
            event.sender.send("text-file-selected", { 
                success: true, 
                content, 
                filename 
            });
        }
    }).catch(err => {
        console.error("Error reading file:", err);
        event.sender.send("text-file-selected", { success: false });
    });
});

5. Add Translation Keys (translations/en.json)

{
    "batchImport": "Batch Import",
    "batchImportTitle": "Import Multiple URLs",
    "or": "OR",
    "importFromFile": "Import from File (.txt)",
    "addToQueue": "Add to Queue",
    "detected": "detected",
    "noValidUrls": "No valid URLs found",
    "urlsAddedToQueue": "URLs added to queue"
}

Benefits

  1. Time-saving - Download dozens of videos with one action
  2. Workflow integration - Import from curated lists
  3. Power user friendly - Works with existing queue system
  4. Non-disruptive - Existing single-URL workflow remains unchanged

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions