MEGA Desktop - Auto Queue + Metadata

Desktop-only queue helper for MEGA folder pages. Scans files, captures metadata, supports checkboxes and copy selected/all.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         MEGA Desktop - Auto Queue + Metadata
// @namespace    http://tampermonkey.net/
// @version      2.5.3
// @description  Desktop-only queue helper for MEGA folder pages. Scans files, captures metadata, supports checkboxes and copy selected/all.
// @author       adapted
// @license      MIT
// @match        *://mega.nz/folder/*
// @match        *://mega.io/folder/*
// @grant        GM_setClipboard
// @grant        GM_openInTab
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  let panel = null;
  let listEl = null;
  let queue = [];
  let scanning = false;

  function getBaseUrl() {
    const match = window.location.href.match(/^(https?:\/\/[^/]+\/folder\/[^#]+#[^/]+)/);
    return match ? match[1] : null;
  }

  function normaliseHandle(value) {
    if (value === null || value === undefined) return null;
    let h = String(value).trim();
    if (!h) return null;

    if (window.M && window.M.d && window.M.d[h]) return h;

    if (window.M && window.M.d) {
      const known = Object.keys(window.M.d).find((k) => h.includes(k));
      if (known) return known;
    }

    const tokens = h.match(/[A-Za-z0-9_-]{6,}/g);
    if (tokens && tokens.length) h = tokens[tokens.length - 1];

    return h.length > 5 && !h.includes(" ") ? h : null;
  }

  function addHandlesFrom(source, out) {
    if (!source) return;
    if (typeof source === "string") {
      const h = normaliseHandle(source);
      if (h) out.push(h);
      return;
    }
    if (Array.isArray(source) || source instanceof Set) {
      [...source].forEach((v) => {
        const h = normaliseHandle(v && (v.h || v.id || v.handle || v.nodeHandle || v));
        if (h) out.push(h);
      });
      return;
    }
    if (typeof source === "object") {
      ["selected", "selected_list", "items"].forEach((k) => source[k] && addHandlesFrom(source[k], out));
      Object.keys(source).forEach((k) => {
        const v = source[k];
        const keyHandle = normaliseHandle(k);
        if (keyHandle && (v === true || v === 1 || typeof v === "object")) out.push(keyHandle);
        const valueHandle = normaliseHandle(v && (v.h || v.id || v.handle || v.nodeHandle));
        if (valueHandle) out.push(valueHandle);
      });
    }
  }

  function getSelectedHandles() {
    const handles = [];
    if (window.$ && window.$.selected) addHandlesFrom(window.$.selected, handles);

    if (window.selectionManager) {
      if (typeof window.selectionManager.get_selected === "function") {
        addHandlesFrom(window.selectionManager.get_selected() || [], handles);
      }
      addHandlesFrom(window.selectionManager.selected_list, handles);
      addHandlesFrom(window.selectionManager.selected, handles);
    }

    const sels = document.querySelectorAll(
      [
        ".ui-selected",
        ".data-block-view.selected",
        "tr.selected",
        ".grid-node.selected",
        ".file.selected",
        ".folder.selected",
        ".megaListItem.selected",
        '[aria-selected="true"]',
      ].join(",")
    );

    sels.forEach((el) => {
      ["data-id", "data-h", "data-handle", "data-node-handle", "id"].forEach((attr) => {
        const h = normaliseHandle(el.getAttribute(attr));
        if (h) handles.push(h);
      });
    });

    return [...new Set(handles)];
  }

  function getNode(handle) {
    return (window.M && window.M.d && window.M.d[handle]) ||
      (window.M && window.M.v && window.M.v.find((n) => n.h === handle)) ||
      null;
  }

  function getChildrenHandles(parent) {
    const children = [];
    if (window.M && window.M.c && window.M.c[parent]) {
      Object.keys(window.M.c[parent]).forEach((h) => children.push(h));
    }
    if (window.M && window.M.d) {
      Object.keys(window.M.d).forEach((h) => {
        const node = window.M.d[h];
        if (node && node.p === parent) children.push(node.h || h);
      });
    }
    return [...new Set(children)];
  }

  function getAllDescendantFileHandles(folderHandle) {
    const out = [];
    const seen = new Set();
    const stack = [folderHandle];

    while (stack.length) {
      const parent = stack.pop();
      if (!parent || seen.has(parent)) continue;
      seen.add(parent);

      getChildrenHandles(parent).forEach((h) => {
        const node = getNode(h);
        if (node && node.t === 1) stack.push(node.h || h);
        else out.push((node && node.h) || h);
      });
    }

    return [...new Set(out)];
  }

  function formatBytes(size) {
    if (typeof size !== "number" || size < 0) return "Unknown";
    const units = ["B", "KB", "MB", "GB", "TB"];
    let n = size;
    let i = 0;
    while (n >= 1024 && i < units.length - 1) {
      n /= 1024;
      i += 1;
    }
    return n.toFixed(i === 0 ? 0 : 2) + " " + units[i];
  }

  function formatDuration(seconds) {
    if (!seconds || !Number.isFinite(seconds)) return "Unknown";
    const total = Math.round(seconds);
    const h = Math.floor(total / 3600);
    const m = Math.floor((total % 3600) / 60);
    const s = total % 60;
    if (h > 0) return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
    return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
  }

  function extractResolution(node) {
    if (!node) return "Unknown";
    if (node.width && node.height) return node.width + "x" + node.height;

    const fa = String(node.fa || "");
    const match = fa.match(/(\d{3,5})x(\d{3,5})/);
    return match ? match[1] + "x" + match[2] : "Unknown";
  }

  function getMeta(node) {
    return {
      size: formatBytes(node && node.s),
      length: formatDuration((node && (node.playtime || node.duration || node.dur)) || 0),
      resolution: extractResolution(node),
    };
  }

  function escapeHtml(value) {
    return String(value).replace(/[&<>'"]/g, (c) => ({
      "&": "&amp;",
      "<": "&lt;",
      ">": "&gt;",
      "'": "&#39;",
      '"': "&quot;",
    }[c]));
  }

  function makeItem(handle) {
    const baseUrl = getBaseUrl();
    if (!baseUrl) return null;
    const node = getNode(handle);
    const meta = getMeta(node);
    return {
      handle: handle,
      name: (node && (node.name || node.n)) || handle,
      url: baseUrl + "/file/" + handle,
      size: meta.size,
      length: meta.length,
      resolution: meta.resolution,
      checked: true,
      status: "Queued",
    };
  }

  function pushUnique(handles) {
    const existing = new Set(queue.map((x) => x.url));
    let added = 0;
    handles.forEach((h) => {
      const item = makeItem(h);
      if (item && !existing.has(item.url)) {
        queue.push(item);
        existing.add(item.url);
        added += 1;
      }
    });
    return added;
  }

  function getCurrentFolderFileHandles() {
    if (window.M && window.M.currentdirid && window.M.d && window.M.d[window.M.currentdirid]) {
      return getAllDescendantFileHandles(window.M.currentdirid);
    }
    if (window.M && Array.isArray(window.M.v)) {
      return window.M.v.filter((n) => n && n.t !== 1).map((n) => n.h).filter(Boolean);
    }
    return [];
  }

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  function getScrollableContainers() {
    const selectors = [
      ".fm-right-files-block",
      ".files-grid-view",
      ".grid-scrolling-table",
      ".file-block-scrolling",
      ".megaListContainer",
      ".megaList",
      ".ps",
    ];

    const elements = [];
    selectors.forEach((s) => {
      document.querySelectorAll(s).forEach((el) => elements.push(el));
    });

    // Fallback: any scrollable element that looks like a list viewport.
    document.querySelectorAll("div").forEach((el) => {
      const cs = window.getComputedStyle(el);
      const canScroll = (cs.overflowY === "auto" || cs.overflowY === "scroll") && el.scrollHeight > el.clientHeight + 80;
      if (canScroll) elements.push(el);
    });

    return [...new Set(elements)].filter((el) => el && el.scrollHeight > el.clientHeight + 20);
  }

  async function hydrateVisibleListWithoutManualScroll() {
    const containers = getScrollableContainers();
    if (!containers.length) return;

    let lastCount = getCurrentFolderFileHandles().length;
    let stableRounds = 0;

    for (let round = 0; round < 45 && stableRounds < 4; round += 1) {
      containers.forEach((el) => {
        // Scroll in chunks so virtualized rows get mounted and added into MEGA's in-memory nodes.
        const nextTop = Math.min(el.scrollTop + Math.max(300, el.clientHeight), el.scrollHeight);
        el.scrollTop = nextTop;
        el.dispatchEvent(new Event("scroll", { bubbles: true }));
      });

      await sleep(160);

      const count = getCurrentFolderFileHandles().length;
      if (count > lastCount) {
        lastCount = count;
        stableRounds = 0;
      } else {
        stableRounds += 1;
      }

      updateStatus("Loading hidden rows... found " + count + " file handles");
    }
  }

  async function autoScanAll() {
    if (scanning) return;
    scanning = true;
    updateStatus("Scanning all files in current folder tree...");

    // MEGA desktop virtualizes long lists. This forces lazy rows to load without manual scrolling.
    await hydrateVisibleListWithoutManualScroll();

    const handles = getCurrentFolderFileHandles();
    if (!handles.length) {
      scanning = false;
      updateStatus("No files found in current view/folder.");
      return;
    }

    const existing = new Set(queue.map((x) => x.url));
    let added = 0;

    for (let i = 0; i < handles.length; i += 1) {
      const h = handles[i];
      const item = makeItem(h);
      if (item && !existing.has(item.url)) {
        queue.push(item);
        existing.add(item.url);
        added += 1;
      }
      if (i % 20 === 0 || i === handles.length - 1) {
        renderQueue();
        updateStatus("Scanning " + (i + 1) + "/" + handles.length + " files...");
      }
      await new Promise((resolve) => setTimeout(resolve, 18));
    }

    scanning = false;
    renderQueue();
    updateStatus("Scan complete. Added " + added + " new links. Queue size: " + queue.length + ".");
  }

  function copyText(text) {
    if (typeof GM_setClipboard !== "undefined") {
      GM_setClipboard(text);
      return Promise.resolve();
    }
    return navigator.clipboard.writeText(text);
  }

  function renderQueue() {
    if (!listEl) return;
    if (!queue.length) {
      listEl.innerHTML = '<div style="padding:12px;color:#9a9a9a;">Queue is empty.</div>';
      return;
    }

    const rows = queue.map(function (item, index) {
      return '<label style="display:grid;grid-template-columns:30px 1.6fr 1fr 0.8fr 0.9fr;gap:10px;padding:10px;border-bottom:1px solid #2b2b2b;align-items:center;">'
        + '<input class="mq-check" type="checkbox" data-index="' + index + '" ' + (item.checked ? 'checked' : '') + ' style="height:18px;width:18px;" />'
        + '<div title="' + escapeHtml(item.name) + '" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#fff;font-size:12px;">' + escapeHtml(item.name) + '</div>'
        + '<div title="' + escapeHtml(item.url) + '" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8bf4ff;font-size:11px;">' + escapeHtml(item.url) + '</div>'
        + '<div style="font-size:11px;color:#ccc;">' + escapeHtml(item.size) + '</div>'
        + '<div style="font-size:11px;color:#ccc;">' + escapeHtml(item.length) + ' | ' + escapeHtml(item.resolution) + '</div>'
        + '</label>';
    }).join("");

    listEl.innerHTML = rows;
    listEl.querySelectorAll(".mq-check").forEach(function (el) {
      el.onchange = function () {
        const i = Number(el.getAttribute("data-index"));
        if (queue[i]) queue[i].checked = el.checked;
      };
    });
  }

  function updateStatus(message) {
    const el = document.getElementById("mq-status");
    if (el) el.textContent = message;
  }

  function initUi() {
    if (document.getElementById("mq-launch")) return;

    const launch = document.createElement("button");
    launch.id = "mq-launch";
    launch.textContent = "MEGA Queue";
    Object.assign(launch.style, {
      position: "fixed",
      bottom: "20px",
      left: "20px",
      zIndex: "2147483647",
      padding: "12px 16px",
      borderRadius: "10px",
      border: "1px solid #fff",
      background: "#cf1111",
      color: "#fff",
      fontWeight: "700",
      cursor: "pointer",
    });

    panel = document.createElement("div");
    panel.id = "mq-panel";
    Object.assign(panel.style, {
      position: "fixed",
      left: "20px",
      bottom: "72px",
      width: "min(920px, calc(100vw - 40px))",
      maxHeight: "78vh",
      zIndex: "2147483647",
      background: "#121212",
      color: "#fff",
      border: "1px solid #3c3c3c",
      borderRadius: "12px",
      padding: "12px",
      display: "none",
      boxShadow: "0 10px 40px rgba(0,0,0,0.45)",
      fontFamily: "Arial, sans-serif",
    });

    panel.innerHTML = ''
      + '<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;">'
      + '  <strong style="font-size:15px;">MEGA Desktop Queue</strong>'
      + '  <button id="mq-close" style="border:0;background:transparent;color:#fff;font-size:20px;cursor:pointer;">x</button>'
      + '</div>'
      + '<div id="mq-status" style="margin-top:8px;color:#d0d0d0;font-size:12px;">Ready.</div>'
      + '<div id="mq-list" style="margin-top:10px;height:340px;overflow:auto;border:1px solid #2f2f2f;border-radius:8px;background:#070707;"></div>'
      + '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:10px;">'
      + '  <button id="mq-add-selected" style="padding:10px;border:0;border-radius:8px;background:#c00000;color:#fff;font-weight:700;cursor:pointer;">Add selected</button>'
      + '  <button id="mq-scan-all" style="padding:10px;border:0;border-radius:8px;background:#0058d9;color:#fff;font-weight:700;cursor:pointer;">Auto scan all files</button>'
      + '  <button id="mq-copy-selected" style="padding:10px;border:0;border-radius:8px;background:#008f47;color:#fff;font-weight:700;cursor:pointer;">Copy selected</button>'
      + '  <button id="mq-copy-all" style="padding:10px;border:0;border-radius:8px;background:#6d38d3;color:#fff;font-weight:700;cursor:pointer;">Copy all</button>'
      + '  <button id="mq-open-selected" style="padding:10px;border:0;border-radius:8px;background:#777;color:#fff;font-weight:700;cursor:pointer;">Open selected</button>'
      + '  <button id="mq-toggle" style="padding:10px;border:0;border-radius:8px;background:#444;color:#fff;font-weight:700;cursor:pointer;">Toggle all checks</button>'
      + '  <button id="mq-clear" style="padding:10px;border:0;border-radius:8px;background:#444;color:#fff;font-weight:700;cursor:pointer;">Clear queue</button>'
      + '</div>';

    document.body.appendChild(panel);
    document.body.appendChild(launch);
    listEl = document.getElementById("mq-list");

    launch.onclick = function () {
      panel.style.display = panel.style.display === "none" ? "block" : "none";
    };
    document.getElementById("mq-close").onclick = function () { panel.style.display = "none"; };

    document.getElementById("mq-add-selected").onclick = function () {
      const selected = getSelectedHandles();
      const fileHandles = [];
      selected.forEach((h) => {
        const node = getNode(h);
        if (node && node.t === 1) fileHandles.push(...getAllDescendantFileHandles(h));
        else fileHandles.push(h);
      });

      const added = pushUnique([...new Set(fileHandles)]);
      renderQueue();
      updateStatus("Added " + added + " links from " + selected.length + " selected items.");
    };

    document.getElementById("mq-scan-all").onclick = autoScanAll;

    document.getElementById("mq-copy-selected").onclick = function () {
      const links = queue.filter((x) => x.checked).map((x) => x.url).join("\n");
      if (!links) return updateStatus("No checked links.");
      copyText(links).then(function () { updateStatus("Copied checked links."); });
    };

    document.getElementById("mq-copy-all").onclick = function () {
      const links = queue.map((x) => x.url).join("\n");
      if (!links) return updateStatus("Queue is empty.");
      copyText(links).then(function () { updateStatus("Copied all links."); });
    };

    document.getElementById("mq-open-selected").onclick = function () {
      const selected = queue.filter((x) => x.checked);
      if (!selected.length) return updateStatus("No checked links.");
      selected.forEach((item) => {
        try {
          if (typeof GM_openInTab !== "undefined") GM_openInTab(item.url, { active: false, insert: true, setParent: true });
          else window.open(item.url, "_blank", "noopener,noreferrer");
        } catch (e) {}
      });
      updateStatus("Opened " + selected.length + " links.");
    };

    document.getElementById("mq-toggle").onclick = function () {
      const allChecked = queue.length > 0 && queue.every((x) => x.checked);
      queue.forEach((x) => { x.checked = !allChecked; });
      renderQueue();
    };

    document.getElementById("mq-clear").onclick = function () {
      queue = [];
      renderQueue();
      updateStatus("Queue cleared.");
    };

    renderQueue();
  }

  setInterval(function () {
    if (!document.body) return;
    initUi();
  }, 700);
})();