Subsource.net Batch Downloader

Batch download subtitles from subsource.net

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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();
  }
})();