Subsource.net Batch Downloader

Batch download subtitles from subsource.net

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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