Subsource.net Batch Downloader

Batch download subtitles from subsource.net

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Subsource.net Batch Downloader
// @namespace    Subsource.net Downloader
// @version      1.0.0
// @description  Batch download subtitles from subsource.net
// @icon         https://www.google.com/s2/favicons?sz=64&domain=subsource.net
// @author       ashik4u, https://github.com/ashik4u
// @license      GPL-3.0
// @match        https://subsource.net/
// @match        https://subsource.net/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @connect      api.subsource.net
// ==/UserScript==

(function () {
  'use strict';

  let selectedRows = new Set();
  let apiKey = GM_getValue('subsource_api_key', '');
  let activePageKey = getPageKey();
  let isDownloading = false;
  let refreshTimer = null;

  GM_registerMenuCommand('Set API Key', () => {
    const key = prompt('Enter your API Key:', apiKey);
    if (key !== null) {
      apiKey = key.trim();
      GM_setValue('subsource_api_key', apiKey);
      alert('API Key saved!');
    }
  });

  function shouldActivate(url) {
    const path = new URL(url).pathname;
    return /^\/subtitles\/[^/]+(?:\/season-\d+)?\/?$/.test(path);
  }

  function getPageKey() {
    return shouldActivate(location.href) ? new URL(location.href).pathname : '';
  }

  function resetSelectionIfPageChanged() {
    const pageKey = getPageKey();
    if (pageKey !== activePageKey) {
      activePageKey = pageKey;
      selectedRows.clear();
    }
  }

  function activate() {
    resetSelectionIfPageChanged();

    if (!shouldActivate(location.href)) {
      removeUI();
      return;
    }

    createCheckboxColumn();
  }

  function removeUI() {
    ['batch-download-btn', 'selected-counter', 'download-notifications'].forEach(id => {
      const el = document.getElementById(id);
      if (el) el.remove();
    });
  }

  function createCheckboxColumn() {
    const table = document.querySelector('table.w-full');
    if (!table) return;

    const thead = table.querySelector('thead tr');
    if (thead && !document.getElementById('select-all-subs')) {
      const selectAllTh = document.createElement('th');
      selectAllTh.className = 'px-2 py-3 text-center w-[3%]';

      const selectAll = document.createElement('input');
      selectAll.type = 'checkbox';
      selectAll.id = 'select-all-subs';
      selectAll.className = 'cursor-pointer w-4 h-4';

      selectAllTh.appendChild(selectAll);
      thead.insertBefore(selectAllTh, thead.firstChild);
    }

    const rows = table.querySelectorAll('tbody tr.subtitles-table-row');
    rows.forEach(row => {
      const existing = row.querySelector('.sub-checkbox');
      if (existing) {
        existing.checked = selectedRows.has(existing.dataset.id);
        return;
      }

      const linkElement = row.querySelector('a[href*="/subtitle/"]');
      if (!linkElement) return;

      const href = linkElement.getAttribute('href');
      const match = href.match(/\/(\d+)$/);
      if (!match) return;

      const id = match[1];
      const releaseName = row.querySelector('td:nth-child(4) a')?.textContent.trim() || 'Unknown';

      const checkboxTd = document.createElement('td');
      checkboxTd.className = 'px-2 py-3 text-center';

      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.className = 'sub-checkbox cursor-pointer w-4 h-4';
      checkbox.dataset.id = id;
      checkbox.dataset.name = releaseName;
      checkbox.checked = selectedRows.has(id);

      checkboxTd.appendChild(checkbox);
      row.insertBefore(checkboxTd, row.firstChild);
    });

    bindControls();
    createDownloadButton();
    createNotificationArea();
    createSelectedCounter();
    updateSelectedCount();
    updateSelectAllState();
  }

  function bindControls() {
    const selectAll = document.getElementById('select-all-subs');
    if (selectAll && !selectAll.dataset.bound) {
      selectAll.dataset.bound = 'true';
      selectAll.addEventListener('change', e => {
        const checkboxes = document.querySelectorAll('.sub-checkbox');
        checkboxes.forEach(cb => {
          cb.checked = e.target.checked;
          if (e.target.checked) selectedRows.add(cb.dataset.id);
          else selectedRows.delete(cb.dataset.id);
        });
        updateSelectedCount();
        updateSelectAllState();
      });
    }

    document.querySelectorAll('.sub-checkbox').forEach(cb => {
      if (cb.dataset.bound) return;
      cb.dataset.bound = 'true';
      cb.addEventListener('change', e => {
        const id = e.target.dataset.id;
        if (e.target.checked) selectedRows.add(id);
        else selectedRows.delete(id);
        updateSelectedCount();
        updateSelectAllState();
      });
    });
  }

  function createSelectedCounter() {
    if (document.getElementById('selected-counter')) return;
    const counter = document.createElement('div');
    counter.id = 'selected-counter';
    counter.style.cssText =
      'position: fixed; bottom: 80px; right: 20px; background: #3b82f6; color: white; padding: 10px 15px; border-radius: 8px; font-weight: bold; z-index: 9999; box-shadow: 0 4px 6px rgba(0,0,0,0.1);';
    document.body.appendChild(counter);
  }

  function updateSelectedCount() {
    const counter = document.getElementById('selected-counter');
    if (counter) counter.textContent = `Selected: ${selectedRows.size}`;
  }

  function updateSelectAllState() {
    const selectAll = document.getElementById('select-all-subs');
    if (!selectAll) return;

    const checkboxes = Array.from(document.querySelectorAll('.sub-checkbox'));
    const checkedCount = checkboxes.filter(cb => cb.checked).length;

    selectAll.checked = checkboxes.length > 0 && checkedCount === checkboxes.length;
    selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
  }

  function createDownloadButton() {
    if (document.getElementById('batch-download-btn')) return;
    const btn = document.createElement('button');
    btn.id = 'batch-download-btn';
    btn.type = 'button';
    btn.textContent = 'Download';
    btn.style.cssText =
      'position: fixed; bottom: 20px; right: 20px; background: #10b981; color: white; padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 16px; z-index: 9999; box-shadow: 0 4px 6px rgba(0,0,0,0.1);';
    btn.addEventListener('click', startBatchDownload);
    document.body.appendChild(btn);
  }

  function createNotificationArea() {
    if (document.getElementById('download-notifications')) return;
    const notifArea = document.createElement('div');
    notifArea.id = 'download-notifications';
    notifArea.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000; max-width: 300px;';
    document.body.appendChild(notifArea);
  }

  function showNotification(message, isProgress = false) {
    const notifArea = document.getElementById('download-notifications');
    if (!notifArea) return null;

    const notif = document.createElement('div');
    notif.className = 'download-notif';
    notif.style.cssText =
      'background: white; border-left: 4px solid #3b82f6; padding: 12px; margin-bottom: 10px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); font-size: 14px;';
    notif.textContent = message;
    notifArea.appendChild(notif);
    if (!isProgress) setTimeout(() => notif.remove(), 3000);
    return notif;
  }

  async function downloadSubtitle(id, name) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: `https://api.subsource.net/api/v1/subtitles/${encodeURIComponent(id)}/download`,
        headers: { 'X-API-Key': apiKey },
        responseType: 'blob',
        timeout: 30000,
        onload: response => {
          if (response.status === 200) {
            resolve({ blob: response.response, name, id });
          } else {
            reject(new Error(`Status ${response.status}`));
          }
        },
        onerror: () => reject(new Error('Network error')),
        ontimeout: () => reject(new Error('Timeout')),
      });
    });
  }

  async function extractZipFlat(zipBlob, mainZip, item) {
    const zipData = await JSZip.loadAsync(zipBlob);

    for (const [fileName, fileObj] of Object.entries(zipData.files)) {
      if (!fileObj.dir) {
        const fileData = await fileObj.async('arraybuffer');
        const safeName = safeFilePart(fileName.split(/[\\/]+/).filter(Boolean).pop() || 'subtitle');
        mainZip.file(getUniqueZipName(mainZip, safeName), fileData);
      }
    }
  }

  function getUniqueZipName(zip, fileName) {
    if (!zip.file(fileName)) return fileName;

    const dotIndex = fileName.lastIndexOf('.');
    const base = dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
    const ext = dotIndex > 0 ? fileName.slice(dotIndex) : '';
    let index = 2;
    let candidate = `${base}_${index}${ext}`;

    while (zip.file(candidate)) {
      index++;
      candidate = `${base}_${index}${ext}`;
    }

    return candidate;
  }

  function safeFilePart(value) {
    return String(value || 'unknown')
      .trim()
      .replace(/[^a-z0-9.\-_ ]/gi, '_')
      .replace(/\s+/g, '_')
      .replace(/^_+|_+$/g, '')
      .slice(0, 120) || 'unknown';
  }

  function downloadFile(blob, filename) {
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      document.body.removeChild(a);
      window.URL.revokeObjectURL(url);
    }, 100);
  }

  function getSafeNameFromURL() {
    const path = location.pathname.replace(/^\/subtitles\//, '').replace(/\/$/, '');
    const clean = path.replace(/\//g, '-').replace(/[^a-z0-9\-_]/gi, '');
    return clean || 'subsource';
  }

  function getSelectedDataFromPage() {
    return Array.from(document.querySelectorAll('.sub-checkbox:checked')).map(cb => ({
      id: cb.dataset.id,
      name: cb.dataset.name || 'Unknown',
    }));
  }

  function setDownloadButtonState(disabled) {
    const btn = document.getElementById('batch-download-btn');
    if (!btn) return;

    btn.disabled = disabled;
    btn.textContent = disabled ? 'Downloading...' : 'Download';
    btn.style.opacity = disabled ? '0.65' : '1';
    btn.style.cursor = disabled ? 'wait' : 'pointer';
  }

  async function startBatchDownload() {
    if (isDownloading) return;

    if (!apiKey) {
      alert('Please set your API Key first! Go to Tampermonkey menu > Set API Key');
      return;
    }

    const selectedData = getSelectedDataFromPage();
    if (selectedData.length === 0) {
      alert('Please select at least one subtitle to download');
      selectedRows.clear();
      updateSelectedCount();
      updateSelectAllState();
      return;
    }

    isDownloading = true;
    setDownloadButtonState(true);

    const progressNotif = showNotification(`Starting... (0/${selectedData.length})`, true);
    let completed = 0;
    const combinedZip = new JSZip();

    for (const item of selectedData) {
      try {
        if (progressNotif) progressNotif.textContent = `Downloading: ${item.name} (${completed + 1}/${selectedData.length})`;
        const result = await downloadSubtitle(item.id, item.name);
        await extractZipFlat(result.blob, combinedZip, item);
        completed++;
        if (progressNotif) progressNotif.textContent = `Extracted: ${completed}/${selectedData.length}`;
        await new Promise(resolve => setTimeout(resolve, 300));
      } catch (error) {
        console.error('Download error:', error);
        showNotification(`Failed: ${item.name.substring(0, 30)}...`);
      }
    }

    if (completed === 0) {
      if (progressNotif) progressNotif.remove();
      showNotification('No subtitles were downloaded successfully.');
      isDownloading = false;
      setDownloadButtonState(false);
      return;
    }

    if (progressNotif) progressNotif.textContent = `Creating final ZIP (${completed} sets)...`;

    try {
      const finalZipBlob = await combinedZip.generateAsync({
        type: 'blob',
        compression: 'DEFLATE',
        compressionOptions: { level: 6 },
      });

      const baseName = getSafeNameFromURL();
      const filename = `${baseName}.zip`;

      downloadFile(finalZipBlob, filename);

      setTimeout(() => {
        if (progressNotif) progressNotif.remove();
        showNotification(`Combined ${completed} ZIPs into ${filename}!`);
        selectedRows.clear();
        document.querySelectorAll('.sub-checkbox').forEach(cb => (cb.checked = false));
        updateSelectedCount();
        updateSelectAllState();
      }, 500);
    } catch (error) {
      console.error('ZIP creation error:', error);
      if (progressNotif) progressNotif.remove();
      showNotification('ZIP failed: ' + error.message);
    } finally {
      isDownloading = false;
      setDownloadButtonState(false);
    }
  }

  function scheduleActivate() {
    clearTimeout(refreshTimer);
    refreshTimer = setTimeout(activate, 150);
  }

  const observer = new MutationObserver(scheduleActivate);
  observer.observe(document.body, { childList: true, subtree: true });

  let lastUrl = location.href;
  new MutationObserver(() => {
    const current = location.href;
    if (current !== lastUrl) {
      lastUrl = current;
      setTimeout(activate, 500);
    }
  }).observe(document.body, { childList: true, subtree: true });

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', activate);
  } else {
    activate();
  }
})();